phase: 106_document_dates
Build and Push Containers / build-and-push-app (push) Successful in 4m35s
Build and Push Containers / build-and-push-db (push) Successful in 14s

Everything is verified green. Final report:

**Phase 106 — Document dates (final verification pass; all 10 tasks already complete)**

- Verified all phase artifacts: alembic `0020` (dev DB at `0020`), `doc_dates.py`, git `file_commit_dates`, importer `doc_dates_by_root`/`dates_updated`, both entry-point wirings, date APIs + tree `created_at`/`updated_at`, LLM surfaces (prompt block, `read` line 2, appended `ls` field), `apply_recency_boost` in `retrieve()`, UI columns/badge, admin editor, mock-LLM regex — all present and correct; no defects found, no fixes needed.
- `uv run pytest --cov=app --cov-report=term-missing` → **2299 passed, TOTAL 99%** (>90% ✓)
- `uv run pytest tests/e2e/test_document_dates.py -v --no-cov` → **6/6 passed** in isolation (DB up)
- 12 regression E2E suites (retrieval_quality, whole_document_context, agent_document_tools, ls_tree_drilldown, read_truncation_cap, kb_tree, kb_tree_nav, document_viewer, edit_summaries, import_documents, sync_button, hidden_folders_toggle, smoke) → **all green in isolation**
- `uv run ruff check .` → clean; `uv run pyright` → **0 errors, 0 warnings**

**Completion criteria:** 1) non-null `created_at` + 0020 upgrade/downgrade on dev DB ✓ (real-Alembic integration tests) 2) sync refresh/older/manual-persists/content-reset/no sources_meta bump ✓ 3) zip/tar mtime + future→today ✓ 4) LLM date surfaces + cross-check ✓ 5) UI Created/Updated/badge positions ✓ 6) admin editor set+revert round-trip ✓ 7) old-correct-beats-new-similar (defaults & boost-off) + near-tie + `BOR_RECENCY_BOOST=0` byte-identical ✓ 8) full gate ✓ 9) commit/phase-move — left to harness per instructions.

- **Notable:** recency default tuned 0.001 → **0.0007** (task 07 step 5 explicitly permits; measured margins recorded in `test_recency_boost.py` docstring).
- **Next pending phase:** none — `todo/` holds only this phase.
This commit is contained in:
2026-09-13 19:28:05 -04:00
parent cec819743d
commit ee3efb28c9
113 changed files with 8228 additions and 344 deletions
+85 -51
View File
@@ -37,6 +37,7 @@ import logging
import uuid
from collections.abc import AsyncGenerator, AsyncIterator, Sequence
from copy import deepcopy
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, cast
import pytest
@@ -72,6 +73,13 @@ def _settings(**kwargs: Any) -> Settings:
return Settings(**kwargs) # pyright: ignore[reportCallIssue]
#: The fixture documents' fixed creation date (phase 106, D5): the
#: agent formats ``doc.created_at`` on the ``read`` result's second
#: line — the detached fixture rows carry it exactly as the NOT NULL
#: DB column guarantees it for real rows.
_FIXTURE_CREATED_AT = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC)
def _doc(source: str, path: str, title: str = "Title", content: str = "CONTENT") -> Document:
return Document(
id=uuid.uuid4(),
@@ -81,6 +89,7 @@ def _doc(source: str, path: str, title: str = "Title", content: str = "CONTENT")
title=title,
content=content,
content_hash="0" * 64,
created_at=_FIXTURE_CREATED_AT,
)
@@ -438,7 +447,9 @@ def test_ls_then_read_then_answer(monkeypatch: pytest.MonkeyPatch) -> None:
# lookup, no self-correction candidates (phase 70).
assert calls == [("Homelab", "aws-route53.md")]
# The follow-up request carries the assistant tool-call + tool result.
# The follow-up request carries the assistant tool-call + tool
# result (phase 106, D5: the ``date:`` second line rides every
# read — the first line is byte-identical).
msgs = llm.requests[1][0]
assert msgs[0] == {"role": "system", "content": "SYSTEM_PROMPT"}
assert msgs[1] == {"role": "user", "content": "QUESTION"}
@@ -468,7 +479,11 @@ def test_ls_then_read_then_answer(monkeypatch: pytest.MonkeyPatch) -> None:
assert msgs[5] == {
"role": "tool",
"tool_call_id": "call_2",
"content": "Document Homelab/aws-route53.md:\nR53-CONTENT", # full text, no cap
"content": (
"Document Homelab/aws-route53.md:\n"
"date: 2024-06-15\n"
"R53-CONTENT"
), # full text, no cap (date: the D5 second line)
}
@@ -641,14 +656,14 @@ def test_ls_source_scope_lists_root_folder(monkeypatch: pytest.MonkeyPatch) -> N
"""A registered source name (no ``/``): the source's ROOT folder —
subfolders (2-space-indented, path order, ``: {summary}`` only when
stored) + the root's own file lines in EXACTLY the
``source: X | path: Y | title: Z`` format — the pinned template,
counted."""
``source: X | path: Y | title: Z | date: YYYY-MM-DD`` format (the
phase-106 D5 appended date field) — the pinned template, counted."""
monkeypatch.setattr(
agent,
"ls_folder",
lambda db, source, folder: (
[("backups", 2, "Backup notes."), ("networking", 1, None)],
[("Homelab", "readme.md", "Readme")],
[("Homelab", "readme.md", "Readme", "2024-06-15")],
1,
),
)
@@ -665,7 +680,7 @@ def test_ls_source_scope_lists_root_folder(monkeypatch: pytest.MonkeyPatch) -> N
" backups/ — 2 documents: Backup notes.\n"
" networking/ — 1 documents\n"
"\n"
"source: Homelab | path: readme.md | title: Readme"
"source: Homelab | path: readme.md | title: Readme | date: 2024-06-15"
)
assert holder.tool_calls == 1
@@ -677,11 +692,11 @@ def test_ls_nested_folder_scope_lists_one_level_deeper(
lines, identity = ``source/folder`` (the same template as the
root), counted; the fetchers are the source-scoped ones."""
def _rows(db: Any, source: str) -> list[tuple[str, str]]:
def _rows(db: Any, source: str) -> list[tuple[str, str, str]]:
assert (source, db) == ("Homelab", None)
return [
("networking/lan.md", "LAN"),
("networking/vpn.md", "VPN"),
("networking/lan.md", "LAN", "2024-06-15"),
("networking/vpn.md", "VPN", "2024-06-15"),
]
monkeypatch.setattr(agent, "_source_document_rows", _rows)
@@ -702,8 +717,8 @@ def test_ls_nested_folder_scope_lists_one_level_deeper(
assert llm.requests[1][0][3]["content"] == (
"Homelab/networking — 2 documents, 0 folders:\n"
"\n"
"source: Homelab | path: networking/lan.md | title: LAN\n"
"source: Homelab | path: networking/vpn.md | title: VPN"
"source: Homelab | path: networking/lan.md | title: LAN | date: 2024-06-15\n"
"source: Homelab | path: networking/vpn.md | title: VPN | date: 2024-06-15"
)
assert holder.tool_calls == 1
@@ -812,9 +827,9 @@ def test_ls_unknown_top_level_folder_gets_not_a_folder_teaching(
agent,
"_source_document_rows",
lambda db, source: [
("backups/cron.md", "Cron"),
("containers/caddy.md", "Caddy"),
("networking/lan.md", "LAN"),
("backups/cron.md", "Cron", "2024-06-15"),
("containers/caddy.md", "Caddy", "2024-06-15"),
("networking/lan.md", "LAN", "2024-06-15"),
],
)
monkeypatch.setattr(agent, "_source_folder_summaries", lambda db, source: {})
@@ -847,9 +862,9 @@ def test_ls_unknown_nested_folder_gets_not_a_folder_with_nested_parent(
agent,
"_source_document_rows",
lambda db, source: [
("networking/lan/a.md", "A"),
("networking/vpn/b.md", "B"),
("readme.md", "Readme"),
("networking/lan/a.md", "A", "2024-06-15"),
("networking/vpn/b.md", "B", "2024-06-15"),
("readme.md", "Readme", "2024-06-15"),
],
)
monkeypatch.setattr(agent, "_source_folder_summaries", lambda db, source: {})
@@ -878,7 +893,10 @@ def test_ls_file_path_scope_gets_not_a_folder(monkeypatch: pytest.MonkeyPatch) -
monkeypatch.setattr(
agent,
"_source_document_rows",
lambda db, source: [("notes.md", "Notes"), ("a/b.md", "B")],
lambda db, source: [
("notes.md", "Notes", "2024-06-15"),
("a/b.md", "B", "2024-06-15"),
],
)
monkeypatch.setattr(agent, "_source_folder_summaries", lambda db, source: {})
monkeypatch.setattr(agent, "list_source_names", lambda db: ["S"])
@@ -937,9 +955,9 @@ def test_ls_folder_composes_the_fetchers(monkeypatch: pytest.MonkeyPatch) -> Non
monkeypatch surface)."""
seen: list[tuple[str, str, str]] = []
def _rows(db: Any, source: str) -> list[tuple[str, str]]:
def _rows(db: Any, source: str) -> list[tuple[str, str, str]]:
seen.append(("rows", source, ""))
return [("a/b.md", "B"), ("a.md", "A")]
return [("a/b.md", "B", "2024-06-15"), ("a.md", "A", "2024-06-15")]
def _summaries(db: Any, source: str) -> dict[str, str]:
seen.append(("summaries", source, ""))
@@ -949,7 +967,7 @@ def test_ls_folder_composes_the_fetchers(monkeypatch: pytest.MonkeyPatch) -> Non
monkeypatch.setattr(agent, "_source_folder_summaries", _summaries)
assert agent.ls_folder(cast("Session", object()), "S", "") == (
[("a", 1, "A stuff.")],
[("S", "a.md", "A")],
[("S", "a.md", "A", "2024-06-15")],
1,
)
assert seen == [("rows", "S", ""), ("summaries", "S", "")]
@@ -961,10 +979,10 @@ def test_group_folder_listing_subfolder_recursion_and_counts() -> None:
counts for BOTH ``a`` and ``a/b``), path order, the stored summary
attached or ``None``."""
rows = [
("a/b/c.md", "C"),
("a/b/d.md", "D"),
("a/e.md", "E"),
("f.md", "F"),
("a/b/c.md", "C", "2024-06-15"),
("a/b/d.md", "D", "2024-06-15"),
("a/e.md", "E", "2024-06-15"),
("f.md", "F", "2024-06-15"),
]
sub, files, total = agent.group_folder_listing(
"S", "", rows, {"a": "A subtree.", "a/b": "B subtree."}
@@ -974,7 +992,7 @@ def test_group_folder_listing_subfolder_recursion_and_counts() -> None:
# recursive subtree (a/e.md + a/b/c.md + a/b/d.md), the stored
# summary attached.
assert sub == [("a", 3, "A subtree.")]
assert files == [("S", "f.md", "F")]
assert files == [("S", "f.md", "F", "2024-06-15")]
assert total == 1
# One level down: a/b is a's direct subfolder with its own count.
sub2, _files2, _total2 = agent.group_folder_listing("S", "a", rows, {"a/b": "B subtree."})
@@ -987,13 +1005,13 @@ def test_group_folder_listing_nested_level_counts_and_membership() -> None:
file of ``a``) — membership is the folder_of rule, order is path
order."""
rows = [
("a/b/c.md", "C"),
("a/b/d.md", "D"),
("a/e.md", "E"),
("a/b/c.md", "C", "2024-06-15"),
("a/b/d.md", "D", "2024-06-15"),
("a/e.md", "E", "2024-06-15"),
]
sub, files, total = agent.group_folder_listing("S", "a", rows, {})
assert sub == [("a/b", 2, None)]
assert files == [("S", "a/e.md", "E")]
assert files == [("S", "a/e.md", "E", "2024-06-15")]
assert total == 1
@@ -1004,18 +1022,18 @@ def test_group_folder_listing_file_path_is_not_a_folder() -> None:
sharing a real folder's name counts for that folder, the existence
rule intact)."""
rows = [
("a.md", "A"), # a file at the root, and a folder name? NO —
("b/x.md", "X"), # nothing starts with "a.md/"
("a.md", "A", "2024-06-15"), # a file at the root, and a folder name? NO —
("b/x.md", "X", "2024-06-15"), # nothing starts with "a.md/"
]
sub, files, total = agent.group_folder_listing("S", "", rows, {})
assert sub == [("b", 1, None)] # "a.md" is NOT a subfolder
assert files == [("S", "a.md", "A")] # b/x.md is NOT a direct root file
assert files == [("S", "a.md", "A", "2024-06-15")] # b/x.md is NOT a direct root file
assert total == 1
# The path == folder arm: a doc named "a" under a real folder "a/".
rows2 = [("a", "FileA"), ("a/c.md", "C")]
rows2 = [("a", "FileA", "2024-06-15"), ("a/c.md", "C", "2024-06-15")]
sub2, files2, total2 = agent.group_folder_listing("S", "", rows2, {})
assert sub2 == [("a", 2, None)] # the file "a" counts for folder "a"
assert files2 == [("S", "a", "FileA")] # …and is a direct ROOT file
assert files2 == [("S", "a", "FileA", "2024-06-15")] # …and is a direct ROOT file
assert total2 == 1
@@ -1023,14 +1041,14 @@ def test_group_folder_listing_caps_files_at_fifty_keeps_the_total() -> None:
"""The cap: 51 direct files → 50 file lines + the PRE-cap total (51)
for the renderer's note; 50 files → 50 lines, no note material.
A 500-file folder costs 50 lines, never 500."""
rows51 = [(f"big/f{i:03d}.md", f"T{i}") for i in range(51)]
rows51 = [(f"big/f{i:03d}.md", f"T{i}", "2024-06-15") for i in range(51)]
sub, files, total = agent.group_folder_listing("S", "big", rows51, {})
assert sub == []
assert total == 51
assert len(files) == 50
assert files[0] == ("S", "big/f000.md", "T0")
assert files[-1] == ("S", "big/f049.md", "T49")
rows50 = [(f"big/f{i:03d}.md", f"T{i}") for i in range(50)]
assert files[0] == ("S", "big/f000.md", "T0", "2024-06-15")
assert files[-1] == ("S", "big/f049.md", "T49", "2024-06-15")
rows50 = [(f"big/f{i:03d}.md", f"T{i}", "2024-06-15") for i in range(50)]
_sub, files50, total50 = agent.group_folder_listing("S", "big", rows50, {})
assert total50 == 50 and len(files50) == 50
@@ -1056,7 +1074,7 @@ def test_render_folder_listing_root_template() -> None:
agent.render_folder_listing(
"Homelab",
[("backups", 2, "Backup notes."), ("networking", 1, None)],
[("Homelab", "readme.md", "Readme")],
[("Homelab", "readme.md", "Readme", "2024-06-15")],
1,
)
== "Homelab — 1 documents, 2 folders:\n"
@@ -1064,7 +1082,7 @@ def test_render_folder_listing_root_template() -> None:
" backups/ — 2 documents: Backup notes.\n"
" networking/ — 1 documents\n"
"\n"
"source: Homelab | path: readme.md | title: Readme"
"source: Homelab | path: readme.md | title: Readme | date: 2024-06-15"
)
@@ -1090,7 +1108,7 @@ def test_render_folder_listing_cap_note_only_past_fifty() -> None:
cap: 51 → 50 lines + the deterministic grep-pointer note (the
``…and 1 more…`` shape — unpluralized, the house pin); 50 → no
note."""
files51 = [("S", f"f{i:03d}.md", f"T{i}") for i in range(51)]
files51 = [("S", f"f{i:03d}.md", f"T{i}", "2024-06-15") for i in range(51)]
capped = files51[:50]
rendered = agent.render_folder_listing("S/big", [], capped, 51)
lines = rendered.splitlines()
@@ -1100,9 +1118,11 @@ def test_render_folder_listing_cap_note_only_past_fifty() -> None:
"…and 1 more documents in this folder — use grep (pattern) to "
"find a specific one."
)
files50 = [("S", f"f{i:03d}.md", f"T{i}") for i in range(50)]
files50 = [("S", f"f{i:03d}.md", f"T{i}", "2024-06-15") for i in range(50)]
rendered50 = agent.render_folder_listing("S/big", [], files50, 50)
assert rendered50.splitlines()[-1] == "source: S | path: f049.md | title: T49"
assert rendered50.splitlines()[-1] == (
"source: S | path: f049.md | title: T49 | date: 2024-06-15"
)
assert "more documents" not in rendered50
@@ -1145,7 +1165,9 @@ def test_read_combined_path_resolves_and_returns_full_content(
assert holder.read_docs == [doc]
assert holder.tool_calls == 1
assert llm.requests[1][0][3]["content"] == (
"Document Homelab/active/container_caddy/caddy.md:\nCADDY-CONTENT"
"Document Homelab/active/container_caddy/caddy.md:\n"
"date: 2024-06-15\n"
"CADDY-CONTENT"
)
@@ -1533,7 +1555,9 @@ def test_reading_an_already_read_doc_is_deduped(monkeypatch: pytest.MonkeyPatch)
asyncio.run(_run(llm, holder, _settings()))
assert holder.read_docs == [doc] # appended exactly once
assert holder.tool_calls == 1 # the re-read counts nothing
assert llm.requests[1][0][3]["content"] == "Document S/a.md:\nA-CONTENT"
assert llm.requests[1][0][3]["content"] == (
"Document S/a.md:\ndate: 2024-06-15\nA-CONTENT"
)
assert llm.requests[2][0][5]["content"] == agent.ALREADY_IN_CONTEXT
# Rejected → the tools are still offered on the next request…
assert llm.requests[2][1] == AGENT_TOOLS
@@ -1558,8 +1582,11 @@ def test_read_exactly_at_cap_is_byte_identical_and_untruncated(
[StreamPiece("content", "ans")],
)
out = asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
# Byte-identical to today's read result (no marker, no notice).
assert llm.requests[1][0][3]["content"] == "Document S/big.md:\n" + content
# The read result plus the phase-106 D5 date line (no marker, no
# notice).
assert llm.requests[1][0][3]["content"] == (
"Document S/big.md:\ndate: 2024-06-15\n" + content
)
assert TRUNCATION_MARKER not in llm.requests[1][0][3]["content"]
# No truncation recorded, none surfaced to the loop.
assert holder.read_truncations == []
@@ -1589,6 +1616,7 @@ def test_read_at_cap_plus_one_truncates_with_marker_and_notice(
asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
expected = (
"Document S/big.md:\n"
"date: 2024-06-15\n"
+ content[:cap]
+ "\n"
+ TRUNCATION_MARKER
@@ -2247,7 +2275,9 @@ def test_grep_counts_but_never_adds_context(monkeypatch: pytest.MonkeyPatch) ->
assert holder.tool_calls == 2 # grep + read, both executed
assert holder.read_docs == [doc] # only the read added context (A5)
assert llm.requests[1][0][3]["content"] == "S/a.md:1: needle here"
assert llm.requests[2][0][5]["content"] == "Document S/a.md:\nneedle here"
assert llm.requests[2][0][5]["content"] == (
"Document S/a.md:\ndate: 2024-06-15\nneedle here"
)
# ---------- unlimited calls: re-lists and multi-reads (phase 45) ----------
@@ -2292,8 +2322,12 @@ def test_multi_read_executes_without_budgets(monkeypatch: pytest.MonkeyPatch) ->
asyncio.run(_run(llm, holder, _settings()))
assert holder.read_docs == [a, b] # both reads appended, in order
assert holder.tool_calls == 2
assert llm.requests[1][0][3]["content"] == "Document S/a.md:\nA-CONTENT"
assert llm.requests[2][0][5]["content"] == "Document S/b.md:\nB-CONTENT"
assert llm.requests[1][0][3]["content"] == (
"Document S/a.md:\ndate: 2024-06-15\nA-CONTENT"
)
assert llm.requests[2][0][5]["content"] == (
"Document S/b.md:\ndate: 2024-06-15\nB-CONTENT"
)
assert llm.requests[2][1] == AGENT_TOOLS # the second read was still offered
+120
View File
@@ -0,0 +1,120 @@
"""Unit: phase 106 task 03 — mtime-preserving archive unpack (D2).
Pins the date source of the upload path: a zip member's DOS
``date_time`` and a tar member's ``mtime`` survive the unpack as the
extracted file's atime+mtime, so the importer (task 04) reads the
archive's ORIGINAL file dates — the owner's "file metadata (hopefully)
preserved in the tar or zip archive process" made real.
Regular files only: directories/symlinks/hardlinks are untouched (they
carry the extraction-time values, never the member's). The safety
behavior (zip-slip, absolute members, link targets, the extraction
cap) is unchanged and pinned by ``tests/unit/test_archive_upload.py``
— this file adds only the date pins.
"""
from __future__ import annotations
import os
import tarfile
import zipfile
from datetime import UTC, datetime
from io import BytesIO
from pathlib import Path
from app.rag.archive_upload import unpack_archive
#: The old member timestamp both fixtures carry: 2020-01-02 03:04:06
#: UTC — the zip as the DOS tuple ``(2020, 1, 2, 3, 4, 6)``, the tar
#: as the epoch seconds.
OLD_MTIME = datetime(2020, 1, 2, 3, 4, 6, tzinfo=UTC)
OLD_EPOCH = OLD_MTIME.timestamp() # 1577934246.0
#: mtime granularity tolerance (the task pin: ±1 s).
_TOLERANCE_S = 1.0
_PAYLOAD = b"# Old note\ncontent from 2020\n"
def _zip_with_old_mtime(path: Path) -> None:
"""A two-member zip (a directory + one file), both carrying the
old fixed ``date_time`` tuple."""
with zipfile.ZipFile(path, "w") as zf:
dir_info = zipfile.ZipInfo("docs/", date_time=(2020, 1, 2, 3, 4, 6))
dir_info.external_attr = (0o40755 << 16)
zf.writestr(dir_info, b"")
file_info = zipfile.ZipInfo("docs/note.md", date_time=(2020, 1, 2, 3, 4, 6))
file_info.external_attr = (0o100644 << 16)
zf.writestr(file_info, _PAYLOAD)
def _tar_with_old_mtime(path: Path) -> None:
"""A two-member tar (a directory + one file), both carrying the
old epoch ``mtime``."""
with tarfile.open(path, "w") as tf:
dir_ti = tarfile.TarInfo("docs/")
dir_ti.type = tarfile.DIRTYPE
dir_ti.mode = 0o755
dir_ti.mtime = OLD_EPOCH
tf.addfile(dir_ti)
file_ti = tarfile.TarInfo("docs/note.md")
file_ti.size = len(_PAYLOAD)
file_ti.mode = 0o644
file_ti.mtime = OLD_EPOCH
tf.addfile(file_ti, BytesIO(_PAYLOAD))
def _extract(archive: Path, tmp_path: Path) -> tuple[Path, os.stat_result]:
"""Unpack ``archive``; return (file path, pre-read stat). The stat
happens BEFORE the content read (a read refreshes atime under
relatime — the atime pin needs the unpacked value); the content is
asserted byte-identical (the mtime work moved no bytes)."""
target = tmp_path / "out"
unpack_archive(archive, target, max_extract_bytes=1 << 20)
dest = target / "docs" / "note.md"
st = dest.stat() # before the read — reading would refresh atime
assert dest.read_bytes() == _PAYLOAD
return dest, st
def test_zip_member_mtime_is_restored(tmp_path: Path) -> None:
"""A zip member's DOS ``date_time`` lands as the extracted file's
mtime (and atime — ``os.utime(ns=(t, t))`` sets both), within the
mtime-granularity tolerance."""
archive = tmp_path / "old.zip"
_zip_with_old_mtime(archive)
_dest, st = _extract(archive, tmp_path)
assert abs(st.st_mtime - OLD_EPOCH) <= _TOLERANCE_S
assert abs(st.st_atime - OLD_EPOCH) <= _TOLERANCE_S
def test_tar_member_mtime_is_restored(tmp_path: Path) -> None:
"""A tar member's epoch ``mtime`` lands as the extracted file's
mtime (and atime), within the mtime-granularity tolerance."""
archive = tmp_path / "old.tar"
_tar_with_old_mtime(archive)
_dest, st = _extract(archive, tmp_path)
assert abs(st.st_mtime - OLD_EPOCH) <= _TOLERANCE_S
assert abs(st.st_atime - OLD_EPOCH) <= _TOLERANCE_S
def test_zip_directory_member_mtime_is_not_restored(tmp_path: Path) -> None:
"""Regular files ONLY: a zip directory member carrying the old
``date_time`` keeps the EXTRACTION-time mtime (≈ now, long after
the old 2020 value) — directories are never indexed, so their
dates don't matter; the pin is that the unpacker doesn't utime
them (the task contract)."""
archive = tmp_path / "old.zip"
_zip_with_old_mtime(archive)
target = tmp_path / "out"
unpack_archive(archive, target, max_extract_bytes=1 << 20)
assert (target / "docs").stat().st_mtime > OLD_EPOCH + _TOLERANCE_S
def test_tar_directory_member_mtime_is_not_restored(tmp_path: Path) -> None:
"""The tar twin of the zip directory pin: a directory member with
the old ``mtime`` is not utime'd (regular files only)."""
archive = tmp_path / "old.tar"
_tar_with_old_mtime(archive)
target = tmp_path / "out"
unpack_archive(archive, target, max_extract_bytes=1 << 20)
assert (target / "docs").stat().st_mtime > OLD_EPOCH + _TOLERANCE_S
+5
View File
@@ -29,6 +29,7 @@ import logging
import time
import uuid
from collections.abc import Callable, Iterator, MutableMapping
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any
@@ -52,6 +53,10 @@ def _doc(title: str, content: str) -> Document:
title=title,
content=content,
content_hash="0" * 64,
# Phase 106, D5: the HIGH block formats the row's created_at
# UTC date part — the detached fixture carries it (the NOT NULL
# DB column guarantees it for real rows).
created_at=datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC),
)
+5
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import json
import uuid
from collections.abc import Iterator
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
import pytest
@@ -51,6 +52,10 @@ def _doc(title: str, content: str) -> Document:
title=title,
content=content,
content_hash="0" * 64,
# Phase 106, D5: the HIGH block formats the row's created_at
# UTC date part — the detached fixture carries it (the NOT NULL
# DB column guarantees it for real rows).
created_at=datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC),
)
+45
View File
@@ -37,6 +37,10 @@ def test_defaults_match_locked_decisions(monkeypatch: pytest.MonkeyPatch) -> Non
assert s.hybrid_lexical_candidates >= 1
assert s.rrf_k >= 1
assert s.top_n_docs >= 1
# Phase 106, D6: the recency boost is ON by default (0.0007 — the
# fine-line-tuned value, task 07) with a 365-day decay timescale.
assert s.recency_boost == 0.0007
assert s.recency_half_life_days == 365
# Owner instruction 2026-08-22: answers may run up to 32 768 tokens.
assert s.max_output_tokens == 32_768
# Phase 17: the model's thinking streams by default (kill-switch off).
@@ -235,6 +239,47 @@ def test_read_max_chars_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> Non
_settings()
def test_recency_boost_default_and_env_override(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 106, D6: the recency boost on the RRF-fused score is ON by
default (0.0007 — the fine-line-tuned value, task 07; ``0`` is the
byte-identical kill switch) with a 365-day decay timescale; both
env-tunable so the owner re-tunes live."""
monkeypatch.delenv("BOR_RECENCY_BOOST", raising=False)
monkeypatch.delenv("BOR_RECENCY_HALF_LIFE_DAYS", raising=False)
s = _settings()
assert s.recency_boost == 0.0007
assert s.recency_half_life_days == 365
monkeypatch.setenv("BOR_RECENCY_BOOST", "0")
monkeypatch.setenv("BOR_RECENCY_HALF_LIFE_DAYS", "90")
s = _settings()
assert s.recency_boost == 0.0
assert s.recency_half_life_days == 90
monkeypatch.setenv("BOR_RECENCY_BOOST", "0.002")
assert _settings().recency_boost == 0.002
def test_recency_boost_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> None:
"""``0`` is the kill switch — a NEGATIVE boost would demote fresh
documents (the exact opposite of D6), so the validator fails loudly
at startup naming the field (the ``agent_max_rounds`` pattern)."""
monkeypatch.setenv("BOR_RECENCY_BOOST", "-0.001")
with pytest.raises(ValidationError, match="recency_boost"):
_settings()
def test_recency_half_life_rejects_non_positive(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A ``0``/negative decay timescale would divide the exponent by
zero — the validator fails loudly at startup naming the field."""
for bad in ("0", "-365"):
monkeypatch.setenv("BOR_RECENCY_HALF_LIFE_DAYS", bad)
with pytest.raises(ValidationError, match="recency_half_life_days"):
_settings()
def test_stream_thinking_default_true_and_env_parse(monkeypatch: pytest.MonkeyPatch) -> None:
"""Phase 17 kill-switch (``BOR_STREAM_THINKING``): on by default,
``0``/``false`` turn the ``thinking`` SSE frames off."""
+470
View File
@@ -0,0 +1,470 @@
"""Unit: the admin-only date editor in the viewer (phase 106, task 09,
D7).
The owner asked for the creation date to be editable "so users can
correct for errors" — admin-only, in the shared viewer core (modal +
``/document.html`` through the ONE ``renderDocument`` — no per-surface
copy), on the phase-57 ``wireSummaryEdit`` idiom (the same
``docAdminReady()`` gate on the module-cached whoami promise — no
second request per page; the public badge row stays byte-for-byte the
task-08 shape for non-admins). The task-05 endpoint
(``PATCH /api/documents/date`` — set + flag manual; null = the D7
CLEAR, the flag drops) is the single source this file cross-checks
against.
The browser behavior itself is E2E-gated by the phase's dedicated
suite (``tests/e2e/test_document_dates.py``, task 10); like the other
frontend-adjacent unit files (the ``test_summary_edit_ui.py`` /
``test_sources_dates.py`` house pattern), this module pins the
source-level contract a silent regression would break:
* the ``docAdminReady()`` gate — ``wireDateEdit`` is called ONLY in
the gate's ``if (admin)`` branch (one call site, after the badge
row is built for everyone — the anonymous DOM is never touched);
* the ``Edit date`` affordance — a real ``type="button"`` with the
``aria-label`` ``Edit creation date: <source>/<path>``
(setAttribute, never innerHTML), inserted AFTER the task-08 Created
badge;
* the editor construction — the button swaps in-place for a box with
a native ``<input type="date">`` (``aria-label="Document
creation date"``, prefilled with the stored date's UTC date part
via ``.value`` — never innerHTML), Save / Cancel text buttons, the
muted "Revert to sync" clear affordance (the phase-57 "clear =
explicit" contrast), a ``role="status"`` live line and a
``role="alert"`` error line;
* the exact PATCH — ``/api/documents/date`` with method PATCH and the
``{source, path, date}`` body; the endpoint string appears exactly
ONCE in the JS (the single-source cross-file check against
``app/api/docs.py``'s route); Revert sends ``date: null``;
* the response-driven re-render — the badge is re-rendered from the
RESPONSE's ``created_at`` (``res.created_at`` — never the
input's optimistic value);
* the §7.4 never-stale lifecycle — the controls disable IMMEDIATELY
on Save/Revert (one PATCH at a time), an EMPTY input disables Save
(the explicit Revert is the only clear path — no accidental
wipes), a failure (non-2xx or network) lands the server detail
(or the canned retry copy) in the ``role="alert"`` line, reverts
the input to the stored date, keeps the editor open, and
re-enables in the ``finally``; the success confirmation lands
AFTER the badge update (the phase-89 last-announce order);
* styles.css — the eight editor classes next to the summary-editor
family, the phase-106 D7 provenance comment with the recorded WCAG
pairs, the ``[hidden]`` override, the ``cursor: wait`` disabled
idiom, the global 3px ``:focus-visible`` ring (no per-control
rule), no CDN.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
DOCS_PY = Path(__file__).resolve().parents[2] / "app" / "api" / "docs.py"
def _js() -> str:
return DOCUMENT_JS.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _fn(js: str, name: str) -> str:
"""The source of a (possibly async, possibly nested) function via
balanced-brace counting. The brace count starts AFTER the
parameter list (a destructured parameter may carry braces of its
own — renderDocument's target object)."""
for prefix in ("async function ", "function "):
start = js.find(f"{prefix}{name}(")
if start != -1:
depth = 0
i = js.find("(", start)
close = i
while i < len(js):
if js[i] == "(":
depth += 1
elif js[i] == ")":
depth -= 1
if depth == 0:
close = i
break
i += 1
brace = js.find("{", close)
depth = 0
for j in range(brace, len(js)):
if js[j] == "{":
depth += 1
elif js[j] == "}":
depth -= 1
if depth == 0:
return js[start : j + 1]
raise AssertionError(f"unbalanced braces in {name}()")
raise AssertionError(f"{name}() must exist in document.js")
# ---------- the admin gate (the phase-57 split) ----------
def test_wire_date_edit_is_called_only_behind_doc_admin_ready() -> None:
"""renderDocument (the ONE shared core — modal + page): the badge
row is built for EVERYONE first (task-08 shape), and the wiring
runs ONLY in the gate's ``if (admin)`` branch on the
module-cached ``docAdminReady()`` promise — the string sequence
``docAdminReady().then`` … ``wireDateEdit``, one call site in
the whole file (definition + call), so a non-admin / token
holder / failed whoami keeps exactly the task-08 badge row (no
button, no wiring, no admin-only network call)."""
js = _js()
render = _fn(js, "renderDocument")
row = render.find("metaEl.replaceChildren(")
gate = render.find("void docAdminReady().then(")
wiring = render.find("if (admin) wireDateEdit(metaEl, doc);")
assert -1 < row < gate < wiring, (
"the badge row is built for everyone BEFORE the admin gate; "
"the wiring is only in the gate's success branch"
)
# The gate's .then lands wireDateEdit (not the summary edit).
assert "wireDateEdit(metaEl, doc)" in render[gate : gate + 120]
# Exactly two occurrences in the whole file: the definition and
# the gated call — no second wiring site (no per-surface copy).
assert js.count("wireDateEdit(") == 2, (
"wireDateEdit has one definition and one (gated) call site"
)
def test_gate_reuses_the_cached_whoami_promise() -> None:
"""docAdminReady() resolves header.js's fetchIsAdmin (the SAME
single request per page the shared header makes — no second
whoami call site in document.js, no admin-only network call for
anonymous visitors)."""
js = _js()
body = _fn(js, "docAdminReady")
assert "await fetchIsAdmin()" in body
assert "=== true" in body
assert 'fetch("/api/whoami")' not in js, (
"whoami must come from the header.js cached promise"
)
# ---------- the Edit date affordance ----------
def test_edit_date_button_is_a_real_button_after_the_created_badge() -> None:
"""The affordance: a real ``type="button"`` with the visible
text "Edit date", the .doc-date-edit class, and the aria-label
``Edit creation date: <source>/<path>`` (setAttribute — the
document-derived pair is user-storable, never innerHTML),
inserted AFTER the task-08 Created badge (the insertion point).
A meta row without the Created badge (should not happen — the
core always builds it) is a no-op, not a crash."""
body = _fn(_js(), "wireDateEdit")
assert 'metaEl.querySelector(".doc-created")' in body
assert "if (!createdBadge) return;" in body
assert 'editBtn.type = "button"' in body
assert 'editBtn.className = "doc-date-edit"' in body
assert 'editBtn.textContent = "Edit date"' in body
assert "editBtn.setAttribute(" in body
assert "Edit creation date: ${doc.source}/${doc.path}" in body, (
"the aria-label is the 'Edit creation date: <source>/<path>' template"
)
assert 'createdBadge.insertAdjacentElement("afterend", editBtn)' in body, (
"the button lands AFTER the Created badge"
)
# ---------- the editor construction ----------
def test_editor_swaps_in_date_input_save_cancel_revert_and_live_lines() -> None:
"""Edit swaps the button for an inline box in the badge row: a
native <input type="date"> (aria-label "Document creation
date", prefilled via ``.value`` with the stored date's UTC date
part — ``new Date(doc.created_at).toISOString().slice(0, 10)`` —
NEVER innerHTML), Save / Cancel real type=buttons, the muted
"Revert to sync" clear affordance, a role=status/aria-live=polite
live line, and a role=alert error line. The order is hide the
button → insert the box after the Created badge → focus the
input (the phase-57 swap pattern)."""
body = _fn(_js(), "wireDateEdit")
assert 'input.type = "date"' in body
assert 'input.className = "doc-date-input"' in body
assert 'input.setAttribute("aria-label", "Document creation date")' in body
assert "new Date(doc.created_at).toISOString().slice(0, 10)" in body, (
"the prefill is the stored date's UTC date part (D3 stores UTC)"
)
assert 'input.value = storedValue()' in body, "prefill via .value (XSS contract)"
assert 'saveBtn.type = "button"' in body
assert 'saveBtn.className = "doc-date-save"' in body
assert 'saveBtn.textContent = "Save"' in body
assert 'cancelBtn.type = "button"' in body
assert 'cancelBtn.className = "doc-date-cancel"' in body
assert 'cancelBtn.textContent = "Cancel"' in body
assert 'revertBtn.type = "button"' in body
assert 'revertBtn.className = "doc-date-revert"' in body
assert 'revertBtn.textContent = "Revert to sync"' in body
assert 'status.className = "doc-date-status"' in body
assert 'status.setAttribute("role", "status")' in body
assert 'status.setAttribute("aria-live", "polite")' in body
assert 'errorLine.className = "doc-date-error"' in body
assert 'errorLine.setAttribute("role", "alert")' in body
hide = body.find("editBtn.hidden = true")
swap = body.find('createdBadge.insertAdjacentElement("afterend", box)')
focus = body.find("input.focus()")
assert -1 < hide < swap < focus, "hide Edit → insert box → focus the input"
# The swap keeps all six editor parts in the box.
assert (
"box.replaceChildren(input, saveBtn, cancelBtn, revertBtn, status, errorLine)"
in body
)
# XSS contract: the whole wiring is textContent/.value only
# (comments stripped — the word may appear in a note, never in code).
code = re.sub(r"//.*?$|/\*.*?\*/", "", body, flags=re.S | re.M)
assert "innerHTML" not in code, "XSS contract: no innerHTML in the wiring"
# ---------- the PATCH round-trip (the single source) ----------
def test_save_patches_the_single_date_endpoint() -> None:
"""Save → PATCH /api/documents/date (the task-05 admin endpoint)
with the EXACT body shape {source, path, date} — the pair from
the doc object, JSON content type. The endpoint string appears
exactly ONCE in the JS (the single-source cross-file check — the
one call site inside the wired editor matches app/api/docs.py's
route; anonymous visitors never have it)."""
js = _js()
assert js.count('"/api/documents/date"') == 1, (
"exactly one occurrence of the endpoint string in document.js"
)
body = _fn(js, "wireDateEdit")
fetch_i = body.find('fetch("/api/documents/date"')
assert fetch_i != -1, "the PATCH must live in the wired editor"
assert 'method: "PATCH"' in body[fetch_i:]
assert '"Content-Type": "application/json"' in body[fetch_i:]
assert "date: dateValue" in body, "the body shape: {source, path, date}"
assert "source: doc.source" in body and "path: doc.path" in body
# The cross-file check: the JS endpoint matches the Python route.
docs = DOCS_PY.read_text(encoding="utf-8")
assert '@router.patch("/documents/date"' in docs, (
"app/api/docs.py registers the route the JS PATCHes"
)
def test_revert_sends_date_null_the_clear_path() -> None:
"""The "Revert to sync" affordance (the D7 CLEAR — the manual
flag drops, the stored date stands until the next sync) sends
``{source, path, date: null}``: the revert binding calls
saveDate(null) — the ONLY null call site."""
body = _fn(_js(), "wireDateEdit")
assert body.count("void saveDate(null)") == 1, (
"exactly one null (clear) call site"
)
revert_i = body.find('revertBtn.addEventListener("click"')
null_i = body.find("void saveDate(null)", revert_i)
assert -1 < revert_i < null_i, "the null call is the revert binding"
save_i = body.find('saveBtn.addEventListener("click"')
value_i = body.find("void saveDate(input.value)", save_i)
assert -1 < save_i < value_i, "Save sends the input's (non-empty) value"
def test_badge_rerenders_from_the_response_not_the_input() -> None:
"""The UI shows exactly what the server stored: on 200 the doc
object syncs from the RESPONSE (``res.created_at``) and the
badge's text re-renders from ``res.created_at`` (plus the ISO
title — the ellipsis-precision idiom). The input's optimistic
value never feeds the badge anywhere in the file."""
js = _js()
body = _fn(js, "wireDateEdit")
json_i = body.find("const res = await r.json();")
sync_i = body.find("doc.created_at = res.created_at;")
badge_i = body.find("createdBadge.textContent = `Created ${fmtDate(res.created_at)}`")
title_i = body.find('createdBadge.setAttribute("title", res.created_at);')
assert -1 < json_i < sync_i < badge_i, "JSON → doc sync → badge re-render"
assert -1 < badge_i < title_i, "the title follows the same response"
assert "fmtDate(input.value)" not in js, (
"the badge is response-driven — never the input's optimistic value"
)
def test_status_announces_after_the_badge_update() -> None:
"""The success confirmations (the role=status live line) land
AFTER the badge re-render (the phase-89 last-announce order):
'Date saved for <source>/<path>.' for a set, 'Reverted to
sync-managed date.' for the clear."""
body = _fn(_js(), "wireDateEdit")
badge_i = body.find("createdBadge.textContent = `Created ${fmtDate(res.created_at)}`")
saved_i = body.find("`Date saved for ${doc.source}/${doc.path}.`")
reverted_i = body.find('"Reverted to sync-managed date."')
assert -1 < badge_i < reverted_i < saved_i, (
"the badge updates BEFORE either confirmation (last-announce order)"
)
# ---------- §7.4 never-stale lifecycle ----------
def test_submit_disables_controls_before_the_fetch() -> None:
"""One PATCH at a time: Save/Revert lock ALL the editor controls
(input + Save + Cancel + Revert) IMMEDIATELY — before the fetch
— so a double-submit is impossible (PLAN §7.4)."""
body = _fn(_js(), "wireDateEdit")
lock_fn = _fn(body, "setControlsLocked")
assert "input.disabled = locked" in lock_fn
assert "cancelBtn.disabled = locked" in lock_fn
assert "revertBtn.disabled = locked" in lock_fn
lock_i = body.find("setControlsLocked(true)")
fetch_i = body.find('fetch("/api/documents/date"')
assert -1 < lock_i < fetch_i, "the controls lock BEFORE the fetch"
def test_empty_input_disables_save() -> None:
"""An empty type=date input is NOT the clear path: Save disables
itself on an empty input (the explicit Revert below handles the
clear — no accidental wipes). The locked state owns the controls
while a PATCH is in flight (the input is disabled then)."""
body = _fn(_js(), "wireDateEdit")
lock_fn = _fn(body, "setControlsLocked")
assert 'saveBtn.disabled = locked || input.value === ""' in lock_fn
listener_i = body.find('input.addEventListener("input"')
assert listener_i != -1, "the input event keeps Save in sync"
assert 'if (!input.disabled) saveBtn.disabled = input.value === "";' in body
def test_failure_reverts_input_announces_alert_and_reenables() -> None:
"""A failed Save/Revert (non-2xx OR network) lands the server
detail (the git-sources.js apiDetail shape) — or the canned
'Couldn't save the date — try again.' on a non-JSON body — into
the role=alert line, reverts the input to the stored date, and
re-enables the controls in the ``finally`` (every outcome, never
stale). The UI never claims a state the server didn't save."""
body = _fn(_js(), "wireDateEdit")
nonok = body.find("if (!r.ok)")
detail = body.find("errorLine.textContent = await apiDetail(")
canned = body.find("Couldn't save the date — try again.")
revert_nonok = body.find("input.value = storedValue()", nonok)
assert -1 < nonok < detail < canned < revert_nonok, (
"non-ok: server detail (canned fallback) → alert line → input reverts"
)
catch_i = body.find("} catch {")
canned2 = body.find("Couldn't save the date — try again.", catch_i)
revert_catch = body.find("input.value = storedValue()", catch_i)
assert -1 < catch_i < canned2 < revert_catch, (
"network failure: canned retry copy → input reverts"
)
finally_i = body.find("finally {")
unlock = body.find("setControlsLocked(false)", finally_i)
assert -1 < finally_i < unlock, "the controls re-enable in the finally"
def test_failure_keeps_the_editor_open() -> None:
"""Neither failure branch (the non-ok early return, the network
catch) collapses the editor or clears the user's view — the
alert line + the reverted stored value are visible (the editor
stays open; only Cancel and the success beat collapse it)."""
body = _fn(_js(), "wireDateEdit")
nonok_slice = body[body.find("if (!r.ok)") : body.find("const res = await r.json();")]
assert "closeEditor" not in nonok_slice, "non-ok keeps the editor open"
catch_slice = body[body.find("} catch {") : body.find("finally {")]
assert "closeEditor" not in catch_slice, "the network catch keeps the editor open"
def test_cancel_closes_without_a_patch() -> None:
"""Cancel collapses back to the badge row + the Edit button
(focus returns to the opener) and sends NO PATCH (the stored
value is untouched — the badge was never mutated)."""
body = _fn(_js(), "wireDateEdit")
cancel_i = body.find('cancelBtn.addEventListener("click"')
close_i = body.find("closeEditor()", cancel_i)
assert -1 < cancel_i < close_i, "Cancel closes the editor"
assert "fetch" not in body[cancel_i : close_i], "Cancel sends no PATCH"
close_fn = _fn(body, "closeEditor")
assert 'createdBadge.insertAdjacentElement("afterend", editBtn)' in close_fn
assert "box.remove()" in close_fn
assert "editBtn.focus()" in close_fn, "focus returns to the opener"
# ---------- styles.css ----------
def test_date_editor_classes_present_next_to_the_summary_family() -> None:
"""styles.css carries the eight editor classes (the button, the
box, the input, Save / Cancel, the muted revert, the status and
the alert line) placed NEXT to the summary-editor rule family
(after .doc-summary-status, before the raw-format block), with
the house palette (phase-08 tokens) and no CDN."""
css = _css()
for cls in (
".doc-date-edit",
".doc-date-editor",
".doc-date-input",
".doc-date-save",
".doc-date-cancel",
".doc-date-revert",
".doc-date-status",
".doc-date-error",
):
assert f"{cls} " in css, f"styles.css must style {cls}"
assert (
css.find(".doc-summary-status:empty")
< css.find(".doc-date-edit {")
< css.find(".doc-raw {")
), "the editor family sits next to the summary-editor rules"
assert "url(http" not in css and "@import url(" not in css, (
"no CDN (AGENTS.md rule 6)"
)
def test_date_editor_css_provenance_and_contrast() -> None:
"""The phase-106 D7 provenance comment sits directly above the
button rule with the verified WCAG pairs recorded (house
style): the ink-soft row family (5.1:1 on --surface), the brand
pill Save (--bg on --brand = 5.2:1), the input ink on --bg
(16.7:1), and the alert line's err pair (9.1:1 on --err-bg) —
all ≥4.5:1. The [hidden] override beats the button's display,
and :focus-visible rides the global 3px outline rule (no
per-control rule — the phase-105 checkbox idiom)."""
css = _css()
rule_i = css.find(".doc-date-edit {")
comment_start = css.rfind("/*", 0, rule_i)
comment_end = css.find("*/", comment_start)
assert -1 < comment_start < rule_i and comment_end < rule_i, (
"a comment block must sit directly above the button rule"
)
header = css[comment_start:comment_end]
assert "phase 106" in header.lower() and "D7" in header, (
"the provenance comment cites phase 106 + D7"
)
for pair in ("5.1:1", "5.2:1", "9.1:1", "16.7:1"):
assert pair in header, f"the verified contrast pair {pair} is recorded"
hidden = css.find(".doc-date-edit[hidden]")
assert hidden != -1 and "display: none" in css[hidden : hidden + 80], (
"the hidden attr must beat the button's display rule"
)
assert ":focus-visible {" in css
assert "outline: 3px solid var(--brand)" in css
def test_date_editor_disabled_uses_the_wait_idiom() -> None:
"""The :disabled state on the editor controls is the
.git-source-remove:disabled idiom (opacity + cursor: wait — one
PATCH at a time), and the empty live lines take no space
(display: none on :empty)."""
css = _css()
dis = css.find(".doc-date-save:disabled")
assert dis != -1, "the disabled rule names the editor controls"
block = css[dis : css.find("}", dis)]
for cls in (
".doc-date-save:disabled",
".doc-date-cancel:disabled",
".doc-date-revert:disabled",
".doc-date-input:disabled",
):
assert cls in block, f"the disabled idiom covers {cls}"
assert "opacity: 0.5" in block and "cursor: wait" in block
empty = css.find(".doc-date-status:empty")
assert empty != -1, "the empty live lines hide themselves"
empty_block = css[empty : css.find("}", empty)]
assert "display: none" in empty_block
+139
View File
@@ -0,0 +1,139 @@
"""Unit tests: ``app.rag.doc_dates`` — the date normalization choke point.
Phase 106, task 02 (D2/D3). The owner's rules, pinned as a boundary
matrix on the pure function (no database): an UNDETERMINED date
(``None``) and a FUTURE date (beyond the 1-day clock-skew tolerance)
both assume "created today"; naive source timestamps are tz-agnostic
epoch values rendered as UTC (never local-converted); aware ones are
converted to UTC; the stored value keeps full precision. The
strict-greater 1-day boundary is pinned on both sides.
"""
from __future__ import annotations
import os
import re
import sys
from datetime import UTC, datetime, timedelta, timezone
from pathlib import Path
import app.rag.doc_dates as doc_dates
from app.rag.doc_dates import (
FUTURE_SKEW_TOLERANCE,
file_mtime_datetime,
normalize_doc_date,
)
#: A fixed "today" — every relative case in the matrix hangs off this.
NOW = datetime(2026, 9, 13, 12, 0, 0, tzinfo=UTC)
# ---------------------------------------------------------------- the matrix
def test_none_is_undetermined_returns_exactly_now() -> None:
assert normalize_doc_date(None, now=NOW) is NOW
def test_naive_raw_is_utc_attached_not_local_converted() -> None:
# The homelab host TZ is irrelevant: a naive 12:00 is a UTC 12:00.
out = normalize_doc_date(datetime(2020, 5, 1, 12, 0), now=NOW)
assert out == datetime(2020, 5, 1, 12, 0, tzinfo=UTC)
assert out.utcoffset() == timedelta(0)
def test_aware_raw_is_converted_to_utc() -> None:
raw = datetime(2020, 5, 1, 8, 0, tzinfo=timezone(timedelta(hours=-4)))
out = normalize_doc_date(raw, now=NOW)
assert out == datetime(2020, 5, 1, 12, 0, tzinfo=UTC)
def test_past_date_kept_verbatim() -> None:
raw = datetime(2024, 6, 15, 7, 30, 12, 123456, tzinfo=UTC)
assert normalize_doc_date(raw, now=NOW) is raw
def test_future_inside_tolerance_keeps_its_date() -> None:
# 23 h ahead — a drifting clock, not a future document.
raw = NOW + timedelta(hours=23)
assert normalize_doc_date(raw, now=NOW) is raw
def test_future_beyond_tolerance_folds_to_today() -> None:
# 25 h ahead — beyond the 1-day tolerance → today.
assert normalize_doc_date(NOW + timedelta(hours=25), now=NOW) is NOW
def test_exactly_at_tolerance_boundary_keeps_its_date() -> None:
# The check is strict-greater: exactly now + tolerance survives.
raw = NOW + FUTURE_SKEW_TOLERANCE
assert normalize_doc_date(raw, now=NOW) is raw
def test_one_second_past_tolerance_folds_to_today() -> None:
assert normalize_doc_date(NOW + FUTURE_SKEW_TOLERANCE + timedelta(seconds=1), now=NOW) is NOW
def test_result_keeps_full_precision() -> None:
# No date-truncation — the display formats, the storage doesn't.
out = normalize_doc_date(datetime(2020, 5, 1, 12, 0, 0, 987654), now=NOW)
assert out.microsecond == 987654
def test_default_now_is_utc_now_for_none() -> None:
before = datetime.now(UTC)
out = normalize_doc_date(None)
after = datetime.now(UTC)
assert before <= out <= after
assert out.tzinfo is not None
def test_default_now_keeps_old_raw() -> None:
out = normalize_doc_date(datetime(1999, 12, 31, 23, 59, tzinfo=UTC))
assert out == datetime(1999, 12, 31, 23, 59, tzinfo=UTC)
def test_naive_now_is_treated_as_utc() -> None:
# The future check runs in aware space; a naive ``now`` is UTC.
naive_now = datetime(2026, 9, 13, 12, 0)
assert normalize_doc_date(None, now=naive_now) == datetime(2026, 9, 13, 12, 0, tzinfo=UTC)
assert normalize_doc_date(
datetime(2026, 9, 15, 12, 1, tzinfo=UTC), now=naive_now
) == datetime(2026, 9, 13, 12, 0, tzinfo=UTC)
# ----------------------------------------------------- file_mtime_datetime
def test_file_mtime_datetime_reads_utime_as_utc(tmp_path: Path) -> None:
# 1585699200 = 2020-04-01T00:00:00Z (epoch — tz-agnostic).
target = 1_585_699_200
p = tmp_path / "doc.md"
p.write_text("hello\n")
os.utime(p, (target, target))
out = file_mtime_datetime(p)
assert out.tzinfo is not None
# ±1 s: mtime granularity varies by filesystem.
assert abs((out - datetime(2020, 4, 1, tzinfo=UTC)).total_seconds()) <= 1.0
def test_file_mtime_datetime_future_mtime_stays_future(tmp_path: Path) -> None:
# The helper is faithful: the FUTURE folding is normalize's job.
p = tmp_path / "future.md"
p.write_text("hi\n")
future = int((datetime.now(UTC) + timedelta(days=10)).timestamp())
os.utime(p, (future, future))
out = file_mtime_datetime(p)
assert out > datetime.now(UTC)
# ------------------------------------------------- the stdlib-only contract
def test_module_is_pure_stdlib() -> None:
"""Source-level pin (D3 choke point): stdlib imports only."""
src = Path(doc_dates.__file__).read_text()
import_re = re.compile(r"^\s*(?:import|from)\s+([A-Za-z_][A-Za-z0-9_.]*)", re.MULTILINE)
modules = [m.split(".")[0] for m in import_re.findall(src)]
assert modules # sanity: the regex actually matched the import block
non_stdlib = [m for m in modules if m not in sys.stdlib_module_names]
assert non_stdlib == []
+6 -1
View File
@@ -181,6 +181,7 @@ def test_content_known_pair_maps_to_doc_content() -> None:
content_hash="f" * 64,
)
doc.indexed_at = datetime(2026, 8, 22, 1, 2, 3, tzinfo=UTC)
doc.created_at = datetime(2026, 8, 20, 9, 0, 0, tzinfo=UTC) # phase 106
with _client_with_row((doc, 3)) as client:
r = client.get(
"/api/documents/content",
@@ -188,8 +189,11 @@ def test_content_known_pair_maps_to_doc_content() -> None:
)
assert r.status_code == 200
body = r.json()
# Wire-additive (phase 106, task 05): the pre-date keys are all
# still there, joined by ``created_at``.
assert set(body) == {
"source", "path", "title", "format", "summary", "content", "indexed_at", "chunks"
"source", "path", "title", "format", "summary", "created_at",
"content", "indexed_at", "chunks",
}
assert body["source"] == "Homelab"
assert body["path"] == "notes/deep mark.md"
@@ -197,6 +201,7 @@ def test_content_known_pair_maps_to_doc_content() -> None:
assert body["format"] == "md"
assert body["summary"] is None # markdown doc → no summary (phase 36)
assert body["content"] == "# Deep Mark\n\nbody"
assert body["created_at"] == "2026-08-20T09:00:00+00:00" # phase 106
assert body["indexed_at"] == "2026-08-22T01:02:03+00:00"
assert body["chunks"] == 3
+5 -2
View File
@@ -720,17 +720,20 @@ def test_import_summary_log_line_includes_summary_counters(
caplog: pytest.LogCaptureFixture,
) -> None:
"""PLAN §9 summary line: the phase-30 counters sit between
``embed_batches`` and ``formats``."""
``embed_batches`` and ``formats``; the phase-106 date-refresh
counter sits between ``summary_errors`` and ``formats``."""
s = ImportSummary()
s.files, s.added, s.chunks, s.embed_batches = 3, 3, 5, 4
s.summaries, s.summary_errors = 2, 1
s.dates_updated = 0
s.formats = {"md": 1, "yaml": 2}
with caplog.at_level(logging.INFO, logger="app.importer"):
s.log()
line = caplog.records[-1].getMessage()
assert line == (
"import: summary files=3 added=3 updated=0 unchanged=0 pruned=0 errors=0 "
"chunks=5 embed_batches=4 summaries=2 summary_errors=1 formats=yaml:2,md:1"
"chunks=5 embed_batches=4 summaries=2 summary_errors=1 dates_updated=0 "
"formats=yaml:2,md:1"
)
+235
View File
@@ -0,0 +1,235 @@
"""Unit: phase 106 (task 04) — the importer's document-date semantics.
The D2/D4 matrix against the fake LLM + the house ``db`` session
(real compose Postgres, the ``tests/unit/test_importer.py`` pattern
for importer tests): added files store their source date (the
``doc_dates_by_root`` map entry when present, else the file mtime —
both normalized by :func:`app.rag.doc_dates.normalize_doc_date`,
D3); the unchanged path REFRESHES the stored date from the same
source and counts it in ``dates_updated`` (content counts preserved);
manual rows (``created_at_manual``) are skipped (the D1 lock); a
content change re-sources the date AND clears the manual flag; a
future (beyond-skew) mtime folds to today through the importer.
"""
from __future__ import annotations
import asyncio
import os
from datetime import UTC, datetime, timedelta
from pathlib import Path
from sqlalchemy import select
from app.models import Document
from app.rag.importer import import_sources
from tests.fakes import FakeEmbedder
#: mtime granularity tolerance — ``os.utime`` + ``stat`` round-trip on
#: the test filesystem (the archive-date suite uses ±1 s; 50 ms is far
#: tighter and still filesystem-agnostic).
_TOL = timedelta(milliseconds=50)
OLD_2020 = datetime(2020, 1, 2, 3, 4, 5, 123456, tzinfo=UTC)
OLD_2021 = datetime(2021, 6, 1, 12, 0, 0, tzinfo=UTC)
def _utime(path: Path, when: datetime) -> None:
ts = when.timestamp()
os.utime(path, (ts, ts))
def _doc(db, source: str, rel: str) -> Document:
doc = db.scalar(select(Document).where(Document.source == source, Document.path == rel))
assert doc is not None, f"no documents row for ({source!r}, {rel!r})"
return doc
def _cleanup_source(db, source: str) -> None:
for doc in db.scalars(select(Document).where(Document.source == source)).all():
db.delete(doc)
db.commit()
def _make_root(tmp_path: Path, name: str) -> tuple[Path, Path]:
root = tmp_path / name
root.mkdir()
file = root / "note.md"
file.write_text("# Note\n\ncontent for the KB\n", encoding="utf-8")
return root, file
# --- added: the source date lands on the new row -----------------------------
def test_added_file_stores_its_mtime_as_created_at(db, tmp_path: Path) -> None:
"""D2 fallback: an unmapped file's mtime IS its source date — a
file ``os.utime``'d to 2020-01-02 imports with that ``created_at``
(the added branch, D3 normalized), and ``created_at_manual`` stays
the column default (False)."""
root, file = _make_root(tmp_path, "DateAdded")
_utime(file, OLD_2020)
llm = FakeEmbedder()
try:
summary = asyncio.run(import_sources([root], llm, session=db))
assert (summary.added, summary.unchanged, summary.dates_updated) == (1, 0, 0)
doc = _doc(db, root.name, "note.md")
assert abs(doc.created_at - OLD_2020) <= _TOL
assert doc.created_at_manual is False
finally:
_cleanup_source(db, root.name)
def test_doc_dates_map_entry_beats_mtime(db, tmp_path: Path) -> None:
"""D2 git case: a ``doc_dates_by_root`` entry (keyed by ``str(root)``
— the root string exactly as passed in ``sources``) names the file's
raw date and BEATS the file's mtime (the map says 2020, the mtime
says now → 2020 stored). A path missing from its root's map takes
the mtime fallback (≈ now) in the same run."""
root = tmp_path / "DateMap"
root.mkdir()
mapped = root / "git_file.md"
mapped.write_text("# Git\n\nfrom the repo\n", encoding="utf-8")
unmapped = root / "local_file.md"
unmapped.write_text("# Local\n\nnot in the map\n", encoding="utf-8")
now_before = datetime.now(UTC)
llm = FakeEmbedder()
try:
summary = asyncio.run(
import_sources(
[root], llm, session=db,
doc_dates_by_root={str(root): {"git_file.md": OLD_2020}},
)
)
assert summary.added == 2
git_doc = _doc(db, root.name, "git_file.md")
assert abs(git_doc.created_at - OLD_2020) <= _TOL # the map, not the mtime
local_doc = _doc(db, root.name, "local_file.md")
# The unmapped file fell back to its mtime (written just now).
assert now_before - _TOL <= local_doc.created_at <= datetime.now(UTC) + _TOL
# Unchanged re-import with the SAME map: both dates already
# stored → no refresh (the map hit is stable, not a rewrite).
s2 = asyncio.run(
import_sources(
[root], llm, session=db,
doc_dates_by_root={str(root): {"git_file.md": OLD_2020}},
)
)
assert (s2.added, s2.updated, s2.unchanged) == (0, 0, 2)
assert s2.dates_updated == 0
finally:
_cleanup_source(db, root.name)
def test_future_mtime_folds_to_today_through_importer(db, tmp_path: Path) -> None:
"""D3 through the importer: a mtime YEARS in the future (beyond the
1-day clock-skew tolerance) folds to the import moment (today), not
the raw future value."""
root, file = _make_root(tmp_path, "DateFuture")
_utime(file, datetime(2030, 1, 1, 0, 0, 0, tzinfo=UTC))
llm = FakeEmbedder()
try:
before = datetime.now(UTC)
summary = asyncio.run(import_sources([root], llm, session=db))
after = datetime.now(UTC)
assert summary.added == 1
doc = _doc(db, root.name, "note.md")
# The folded date is the normalization moment — between the run's
# bounds (a hair of slack on each side).
assert before - _TOL <= doc.created_at <= after + _TOL
assert doc.created_at.year == before.year # 2030 never stored
finally:
_cleanup_source(db, root.name)
# --- unchanged: the date refresh (D4) ----------------------------------------
def test_unchanged_reimport_refreshes_date_when_mtime_moves(db, tmp_path: Path) -> None:
"""D4: an unchanged file whose source date moved gets the new date
(it may go OLDER — no monotonic guard) and is counted in
``dates_updated`` — ``added/updated/pruned`` stay 0 (content counts
preserved)."""
root, file = _make_root(tmp_path, "DateRefresh")
llm = FakeEmbedder()
try:
first = asyncio.run(import_sources([root], llm, session=db))
assert first.added == 1 and first.dates_updated == 0
_utime(file, OLD_2021) # the source date moved; content identical
second = asyncio.run(import_sources([root], llm, session=db))
assert (second.added, second.updated, second.pruned, second.unchanged) == (0, 0, 0, 1)
assert second.dates_updated == 1
doc = _doc(db, root.name, "note.md")
assert abs(doc.created_at - OLD_2021) <= _TOL
finally:
_cleanup_source(db, root.name)
def test_unchanged_reimport_same_date_no_refresh(db, tmp_path: Path) -> None:
"""D4's no-op case: the source date is unchanged → no write,
``dates_updated`` stays 0 (an unchanged re-sync is byte-identical)."""
root, _file = _make_root(tmp_path, "DateSame")
llm = FakeEmbedder()
try:
first = asyncio.run(import_sources([root], llm, session=db))
assert first.added == 1 and first.dates_updated == 0
second = asyncio.run(import_sources([root], llm, session=db))
assert (second.added, second.updated, second.pruned, second.unchanged) == (0, 0, 0, 1)
assert second.dates_updated == 0
finally:
_cleanup_source(db, root.name)
# --- the D1 manual lock --------------------------------------------------------
def test_manual_row_date_survives_unchanged_reimport(db, tmp_path: Path) -> None:
"""D1: a row carrying the owner's correction (``created_at_manual``)
is left ENTIRELY alone on the unchanged path — the moved mtime does
not refresh it and ``dates_updated`` stays 0 (the sibling of the
phase-97 ``manually_edited`` precedent)."""
root, file = _make_root(tmp_path, "DateManual")
llm = FakeEmbedder()
correction = datetime(2023, 5, 5, 9, 30, 0, tzinfo=UTC)
try:
first = asyncio.run(import_sources([root], llm, session=db))
assert first.added == 1
doc = _doc(db, root.name, "note.md")
doc.created_at = correction # the owner's correction (task 05's API)
doc.created_at_manual = True
db.commit()
_utime(file, OLD_2021) # the source moved to a DIFFERENT date
second = asyncio.run(import_sources([root], llm, session=db))
assert (second.added, second.updated, second.pruned, second.unchanged) == (0, 0, 0, 1)
assert second.dates_updated == 0
db.expire_all()
doc = _doc(db, root.name, "note.md")
assert doc.created_at == correction # the correction survived
assert doc.created_at_manual is True
finally:
_cleanup_source(db, root.name)
def test_content_change_resets_date_and_manual_flag(db, tmp_path: Path) -> None:
"""D4: a content change is a new document version — the date is
re-sourced from the file AND the manual flag is reset (the
correction referred to the old content)."""
root, file = _make_root(tmp_path, "DateReset")
llm = FakeEmbedder()
correction = datetime(2023, 5, 5, 9, 30, 0, tzinfo=UTC)
try:
first = asyncio.run(import_sources([root], llm, session=db))
assert first.added == 1
doc = _doc(db, root.name, "note.md")
doc.created_at = correction
doc.created_at_manual = True
db.commit()
file.write_text("# Note\n\nNEW content — a new version\n", encoding="utf-8")
_utime(file, OLD_2021)
second = asyncio.run(import_sources([root], llm, session=db))
assert (second.added, second.updated, second.unchanged) == (0, 1, 0)
assert second.dates_updated == 0 # an update is not a date-only refresh
doc = _doc(db, root.name, "note.md")
assert abs(doc.created_at - OLD_2021) <= _TOL # re-sourced
assert doc.created_at_manual is False # reset
finally:
_cleanup_source(db, root.name)
+187 -57
View File
@@ -18,6 +18,15 @@ T0 = "2026-09-01T08:00:00+00:00"
T1 = "2026-09-02T08:00:00+00:00"
T2 = "2026-09-03T08:00:00+00:00"
# Phase 106 (task 05): the 6th catalogue element — the document's
# creation date (``created_at``, ISO-8601). Deliberately DISTINCT from
# the ``indexed_at`` stamps so a test that confuses the two columns
# fails loudly.
C0 = "2020-01-01T00:00:00+00:00"
C1 = "2021-06-15T12:00:00+00:00"
C2 = "2022-03-01T06:00:00+00:00"
C3 = "2023-11-30T23:59:59+00:00"
def _folder_nodes(node) -> list:
"""The folder-kind children of a source/folder node, in order."""
@@ -34,10 +43,10 @@ def test_multi_source_registry_order_leads_and_indexed_only_appended() -> None:
indexed-only sources in alphabetical order (the superset rule)."""
names = ["beta", "alpha", "empty"] # registry order — NOT alphabetical
doc_rows: list[TreeDocRow] = [
("beta", "b.md", "B", 1, T0),
("alpha", "a.md", "A", 1, T0),
("gamma", "g.md", "G", 1, T0), # indexed-only → appended
("delta", "d.md", "D", 1, T0), # indexed-only → appended
("beta", "b.md", "B", 1, T0, C0),
("alpha", "a.md", "A", 1, T0, C0),
("gamma", "g.md", "G", 1, T0, C0), # indexed-only → appended
("delta", "d.md", "D", 1, T0, C0), # indexed-only → appended
]
tree = build_kb_tree(names, doc_rows, {})
assert [s.name for s in tree] == ["beta", "alpha", "empty", "delta", "gamma"]
@@ -59,6 +68,8 @@ def test_registered_zero_document_source_lists_empty() -> None:
assert tree[0].documents == 0
assert tree[0].children == []
assert tree[0].summary is None
# Phase 106 (D9): a 0-document source has no dates at all.
assert tree[0].updated_at is None
def test_nested_document_counts_into_source_ancestors_and_own_folder() -> None:
@@ -66,10 +77,10 @@ def test_nested_document_counts_into_source_ancestors_and_own_folder() -> None:
to ``a/b``, and to ``a/b/c`` (the recursive subtree, the phase-94
``ls`` count rule)."""
rows = [
("S", "a/b/c/deep.md", "Deep", 1, T0),
("S", "a/b/shallow.md", "Shallow", 1, T0),
("S", "a/top.md", "Top", 1, T0),
("S", "root.md", "Root", 1, T0),
("S", "a/b/c/deep.md", "Deep", 1, T0, C0),
("S", "a/b/shallow.md", "Shallow", 1, T0, C0),
("S", "a/top.md", "Top", 1, T0, C0),
("S", "root.md", "Root", 1, T0, C0),
]
(source,) = build_kb_tree(["S"], rows, {})
assert source.documents == 4
@@ -91,8 +102,8 @@ def test_existence_rule_a_file_path_is_never_a_folder() -> None:
starts with ``folder + "/"``); a document's own path — even one
with dots — never creates a folder."""
rows = [
("S", "x.md", "X", 1, T0),
("S", "x.y/z.md", "Z", 1, T0),
("S", "x.md", "X", 1, T0, C0),
("S", "x.y/z.md", "Z", 1, T0, C1),
]
(source,) = build_kb_tree(["S"], rows, {})
folders = _folder_nodes(source)
@@ -109,8 +120,8 @@ def test_file_folder_name_collision_both_appear() -> None:
colliding file counts into the folder's subtree (the ``ls`` count
rule's ``path == folder`` arm)."""
rows = [
("S", "a", "File A", 1, T0), # a file wearing the folder's name
("S", "a/b.md", "B", 1, T0), # makes ``a`` a folder
("S", "a", "File A", 1, T0, C0), # a file wearing the folder's name
("S", "a/b.md", "B", 1, T0, C1), # makes ``a`` a folder
]
(source,) = build_kb_tree(["S"], rows, {})
folders = _folder_nodes(source)
@@ -127,12 +138,12 @@ def test_subfolder_path_order_and_file_catalog_order() -> None:
the input (catalog — ``GET /api/docs``) order, independent of the
subfolder ordering."""
rows = [
("S", "zeta/z1.md", "Z1", 1, T0),
("S", "alpha/a1.md", "A1", 1, T0),
("S", "mike/m1.md", "M1", 1, T0),
("S", "beta/b1.md", "B1", 1, T0),
("S", "z-file.md", "Z", 1, T0), # file AFTER the folders in input
("S", "a-file.md", "A", 1, T0), # file before it in input
("S", "zeta/z1.md", "Z1", 1, T0, C0),
("S", "alpha/a1.md", "A1", 1, T0, C0),
("S", "mike/m1.md", "M1", 1, T0, C0),
("S", "beta/b1.md", "B1", 1, T0, C0),
("S", "z-file.md", "Z", 1, T0, C0), # file AFTER the folders in input
("S", "a-file.md", "A", 1, T0, C0), # file before it in input
]
(source,) = build_kb_tree(["S"], rows, {})
assert [f.path for f in _folder_nodes(source)] == ["alpha", "beta", "mike", "zeta"]
@@ -147,9 +158,9 @@ def test_summaries_present_and_absent() -> None:
path) or null when absent — any row (AI or manual is indistinguishable
here; the builder carries whatever is stored)."""
rows = [
("S", "one/a.md", "A", 1, T0),
("S", "one/b.md", "B", 1, T0),
("S", "two/c.md", "C", 1, T0),
("S", "one/a.md", "A", 1, T0, C0),
("S", "one/b.md", "B", 1, T0, C0),
("S", "two/c.md", "C", 1, T0, C0),
]
summaries = {("S", ""): "Source desc.", ("S", "one"): "One desc."}
# ("S", "two") is NOT stored → null.
@@ -158,21 +169,28 @@ def test_summaries_present_and_absent() -> None:
one, two = _folder_nodes(source)
assert one.summary == "One desc."
assert two.summary is None
# File nodes carry no summary key at all (the 00_phase.md shape).
# File nodes carry no summary key at all (the 00_phase.md shape);
# since phase 106 they DO carry the creation date (``created_at``
# — the RAG view's ``Created`` column).
file = _file_nodes(one)[0]
assert set(file.model_dump()) == {"kind", "path", "title", "chunks", "indexed_at"}
assert set(file.model_dump()) == {
"kind", "path", "title", "chunks", "created_at", "indexed_at"
}
assert "summary" not in file.__class__.model_fields
def test_file_metadata_unchanged_in_tree() -> None:
"""File ``title`` / ``chunks`` / ``indexed_at`` ride into the tree
verbatim from the catalogue row (no reformatting)."""
rows = [("S", "deep/x/y.md", "The Title", 7, T2)]
"""File ``title`` / ``chunks`` / ``created_at`` (phase 106) /
``indexed_at`` ride into the tree verbatim from the catalogue row
(no reformatting) — and the two stamps stay distinct (the
``created_at`` date is not confused with the ``indexed_at`` stamp)."""
rows = [("S", "deep/x/y.md", "The Title", 7, T2, C2)]
(source,) = build_kb_tree(["S"], rows, {})
file = _file_nodes(_folder_nodes(_folder_nodes(source)[0])[0])[0]
assert file.path == "deep/x/y.md"
assert file.title == "The Title"
assert file.chunks == 7
assert file.created_at == C2 # phase 106: verbatim from the catalogue row
assert file.indexed_at == T2
@@ -188,10 +206,10 @@ def test_indexed_document_under_unlisted_source_is_impossible() -> None:
is no input where a document is dropped."""
names = ["reg-b", "reg-a"]
rows: list[TreeDocRow] = [
("reg-b", "b.md", "B", 1, T0),
("zzz", "z.md", "Z", 1, T0),
("aaa", "a.md", "A", 1, T0),
("reg-a", "a.md", "A2", 1, T0),
("reg-b", "b.md", "B", 1, T0, C0),
("zzz", "z.md", "Z", 1, T0, C1),
("aaa", "a.md", "A", 1, T0, C2),
("reg-a", "a.md", "A2", 1, T0, C3),
]
tree = build_kb_tree(names, rows, {})
listed = [s.name for s in tree]
@@ -214,14 +232,14 @@ def test_indexed_document_under_unlisted_source_is_impossible() -> None:
#: summary-stored folder and an unstored one. Paths are in (source,
#: path) catalog order; titles map 1:1 to paths.
CROSS_ROWS: list[TreeDocRow] = [
("S", "note", "Note", 1, T0),
("S", "one/a.md", "A", 2, T1),
("S", "one/b.md", "B", 0, T1),
("S", "one/two/c.md", "C", 3, T1),
("S", "one/two/d.md", "D", 1, T1),
("S", "root.md", "Root", 4, T0),
("S", "zz/e.md", "E", 2, T2),
("S", "zz/f.md", "F", 2, T2),
("S", "note", "Note", 1, T0, C0),
("S", "one/a.md", "A", 2, T1, C1),
("S", "one/b.md", "B", 0, T1, C1),
("S", "one/two/c.md", "C", 3, T1, C2),
("S", "one/two/d.md", "D", 1, T1, C2),
("S", "root.md", "Root", 4, T0, C3),
("S", "zz/e.md", "E", 2, T2, C0),
("S", "zz/f.md", "F", 2, T2, C0),
]
CROSS_SUMMARIES = {
@@ -236,9 +254,17 @@ def _cross_check(folder: str, builder_node) -> None:
"""Assert the builder's level *folder* equals
``group_folder_listing("S", folder, ...)`` — same subfolder
``(path, count, summary)`` triples in order AND same file
``(path, title)`` pairs in order (uncapped — the dataset is well
under the ``ls`` 50-line cap, so the cap is inert)."""
rows = [(path, title) for _source, path, title, _chunks, _stamp in CROSS_ROWS]
``(source, path, title, date)`` 4-tuples in order (uncapped — the
dataset is well under the ``ls`` 50-line cap, so the cap is inert).
Phase 106 (task 06, D5): the cross-check compares the builder's
OUTPUT node projections against the agent's extended file shape —
the node's ``created_at`` DATE PART (the same ``YYYY-MM-DD`` the
agent's ``ls`` line renders) joins the comparison.
"""
rows = [
(path, title, created[:10])
for _source, path, title, _chunks, _stamp, created in CROSS_ROWS
]
source_summaries = {
folder_path: summary
for (source, folder_path), summary in CROSS_SUMMARIES.items()
@@ -246,8 +272,8 @@ def _cross_check(folder: str, builder_node) -> None:
}
subs, files, _total = group_folder_listing("S", folder, rows, source_summaries)
assert [(f.path, f.documents, f.summary) for f in _folder_nodes(builder_node)] == subs
assert [(f.path, f.title) for f in _file_nodes(builder_node)] == [
(path, title) for _source, path, title in files
assert [(f.path, f.title, f.created_at[:10]) for f in _file_nodes(builder_node)] == [
(path, title, date) for _source, path, title, date in files
]
@@ -302,8 +328,8 @@ def test_folder_two_docs_no_stored_row_is_pending() -> None:
(the marker's "waiting to generate" semantics); file nodes carry
no flag at all (the file table has no description column)."""
rows = [
("S", "one/a.md", "A", 1, T0),
("S", "one/b.md", "B", 1, T0),
("S", "one/a.md", "A", 1, T0, C0),
("S", "one/b.md", "B", 1, T0, C0),
]
(source,) = build_kb_tree(["S"], rows, {})
(one,) = _folder_nodes(source)
@@ -321,8 +347,8 @@ def test_folder_with_stored_row_is_not_pending() -> None:
A row on the folder does not cover the source root: with no
``(source, "")`` row the SOURCE node stays pending."""
rows = [
("S", "one/a.md", "A", 1, T0),
("S", "one/b.md", "B", 1, T0),
("S", "one/a.md", "A", 1, T0, C0),
("S", "one/b.md", "B", 1, T0, C0),
]
(source,) = build_kb_tree(["S"], rows, {("S", "one"): "Manual."})
(one,) = _folder_nodes(source)
@@ -338,8 +364,8 @@ def test_single_document_folder_never_pending() -> None:
— its one file line IS its description), even with no stored row
— while its ≥ 2-doc source root (no root row) still is."""
rows = [
("S", "solo/only.md", "Only", 1, T0), # 1-doc folder
("S", "top.md", "Top", 1, T0), # source total = 2
("S", "solo/only.md", "Only", 1, T0, C0), # 1-doc folder
("S", "top.md", "Top", 1, T0, C1), # source total = 2
]
(source,) = build_kb_tree(["S"], rows, {})
(solo,) = _folder_nodes(source)
@@ -357,8 +383,8 @@ def test_name_collision_pending_follows_recursive_count() -> None:
the number of direct children. A stored row on the NESTED folder
alone clears only that marker (the rule is per node)."""
rows = [
("S", "one/a", "File A", 1, T0), # a file wearing the folder's name
("S", "one/a/b.md", "B", 1, T0), # makes ``one/a`` a folder
("S", "one/a", "File A", 1, T0, C0), # a file wearing the folder's name
("S", "one/a/b.md", "B", 1, T0, C1), # makes ``one/a`` a folder
]
(source,) = build_kb_tree(["S"], rows, {})
one = _folder_nodes(source)[0]
@@ -386,8 +412,8 @@ def test_source_root_pending_and_zero_document_source_never() -> None:
A registered 0-document source is NEVER pending (0 < the minimum —
there is nothing to summarize), with or without a manual row."""
rows = [
("Full", "x/1.md", "1", 1, T0),
("Full", "y.md", "Y", 1, T0),
("Full", "x/1.md", "1", 1, T0, C0),
("Full", "y.md", "Y", 1, T0, C1),
]
full, empty = build_kb_tree(["Full", "Empty"], rows, {})
assert full.documents == 2
@@ -407,11 +433,115 @@ def test_two_sources_pending_independently() -> None:
row and the other not, only the rowless source's node is pending —
the markers never leak across sources."""
rows = [
("A", "a1.md", "A1", 1, T0),
("A", "a2.md", "A2", 1, T0),
("B", "b1.md", "B1", 1, T0),
("B", "b2.md", "B2", 1, T0),
("A", "a1.md", "A1", 1, T0, C0),
("A", "a2.md", "A2", 1, T0, C1),
("B", "b1.md", "B1", 1, T0, C2),
("B", "b2.md", "B2", 1, T0, C3),
]
a, b = build_kb_tree(["A", "B"], rows, {("A", ""): "A root."})
assert (a.summary, a.summary_pending) == ("A root.", False)
assert (b.summary, b.summary_pending) == (None, True)
# --------------------------------------------------------------------
# Document dates (phase 106, task 05, D8/D9) — the pure builder's date
# threading: file ``created_at`` verbatim; folder/source ``updated_at``
# = the subtree's MAX document ``created_at`` (derived as the builder
# recurses, never stored; ``None`` for a node with no documents).
# --------------------------------------------------------------------
def test_updated_at_deep_file_beats_shallow_sibling() -> None:
"""D9: a folder's ``updated_at`` is the MAX over its WHOLE subtree —
a deeper file's date wins over a shallower sibling's (the max
recurses through every level, not just the direct files)."""
rows = [
("S", "root.md", "Root", 1, T0, C0),
("S", "a/top.md", "Top", 1, T0, C1),
("S", "a/b/shallow.md", "Shallow", 1, T0, C2),
("S", "a/b/c/deep.md", "Deep", 1, T0, C3), # the overall max
]
(source,) = build_kb_tree(["S"], rows, {})
a = _folder_nodes(source)[0]
a_b = _folder_nodes(a)[0]
a_b_c = _folder_nodes(a_b)[0]
# The deepest folder: its one file's date.
assert a_b_c.updated_at == C3
# a/b: its own file (C2) vs its child's subtree max (C3) → C3.
assert a_b.updated_at == C3
# a: its direct file (C1) vs the deeper subtree (C3) → the DEEPER
# file's date wins over the shallow sibling's.
assert a.updated_at == C3
# The source root: max over root.md (C0) + a's subtree (C3).
assert source.updated_at == C3
# File nodes carry their own date verbatim — no ``updated_at`` key.
deep = _file_nodes(a_b_c)[0]
assert deep.created_at == C3
assert "updated_at" not in deep.model_dump()
def test_updated_at_direct_file_wins_when_it_is_the_max() -> None:
"""The inverse: when a folder's OWN direct file holds the newest
date, the max stays at the direct level (the recursion takes the
max, it does not prefer depth)."""
rows = [
("S", "root.md", "Root", 1, T0, C0),
("S", "a/top.md", "Top", 1, T0, C3), # the overall max, DIRECT
("S", "a/b/shallow.md", "Shallow", 1, T0, C1),
("S", "a/b/c/deep.md", "Deep", 1, T0, C2),
]
(source,) = build_kb_tree(["S"], rows, {})
a = _folder_nodes(source)[0]
a_b = _folder_nodes(a)[0]
a_b_c = _folder_nodes(a_b)[0]
assert a_b_c.updated_at == C2 # its own file
assert a_b.updated_at == C2 # max(C1, child C2)
assert a.updated_at == C3 # the direct file (C3) beats the subtree (C2)
assert source.updated_at == C3 # max(C0, a's C3)
def test_updated_at_threads_through_folder_only_subtree() -> None:
"""A folder with NO direct files (only subfolders) still carries the
date threaded up from its child subfolders — the max is over the
children (files AND folders), so a pure directory chain never loses
the dates below it."""
rows = [("S", "a/b/c/x.md", "X", 1, T0, C2)]
(source,) = build_kb_tree(["S"], rows, {})
a = _folder_nodes(source)[0] # no direct files — only subfolder a/b
a_b = _folder_nodes(a)[0] # no direct files — only subfolder a/b/c
a_b_c = _folder_nodes(a_b)[0]
assert a_b_c.updated_at == C2
assert a_b.updated_at == C2
assert a.updated_at == C2
assert source.updated_at == C2
def test_updated_at_is_none_only_for_nodes_without_documents() -> None:
"""``None`` is reserved for nodes with NO documents at all — a
registered 0-document source; every node that has ≥ 1 document in
its subtree carries a date (the 6th catalogue element is always
present — ``created_at`` is NOT NULL, D1)."""
full, empty = build_kb_tree(
["Full", "Empty"],
[("Full", "only.md", "Only", 1, T0, C1)],
{},
)
assert full.documents == 1
assert full.updated_at == C1
assert empty.documents == 0
assert empty.updated_at is None
assert empty.children == []
def test_updated_at_does_not_leak_across_sources() -> None:
"""The max is per source subtree: one source's newest document never
lifts another source's ``updated_at`` (the dates are computed
inside :func:`build_kb_tree`'s per-source node, like ``documents``
and ``summary_pending``)."""
rows = [
("A", "a1.md", "A1", 1, T0, C0),
("B", "b1.md", "B1", 1, T0, C3), # B's date is the global max
]
a, b = build_kb_tree(["A", "B"], rows, {})
assert a.updated_at == C0
assert b.updated_at == C3
+24 -11
View File
@@ -3,8 +3,9 @@
The RAG (Knowledge base) view lists the catalog the way the agent's
``ls`` sees it (the phase-94 concept, ONE end to end): sources at the
top, then per level the subfolders (recursive count + the STORED
description) and the level's files (the existing 5-column
``#docs-table`` — unchanged). The view's catalog load is now
description) and the level's files (the ``#docs-table`` — phase 106,
task 08, D8, added the Created column BEFORE Indexed, making it the
6-column contract). The view's catalog load is now
``loadTree()``: ONE fetch of ``GET /api/docs/tree`` (the full recursive
tree in a single payload — task 02), then client-side drill navigation
(no per-level fetch, no URL change).
@@ -17,8 +18,12 @@ suite, task 06):
* the shell's RAG view ships the static tree skeleton (``#kb-crumb``,
``#kb-level`` + ``#kb-level-title``/``#kb-level-summary``,
``#folders-wrap`` + ``#folders-table`` — ONE table for every level,
the Folder | Documents | Description head) in order after
``#stat-cards`` and BEFORE the unchanged file table;
the Folder | Documents | Updated | Description head — phase 106
(task 08, D8) added the Updated column (the subtree's MAX document
created_at, D9) BETWEEN Documents and Description) in order after
``#stat-cards`` and BEFORE the file table (now the 6-column
Source | Path | Title | Chunks | Created | Indexed head — phase 106
(task 08, D8) added the Created column BEFORE Indexed);
* the exact ``/api/docs/tree`` fetch (and the flat ``/api/docs`` fetch
is gone from the view module);
* the drill state (``current`` / ``kbTree``), the state transitions
@@ -189,8 +194,10 @@ def test_rag_view_ships_the_tree_skeleton_in_order() -> None:
``#kb-crumb`` is the location nav; ``#kb-level`` is the level block
(title + description); ``#folders-wrap`` hosts the ONE
folders/sources table (``.table-wrap`` card, Folder | Documents |
Description, visually-hidden caption, the ``.docs-table``
language + ``.kb-folders-table``)."""
Updated | Description — phase 106 (task 08, D8), visually-hidden
caption, the ``.docs-table`` language + ``.kb-folders-table``).
The file table's head is the 6-column contract (Source | Path |
Title | Chunks | Created | Indexed — phase 106 (task 08, D8))."""
view = _rag_view(_text(SHELL_HTML))
for fragment in (
'<nav id="kb-crumb" class="kb-crumb" aria-label="Catalog location" hidden></nav>',
@@ -209,6 +216,7 @@ def test_rag_view_ships_the_tree_skeleton_in_order() -> None:
for column in (
"<th scope=\"col\">Folder</th>",
"<th scope=\"col\">Documents</th>",
"<th scope=\"col\">Updated</th>", # phase 106 (task 08, D8)
"<th scope=\"col\">Description</th>",
):
assert column in head.group(1), f"#folders-table head must carry {column!r}"
@@ -221,12 +229,13 @@ def test_rag_view_ships_the_tree_skeleton_in_order() -> None:
< view.find('id="folders-wrap"')
< view.find('id="docs-table"')
), "the skeleton must sit between the stat cards and the file table"
# The file table is UNCHANGED (the 5-column contract, makeRow's home).
# The file table's head (the 6-column contract — phase 106 (task
# 08, D8) added Created BEFORE Indexed; makeRow's home).
doc_head = re.search(
r'<table class="docs-table" id="docs-table">.*?<thead>(.*?)</thead>', view, re.S
)
assert doc_head, "the file table must keep its static thead"
for column in ("Source", "Path", "Title", "Chunks", "Indexed"):
for column in ("Source", "Path", "Title", "Chunks", "Created", "Indexed"):
assert f">{column}</th>" in doc_head.group(1), f"#docs-table head must keep {column!r}"
@@ -287,7 +296,9 @@ def test_source_row_click_drills_into_the_source_root() -> None:
The row carries the recursive count and the stored (source, "")
description — textContent only."""
js = _js()
body = js[js.find("function makeSourceRow(") : js.find("function makeSourceRow(") + 1200]
# Slice to the next function (the row builders grew with phase 106's
# Updated cell — a fixed window would drift out of the function).
body = js[js.find("function makeSourceRow(") : js.find("function makeFolderRow(")]
assert 'link.className = "folder-link"' in body, "the source row uses the row drill link"
assert "link.textContent = s.name" in body
assert "goTo({ source: s.name, folder: \"\" })" in body, (
@@ -309,7 +320,7 @@ def test_folder_row_click_drills_into_the_folder() -> None:
`{ source, folder: f.path }` (the source-relative folder path)."""
js = _js()
start = js.find("function makeFolderRow(")
body = js[start : start + 1400]
body = js[start : js.find("function renderLevel(")]
assert 'link.className = "folder-link"' in body
assert "link.textContent = f.path.split(\"/\").pop()" in body, (
"the label is the last segment (the breadcrumb language)"
@@ -1370,7 +1381,9 @@ def test_styles_carry_the_one_line_clamp() -> None:
"white-space: nowrap",
):
assert prop in text, f".kb-desc-text must carry {prop!r}"
col_i = css.find(".kb-folders-table td:nth-child(3) {")
# Phase 106 (task 08): the clamp follows the Description cell, which
# moved to the 4th column (Updated took 3rd).
col_i = css.find(".kb-folders-table td:nth-child(4) {")
assert col_i != -1
col = css[col_i : col_i + 400]
col = col[: col.find("\n}")]
+13 -2
View File
@@ -24,6 +24,7 @@ only) is unchanged.
from __future__ import annotations
import uuid
from datetime import UTC, datetime
import pytest
@@ -47,6 +48,13 @@ OVERVIEW = "- Homelab\n - Kubernetes (k3s)\n- Deployments\n - Borg backups"
KB_INTRO = "The basic categories of everything in this knowledge base (generated at import time):"
#: The fixture documents' fixed creation date (phase 106, D5) — the
#: ``<document>`` block formats its UTC date part (the detached fixture
#: rows carry it exactly as the NOT NULL DB column guarantees it for
#: real rows).
_FIXTURE_CREATED_AT = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC)
def _doc(path: str, content: str, title: str) -> Document:
return Document(
id=uuid.uuid4(),
@@ -56,6 +64,7 @@ def _doc(path: str, content: str, title: str) -> Document:
title=title,
content=content,
content_hash="0" * 64,
created_at=_FIXTURE_CREATED_AT,
)
@@ -137,7 +146,8 @@ def test_zero_note_prompt_is_byte_identical_to_pre_steering() -> None:
fixtures account for it; the LOW prompt is untouched.)"""
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
block = (
'<document source="Homelab" path="kubernetes.md" title="Kubernetes Homelab Cluster">\n'
'<document source="Homelab" path="kubernetes.md" '
'title="Kubernetes Homelab Cluster" date="2024-06-15">\n'
"Talos Linux on three nodes.\n"
"</document>"
)
@@ -394,7 +404,8 @@ def test_no_overview_prompt_is_byte_identical_to_pre_phase() -> None:
body — the fixtures account for it; the LOW prompt is untouched.)"""
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
block = (
'<document source="Homelab" path="kubernetes.md" title="Kubernetes Homelab Cluster">\n'
'<document source="Homelab" path="kubernetes.md" '
'title="Kubernetes Homelab Cluster" date="2024-06-15">\n'
"Talos Linux on three nodes.\n"
"</document>"
)
+346
View File
@@ -0,0 +1,346 @@
"""Unit: the phase-106 D5 date surfaces (task 06) — the date rides every
document the model sees.
Three surfaces, pinned here against pure inputs (no database):
* the HIGH prompt's ``<document>`` block carries
``date="YYYY-MM-DD"`` — the row's ``created_at`` UTC date part,
APPENDED after ``title`` (the only position; always present,
``created_at`` is NOT NULL) — while the DEFLECTION prompt stays
byte-identical to the pre-phase text (the A8 contract — titles only,
no documents, no dates);
* the ``read`` tool result carries ``date: YYYY-MM-DD`` as its SECOND
line — the FIRST line stays ``Document {source}/{path}:``
BYTE-IDENTICAL (the E2E mock's ``_READ_RESULT_PREFIX`` header
contract) — in BOTH the plain and the truncated shapes (marker +
notice still follow the cut content);
* the ``ls`` FILE line ENDS with the appended `` | date: YYYY-MM-DD``
field (never inserted before ``title``) and the 50-line cap note is
unchanged; source/folder lines carry no date.
"""
from __future__ import annotations
import asyncio
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from typing import Any, cast
import pytest
from sqlalchemy.orm import Session
from app.config import Settings
from app.models import Document
from app.rag import agent
from app.rag.agent import READ_TRUNCATION_NOTICE, AgentHolder, run_agent
from app.rag.llm import (
LLMClient,
StreamPiece,
ToolCallPiece,
)
from app.rag.prompts import (
_base,
build_deflect_prompt,
build_high_prompt,
build_steering_section,
)
from app.rag.retriever import TRUNCATION_MARKER
#: A fixed creation date; the UTC date part is ``2024-06-15``.
CREATED_AT = datetime(2024, 6, 15, 12, 30, 45, tzinfo=UTC)
DATE = "2024-06-15"
def _doc(
source: str = "S",
path: str = "P",
title: str = "T",
content: str = "CONTENT",
created_at: datetime = CREATED_AT,
) -> Document:
return Document(
id=uuid.uuid4(),
source=source,
path=path,
full_path=f"/tmp/{path}",
title=title,
content=content,
content_hash="0" * 64,
created_at=created_at,
)
def _settings(**kwargs: Any) -> Settings:
kwargs.setdefault("_env_file", None)
return Settings(**kwargs) # pyright: ignore[reportCallIssue]
class ScriptedLLM:
"""Canned stream sequences; records every ``chat_stream`` request."""
def __init__(self, *streams: list[StreamPiece | ToolCallPiece]) -> None:
self.streams: list[list[StreamPiece | ToolCallPiece]] = list(streams)
self.requests: list[tuple[list[dict[str, Any]], list[dict[str, Any]] | None]] = []
async def chat_stream(
self,
messages: list[dict[str, str]],
tools: list[dict[str, Any]] | None = None,
scaffolding: object = None,
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
self.requests.append((messages, tools))
if not self.streams:
return
for piece in self.streams.pop(0):
yield piece
async def _run(
llm: ScriptedLLM,
holder: AgentHolder,
settings: Settings,
) -> None:
async for _piece in run_agent(
cast("LLMClient", llm),
cast("Session", None),
system_prompt="SYSTEM_PROMPT",
user_message="QUESTION",
seed_docs=[],
settings=settings,
holder=holder,
):
pass
# --------------------------------------------------------------------
# The HIGH prompt's <document> block — date="YYYY-MM-DD" after title
# --------------------------------------------------------------------
def test_high_block_carries_date_attribute_after_title() -> None:
"""The block's identity attributes are ``source`` / ``path`` /
``title`` — and, phase 106 (D5), ``date`` APPENDED after ``title``
(the only position), always present."""
doc = _doc(source="S", path="P", title="T", content="THE BODY")
prompt = build_high_prompt([doc])
block = (
f'<document source="S" path="P" title="T" date="{DATE}">\n'
"THE BODY\n"
"</document>"
)
assert block in prompt
# The attribute order is pinned: date directly after title, then
# the closing bracket (nothing may land between title and date or
# after date before the block opens).
assert f'title="T" date="{DATE}">' in prompt
assert '<document source="S" path="P" title="T">\n' not in prompt # pre-phase shape is gone
def test_high_block_date_is_the_utc_date_part() -> None:
"""The date is the row's ``created_at`` UTC DATE part: a late UTC
instant (23:59:59 on 2024-06-15) renders ``2024-06-15`` — and the
time-of-day is never shown (full precision is stored; only the
date part rides the block). Stored rows are always UTC-aware
(``timestamptz`` read-back + :func:`normalize_doc_date`), so the
UTC date part is the stored date part."""
doc = _doc(created_at=datetime(2024, 6, 15, 23, 59, 59, tzinfo=UTC))
prompt = build_high_prompt([doc])
assert f'title="T" date="{DATE}">' in prompt
assert "2024-06-16" not in prompt
# The time-of-day is never rendered on the block.
assert "23:59" not in prompt and "12:30" not in prompt
def test_high_block_date_always_present_for_every_document() -> None:
"""Every document block carries the date (``created_at`` is NOT
NULL — the attribute is never omitted), each with its OWN row's
date."""
a = _doc(source="A", path="a.md", title="A", content="CA", created_at=CREATED_AT)
b = _doc(
source="B",
path="b.md",
title="B",
content="CB",
created_at=datetime(2019, 6, 15, 5, 0, 0, tzinfo=UTC),
)
prompt = build_high_prompt([a, b])
assert '<document source="A" path="a.md" title="A" date="2024-06-15">' in prompt
assert '<document source="B" path="b.md" title="B" date="2019-06-15">' in prompt
# No date-less block anywhere.
assert prompt.count("<document ") == prompt.count(' date="')
# --------------------------------------------------------------------
# The deflection prompt — byte-identical to the pre-phase text (A8)
# --------------------------------------------------------------------
def test_deflect_prompt_is_byte_identical_to_pre_phase() -> None:
"""The deflection path is titles-only (no documents involved): the
A8 byte-identity contract holds — same inputs, pre-phase bytes,
with or without steering notes."""
# No notes: the pre-phase LOW build verbatim.
expected_plain = (
_base("LOW")
+ "\n"
+ "DEFLECT_MODE: retrieval was weak — the titles below are the closest "
"your notes come to the question. They are titles only; do not pretend "
"they answer it. Use them to propose 2-3 alternative questions.\n"
+ "Reply in plain text only — you have no tools in this mode.\n"
+ "- T1\n- T2"
)
assert build_deflect_prompt(["T1", "T2"]) == expected_plain
# With steering notes: the pre-phase text + the <tuning> section
# (phase 15) — still no date anywhere.
expected_steered = (
_base("LOW")
+ "\n"
+ build_steering_section(["be concise"])
+ "\n"
+ "DEFLECT_MODE: retrieval was weak — the titles below are the closest "
"your notes come to the question. They are titles only; do not pretend "
"they answer it. Use them to propose 2-3 alternative questions.\n"
+ "Reply in plain text only — you have no tools in this mode.\n"
+ "- T1\n- T2"
)
assert build_deflect_prompt(["T1", "T2"], notes=["be concise"]) == expected_steered
for prompt in (expected_plain, expected_steered):
assert "<document" not in prompt
assert 'date="' not in prompt
# --------------------------------------------------------------------
# The read result — the date is the SECOND line (first line
# byte-identical — the mock's _READ_RESULT_PREFIX header contract)
# --------------------------------------------------------------------
def test_read_result_plain_carries_date_second_line(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""At or under the cap: ``Document {source}/{path}:`` (byte-
identical FIRST line) + ``date: YYYY-MM-DD`` (the D5 SECOND line)
+ the full content — nothing else."""
doc = _doc(source="S", path="a.md", title="A", content="A-CONTENT")
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
holder = AgentHolder()
llm = ScriptedLLM(
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/a.md"})],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings()))
assert holder.read_docs == [doc] and holder.tool_calls == 1
content = llm.requests[1][0][3]["content"]
lines = content.splitlines()
assert lines[0] == "Document S/a.md:" # byte-identical first line
assert lines[1] == f"date: {DATE}" # the D5 second line
assert lines[2:] == ["A-CONTENT"]
def test_read_result_truncated_carries_date_then_marker_and_notice(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Over the cap: the SAME two header lines, then the first ``cap``
chars, the shared marker, and the pinned notice — the truncation
contract (phase 95) is unchanged by the date line."""
cap = 10
content_body = "x" * 25
doc = _doc(source="S", path="big.md", title="Big", content=content_body)
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
holder = AgentHolder()
llm = ScriptedLLM(
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/big.md"})],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
expected = (
"Document S/big.md:\n"
f"date: {DATE}\n"
+ content_body[:cap]
+ "\n"
+ TRUNCATION_MARKER
+ "\n"
+ READ_TRUNCATION_NOTICE.format(shown=cap, total=len(content_body))
)
assert llm.requests[1][0][3]["content"] == expected
# The truncation still records on the holder (the ToolResultPiece
# signal) — the date line adds nothing to the counters.
assert holder.read_truncations == [("S/big.md", cap, len(content_body))]
assert holder.tool_calls == 1
# --------------------------------------------------------------------
# The ls FILE line — the appended " | date: YYYY-MM-DD" field
# --------------------------------------------------------------------
def test_ls_file_line_ends_with_appended_date() -> None:
"""The ``ls`` FILE line is ``source: X | path: Y | title: Z`` with
the phase-106 D5 field APPENDED at the END (never inserted before
``title`` — the mock's non-greedy ``path`` capture would swallow an
inserted field)."""
rows = [
("a.md", "A", "2019-06-15"),
("b.md", "B", "2024-06-15"),
]
subfolders, files, total = agent.group_folder_listing("S", "", rows, {})
rendered = agent.render_folder_listing("S", subfolders, files, total)
assert total == 2
lines = rendered.splitlines()
assert lines[0] == "S — 2 documents, 0 folders:"
# The file lines end with the appended date field — per-row dates.
assert lines[2] == "source: S | path: a.md | title: A | date: 2019-06-15"
assert lines[3] == "source: S | path: b.md | title: B | date: 2024-06-15"
# The date field is the LAST field (appended, never inserted before
# ``title`` — where the mock's non-greedy path capture would
# swallow it).
for line in (lines[2], lines[3]):
assert line.index("date:") > line.index("title:")
assert line.rsplit(" | ", 1)[-1].startswith("date: ") # date is final
def test_ls_folder_and_source_lines_carry_no_date() -> None:
"""Only FILE lines are documents: the folder header, the indented
subfolder lines, and the top-level source lines carry NO date."""
rows = [
("a/x.md", "X", "2019-06-15"),
("a/y.md", "Y", "2020-01-02"),
("top.md", "Top", "2024-06-15"),
]
subfolders, files, total = agent.group_folder_listing("S", "", rows, {"a": "A stuff."})
rendered = agent.render_folder_listing("S", subfolders, files, total)
lines = rendered.splitlines()
assert lines[0] == "S — 1 documents, 1 folders:" # header: no date
assert lines[2] == " a/ — 2 documents: A stuff." # subfolder: no date
# Only the DIRECT root file lists (a/ is a subfolder line — the
# drill-down is one level per call), and it alone carries a date.
file_lines = [line for line in lines if line.startswith("source: ")]
assert file_lines == [
"source: S | path: top.md | title: Top | date: 2024-06-15"
]
# The top level (source lines) is byte-identical to the pre-phase
# template — no date on source lines.
assert agent.render_ls_top([("S", 3, "Source stuff.")]) == (
"1 sources:\n\nS — 3 documents\n Source stuff."
)
assert "date" not in agent.render_ls_top([("S", 3, None)])
def test_ls_cap_note_unchanged_past_fifty() -> None:
"""The 50-line cap note is UNCHANGED by the date field: 51 files →
50 lines (each with its appended date) + the exact pre-phase note;
the line COUNT is unaffected by the wider lines."""
rows = [(f"f{i:03d}.md", f"T{i}", "2024-06-15") for i in range(51)]
subfolders, files, total = agent.group_folder_listing("S", "", rows, {})
rendered = agent.render_folder_listing("S", subfolders, files, total)
lines = rendered.splitlines()
assert total == 51 and len(lines) == 1 + 1 + 50 + 1 # header, blank, 50, note
assert lines[-1] == (
"…and 1 more documents in this folder — use grep (pattern) to "
"find a specific one."
)
assert lines[1] == "" # the blank after the header is untouched
assert all(
line.endswith(" | date: 2024-06-15")
for line in lines[2:52]
)
+3 -1
View File
@@ -315,6 +315,7 @@ def _lexical_row(is_summary: bool, doc_path: str) -> object:
doc_content=doc.content,
content_hash=doc.content_hash,
indexed_at=None,
created_at=None,
is_summary=is_summary,
rank=0.33,
)
@@ -427,6 +428,7 @@ def _name_hit_lateral_row(doc: Document, is_summary: bool = False) -> SimpleName
doc_content=doc.content,
content_hash=doc.content_hash,
indexed_at=None,
created_at=None,
chunk_id=uuid.uuid4(),
position=-1 if is_summary else 0,
content="summary chunk" if is_summary else "content chunk",
@@ -526,7 +528,7 @@ def test_lexical_candidates_name_hits_lead_and_dedupe_with_fts() -> None:
chunk_id=q38_chunk, position=1, content="c", doc_id=q38.id,
source=q38.source, path=q38.path, full_path=q38.full_path,
title=q38.title, doc_content=q38.content, content_hash=q38.content_hash,
indexed_at=None, is_summary=False, rank=0.1,
indexed_at=None, created_at=None, is_summary=False, rank=0.1,
),
# an FTS hit on a different chunk of the OTHER doc (kept)
_lexical_row(False, "quadlets/qwen38-other.container"),
+175
View File
@@ -0,0 +1,175 @@
"""Unit: the D6 recency boost on the fused score (phase 106, task 07).
Pure-function pins for
:func:`app.rag.retriever.apply_recency_boost` — the decay magnitude
(zero age → full weight, one half-life → ``weight/e``, ten half-lives
→ negligible), the future-date clamp, the ``weight=0`` kill switch
(byte-identical scores AND order), the tie-breaks (a raw-score tie
breaks toward the newer document; the ``(path, position)`` key still
applies when scores AND cosines AND ages are equal), and the
no-mutation contract (the ``fuse`` convention). Fake rows, no DB —
the real-Postgres fine-line battery (the owner's scenario) lives in
``tests/integration/test_recency_boost.py``.
"""
from __future__ import annotations
import math
import uuid
from datetime import UTC, datetime, timedelta
import pytest
from app.config import get_settings
from app.models import Document
from app.rag.retriever import RetrievedChunk, apply_recency_boost
#: A fixed "now" — the pins must not depend on the wall clock.
NOW = datetime(2026, 9, 13, 12, 0, tzinfo=UTC)
def _doc(path: str, created_at: datetime, source: str = "Homelab") -> Document:
return Document(
id=uuid.uuid4(),
source=source,
path=path,
full_path=f"/tmp/{path}",
title=path,
content="x",
content_hash="0" * 64,
indexed_at=created_at,
created_at=created_at,
)
def _chunk(
doc: Document, score: float, position: int = 0, cosine: float = 0.9
) -> RetrievedChunk:
return RetrievedChunk(
chunk_id=uuid.uuid4(),
position=position,
content="x",
score=score,
document=doc,
cosine=cosine,
)
def test_zero_age_gets_the_full_weight_exactly() -> None:
"""Age 0 → ``+weight`` with no float drift (``exp(0) == 1.0``)."""
c = _chunk(_doc("a.md", NOW), 0.032787)
out = apply_recency_boost([c], now=NOW, weight=0.0007, half_life_days=365)
assert out[0].score == 0.032787 + 0.0007
def test_one_half_life_of_age_decays_to_weight_over_e() -> None:
"""Age = half-life → ``+weight·e⁻¹`` (±1e-9)."""
c = _chunk(_doc("a.md", NOW - timedelta(days=365)), 0.032787)
out = apply_recency_boost([c], now=NOW, weight=0.0007, half_life_days=365)
assert out[0].score == pytest.approx(
0.032787 + 0.0007 * math.exp(-1.0), abs=1e-9
)
def test_ten_half_lives_of_age_is_negligible() -> None:
"""Age 10× the half-life → ``+weight·e⁻¹⁰`` < ``weight·1e-3`` — the
boost has faded to nothing (recency is an age signal, not a binary)."""
c = _chunk(_doc("a.md", NOW - timedelta(days=3650)), 0.032787)
out = apply_recency_boost([c], now=NOW, weight=0.0007, half_life_days=365)
assert out[0].score - 0.032787 < 0.0007 * 1e-3
def test_future_created_at_clamps_to_zero_age() -> None:
"""A future ``created_at`` clamps to age 0 — the full weight (the D3
today-folding consistency: a future-sourced doc reads as brand-new)."""
c = _chunk(_doc("a.md", NOW + timedelta(days=30)), 0.032787)
out = apply_recency_boost([c], now=NOW, weight=0.0007, half_life_days=365)
assert out[0].score == 0.032787 + 0.0007
def test_weight_zero_is_byte_identical_scores_and_order() -> None:
"""The kill switch: ``weight=0`` leaves every score untouched and the
order byte-identical for an already-fused (already 4-key-sorted)
input."""
a0 = _chunk(_doc("a.md", NOW - timedelta(days=10)), 0.03, cosine=0.9, position=0)
b0 = _chunk(_doc("b.md", NOW - timedelta(days=20)), 0.03, cosine=0.8, position=0)
a1 = _chunk(_doc("a.md", NOW - timedelta(days=10)), 0.02, cosine=0.95, position=0)
c1 = _chunk(_doc("c.md", NOW - timedelta(days=30)), 0.02, cosine=0.5, position=1)
chunks = [a0, b0, a1, c1] # already sorted by the 4-key order
out = apply_recency_boost(chunks, now=NOW, weight=0.0, half_life_days=365)
assert [
(rc.score, rc.cosine, rc.document.path, rc.position) for rc in out
] == [
(rc.score, rc.cosine, rc.document.path, rc.position) for rc in chunks
]
def test_raw_score_tie_breaks_toward_the_newer_document() -> None:
"""An EXACT raw-score + cosine tie: the boost moves only the newer
document (the older one's boost has decayed to ~0), so the newer
rank rises above it — and with ``weight=0`` the pre-phase
(path-ordered) ranking stands."""
old = _chunk(_doc("a-old.md", NOW - timedelta(days=2500)), 0.016129)
new = _chunk(_doc("b-new.md", NOW), 0.016129)
out = apply_recency_boost([old, new], now=NOW, weight=0.0007, half_life_days=365)
assert [rc.document.path for rc in out] == ["b-new.md", "a-old.md"]
out_off = apply_recency_boost([old, new], now=NOW, weight=0.0, half_life_days=365)
assert [rc.document.path for rc in out_off] == ["a-old.md", "b-new.md"]
def test_path_position_tiebreak_when_scores_cosines_and_ages_equal() -> None:
"""When the boosted scores AND cosines are equal (same age → same
boost), the EXISTING ``(path, position)`` tie-break still decides —
first by path, then by position within one path."""
a = _chunk(_doc("a.md", NOW - timedelta(days=100)), 0.02, cosine=0.5, position=1)
b = _chunk(_doc("b.md", NOW - timedelta(days=100)), 0.02, cosine=0.5, position=0)
out = apply_recency_boost([b, a], now=NOW, weight=0.001, half_life_days=365)
assert [rc.document.path for rc in out] == ["a.md", "b.md"]
# Same path, different positions (same doc, same age, same score):
p1 = _chunk(_doc("a.md", NOW - timedelta(days=100)), 0.02, cosine=0.5, position=1)
p0 = _chunk(_doc("a.md", NOW - timedelta(days=100)), 0.02, cosine=0.5, position=0)
out2 = apply_recency_boost([p1, p0], now=NOW, weight=0.001, half_life_days=365)
assert [rc.position for rc in out2] == [0, 1]
def test_defaults_come_from_settings_when_omitted() -> None:
"""Omitted *weight* / *half_life_days* fall back to the settings
(``recency_boost`` / ``recency_half_life_days``) — the explicit
settings values must reproduce the default call exactly."""
chunks = [
_chunk(_doc("a.md", NOW - timedelta(days=30)), 0.02),
_chunk(_doc("b.md", NOW - timedelta(days=700)), 0.02),
]
s = get_settings()
out_default = apply_recency_boost(chunks, now=NOW)
out_explicit = apply_recency_boost(
chunks,
now=NOW,
weight=s.recency_boost,
half_life_days=s.recency_half_life_days,
)
assert [rc.score for rc in out_default] == [rc.score for rc in out_explicit]
def test_inputs_are_never_mutated() -> None:
"""The ``fuse`` convention: the input list and its scores are
untouched — every returned chunk is a fresh ``replace()`` copy."""
chunks = [
_chunk(_doc("a.md", NOW - timedelta(days=30)), 0.02),
_chunk(_doc("b.md", NOW), 0.03),
]
original_scores = [rc.score for rc in chunks]
original_order = [rc.chunk_id for rc in chunks]
out = apply_recency_boost(chunks, now=NOW, weight=0.001, half_life_days=365)
assert [rc.score for rc in chunks] == original_scores
assert [rc.chunk_id for rc in chunks] == original_order
assert out is not chunks
assert all(o is not i for o, i in zip(out, chunks, strict=True))
def test_non_positive_half_life_fails_loud() -> None:
"""A ``half_life_days <= 0`` argument would divide the exponent by
zero — the settings validator guards startup, the function guards
direct calls (the ``fuse`` ``k <= 0`` pattern)."""
c = _chunk(_doc("a.md", NOW), 0.02)
with pytest.raises(ValueError, match="half_life_days must be > 0"):
apply_recency_boost([c], now=NOW, weight=0.001, half_life_days=0)
+450
View File
@@ -0,0 +1,450 @@
"""Unit: the phase-106 date COLUMNS in the UI (task 08, D8) + the
viewer's Created badge (display only — the admin date EDITOR is
task 09).
The owner asked for the date everywhere it is read: "For files,
include a date/timestamp before the 'indexed' column in the UI";
"I would also like to see a last updated dates/timestamps on folders
before the description column but after the documents column in the
UI"; "The UI must also show a date for every document at the top of
that document when the user clicks it." The values ride the task-05
APIs (``created_at`` on the tree's file nodes + ``/api/docs`` +
``/api/documents/content``; the derived subtree-max ``updated_at`` on
tree folders/sources, D9 — ``null`` for a 0-document source).
The browser behavior itself is E2E-gated by the phase's dedicated
suite (``tests/e2e/test_document_dates.py``, task 10); like the other
frontend-adjacent unit files (the ``test_source_ignore_paths.py`` /
``test_kb_tree_ui.py`` house pattern), this module pins the
source-level contract a silent regression would break:
* ``frontend/index.html`` — the header cell ORDER, pinned as the
exact ``<th>`` sequence in the RAG view:
``Source | Path | Title | Chunks | Created | Indexed`` (Created
BETWEEN Chunks and Indexed — D8 verbatim) and
``Folder | Documents | Updated | Description`` (Updated BETWEEN
Documents and Description — D8 verbatim);
* ``frontend/assets/sources.js`` — ``makeRow``'s cell order
(``created_at`` BEFORE ``indexed_at``; the Created cell built
explicitly — ``textContent`` = ``fmtDate(d.created_at)`` AND
``title`` = the full ISO value, the path-cell hover idiom the E2E
asserts on — never innerHTML), the file-row object fed from the
tree's file nodes carries ``created_at``, and the ``updatedTd``
null → ``"–"`` branch (the statLast idiom) is present in BOTH row
builders (makeSourceRow + makeFolderRow);
* ``frontend/assets/document.js`` — the ONE shared core's
``.doc-meta`` badge row: the ``doc-created`` badge BEFORE
``doc-indexed`` (modal + ``/document.html`` through the same
core — no per-surface copy), the ``Created `` label +
``fmtDate(doc.created_at)`` template, and the full ISO timestamp on
the badge's ``title`` (the ``titleEl`` ellipsis-precision idiom);
* ``frontend/assets/styles.css`` — the ``.doc-created`` rule with
the phase-106 D8 provenance comment + the recorded WCAG pair
(5.1:1 — the same family as the Indexed badge), and the
Description one-line clamp moved to ``td:nth-child(4)`` (the
Updated column took 3rd — no hard-coded column count left behind).
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
SHELL_HTML = FRONTEND / "index.html"
SOURCES_JS = FRONTEND / "assets" / "sources.js"
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
def _text(path: Path) -> str:
assert path.is_file(), f"missing frontend file: {path}"
return path.read_text(encoding="utf-8")
def _js() -> str:
return _text(SOURCES_JS)
def _doc_js() -> str:
return _text(DOCUMENT_JS)
def _css() -> str:
return _text(STYLES_CSS)
def _rag_view(html: str) -> str:
"""The RAG view section of the shell (view-scoped — the shell
carries many views, so whole-file matches hit the wrong one)."""
i = html.find('<section class="view" id="view-rag"')
assert i != -1, "the RAG view section must be in the shell"
j = html.find('<section class="view" id="view-git-sources"', i)
assert j != -1, "the Sources view section must follow the RAG view"
return html[i:j]
def _fn(js: str, name: str) -> str:
"""The source of a (possibly async, possibly nested) function via
balanced-brace counting (the test_kb_tree_ui.py helper). The brace
count starts AFTER the parameter list — a destructured parameter
(renderDocument's target object) may carry braces of its own."""
for prefix in ("async function ", "function "):
start = js.find(f"{prefix}{name}(")
if start != -1:
# Skip the parameter list (balanced parens).
depth = 0
i = js.find("(", start)
close = i
while i < len(js):
if js[i] == "(":
depth += 1
elif js[i] == ")":
depth -= 1
if depth == 0:
close = i
break
i += 1
# Then brace-count the body.
brace = js.find("{", close)
depth = 0
for j in range(brace, len(js)):
if js[j] == "{":
depth += 1
elif js[j] == "}":
depth -= 1
if depth == 0:
return js[start : j + 1]
raise AssertionError(f"unbalanced braces in {name}()")
raise AssertionError(f"{name}() must exist")
def _header_columns(html: str, table_id: str) -> list[str]:
"""The table's ``<th>`` cells, IN DOCUMENT ORDER (the ORDER is the
pin — D8's verbatim positions)."""
i = html.find(f'id="{table_id}"')
assert i != -1, f"#{table_id} must be in the RAG view"
head = re.search(r"<thead>(.*?)</thead>", html[i :], re.S)
assert head, f"#{table_id} must keep a static thead"
return [m.group(1) for m in re.finditer(r"<th scope=\"col\">([^<]*)</th>", head.group(1))]
def _css_rule(css: str, selector: str) -> str:
"""The declarations of a simple rule (comments stripped first — a
house comment may legally carry braces)."""
clean = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
start = clean.find(f"{selector} {{")
assert start != -1, f"missing rule {selector} in styles.css"
brace = clean.find("{", start)
depth = 0
for i in range(brace, len(clean)):
if clean[i] == "{":
depth += 1
elif clean[i] == "}":
depth -= 1
if depth == 0:
return clean[start : i + 1]
raise AssertionError(f"unbalanced braces in {selector}")
# ---------- index.html: the two header rows (ORDER pinned) ----------
def test_file_table_header_order_is_source_path_title_chunks_created_indexed() -> None:
"""D8 verbatim: the file table's ``<th>`` sequence is EXACTLY
Source | Path | Title | Chunks | Created | Indexed — Created
BETWEEN Chunks and Indexed (a set-membership pin would let a
regression move the column; the sequence pins the position)."""
view = _rag_view(_text(SHELL_HTML))
assert _header_columns(view, "docs-table") == [
"Source",
"Path",
"Title",
"Chunks",
"Created",
"Indexed",
], "the file table's column order (Created BEFORE Indexed, D8)"
def test_folder_table_header_order_is_folder_documents_updated_description() -> None:
"""D8 verbatim: the folder/source table's ``<th>`` sequence is
EXACTLY Folder | Documents | Updated | Description — Updated
BETWEEN Documents and Description."""
view = _rag_view(_text(SHELL_HTML))
assert _header_columns(view, "folders-table") == [
"Folder",
"Documents",
"Updated",
"Description",
], "the folder table's column order (Updated BETWEEN the two, D8)"
def test_inserted_headers_carry_the_phase_106_comment() -> None:
"""House comment style: each inserted <th> is annotated with a
phase-106 provenance comment (D8 for the position; D9 for the
derived Updated value) — a bare <th> with no comment is the
regression this guards against."""
view = _rag_view(_text(SHELL_HTML))
for col in ("Created", "Updated"):
i = view.find(f"<th scope=\"col\">{col}</th>")
assert i != -1, f"the {col} header must be in the RAG view"
comment = view.rfind("<!--", 0, i)
end = view.find("-->", comment)
assert comment > -1 and "phase 106" in view[comment:end].lower(), (
f"a phase-106 comment must sit above the {col} header"
)
assert "D8" in view[comment:end], f"the {col} comment cites D8"
# ---------- sources.js: makeRow's Created cell (before Indexed) ----------
def test_make_row_cell_order_is_created_before_indexed() -> None:
"""makeRow (the file table's row builder): the cell order is
[title, chunks, created, indexed] — the Created cell lands BEFORE
the Indexed one (D8 verbatim). Both date cells are built
EXPLICITLY (the plain-td loop can't carry per-cell titles): the
Created cell has ``textContent = fmtDate(d.created_at)`` AND
``title = d.created_at`` — the full ISO value on hover (the
path-cell idiom; the E2E asserts on the locale-stable title, not
on the toLocaleString output)."""
make = _fn(_js(), "makeRow")
# The loop keeps the two plain cells (title, chunks).
assert "for (const value of [d.title, String(d.chunks)]) {" in make
# The Created cell: explicit, locale date + the ISO title.
created_i = make.find("const createdTd = document.createElement(\"td\");")
assert created_i != -1, "the Created cell is built explicitly"
assert "createdTd.textContent = fmtDate(d.created_at);" in make
assert "createdTd.title = d.created_at;" in make, (
"the full ISO value on hover (the E2E's locale-stable pin)"
)
# The value-list ORDER: created_at before indexed_at (text AND
# append — both orderings pinned, a regression can't swap them).
created_fmt = make.find("fmtDate(d.created_at)")
indexed_fmt = make.find("fmtDate(d.indexed_at)")
assert -1 < created_fmt < indexed_fmt, "created_at BEFORE indexed_at (D8)"
assert (
make.find("tr.appendChild(createdTd)")
< make.find("tr.appendChild(indexedTd)")
), "the Created cell appends BEFORE the Indexed one"
# textContent only — the XSS contract (never innerHTML with
# document-derived data).
assert "createdTd.innerHTML" not in make and "indexedTd.innerHTML" not in make
def test_tree_file_row_object_carries_created_at() -> None:
"""renderLevel's file loop feeds makeRow the FLAT row shape —
the tree's file node carries created_at (task 05's tree shape),
and the row object restores it BEFORE indexed_at (the order the
reader gets matches makeRow's cell order)."""
js = _js()
render = js[js.find("function renderLevel(") : js.find("function renderEmpty(")]
assert "makeRow({" in render, "renderLevel still feeds makeRow"
created_i = render.find("created_at: f.created_at,")
indexed_i = render.find("indexed_at: f.indexed_at,")
assert -1 < created_i < indexed_i, (
"the row object carries created_at (before indexed_at — task 05's tree shape)"
)
# ---------- sources.js: the Updated cell in BOTH row builders ----------
UPDATED_TAIL = "makeDescCell" # the Description cell follows Updated
def test_source_row_has_updated_cell_between_count_and_description() -> None:
"""makeSourceRow (top level — the rows ARE the sources): ONE new
td BETWEEN the count td and the Description cell — the
``updatedTd`` null → ``"–"`` branch (the statLast idiom: D9's
``None`` for a 0-document source), the locale date otherwise
(fmtDate), and the ISO value on the cell's title (hover
precision — the makeRow path-cell idiom)."""
make = _fn(_js(), "makeSourceRow")
count_i = make.find("countTd.textContent = String(s.documents);")
tail_i = make.find(UPDATED_TAIL, count_i)
updated_i = make.find("const updatedTd = document.createElement(\"td\");", count_i)
assert -1 < count_i < updated_i < tail_i, (
"the Updated cell sits BETWEEN the count and the Description (D8)"
)
assert 'updatedTd.textContent = s.updated_at ? fmtDate(s.updated_at) : "–";' in make, (
"the null → '–' branch (the statLast idiom — D9's None for a 0-document source)"
)
assert "if (s.updated_at) updatedTd.title = s.updated_at;" in make, (
"the ISO value on hover (only when there is one)"
)
append_i = make.find("tr.appendChild(updatedTd)", count_i)
desc_i = make.find(UPDATED_TAIL, append_i)
assert -1 < append_i < desc_i, "appended before the Description cell"
assert "updatedTd.innerHTML" not in make, "textContent only (XSS contract)"
def test_folder_row_has_updated_cell_between_count_and_description() -> None:
"""makeFolderRow (a level's subfolders): the SAME one td BETWEEN
the count td and the Description cell — the ``updatedTd`` null →
``"–"`` branch present in BOTH row builders (the phase pins it in
each — a source row and a folder row are separate code paths)."""
make = _fn(_js(), "makeFolderRow")
count_i = make.find("countTd.textContent = String(f.documents);")
tail_i = make.find(UPDATED_TAIL, count_i)
updated_i = make.find("const updatedTd = document.createElement(\"td\");", count_i)
assert -1 < count_i < updated_i < tail_i, (
"the Updated cell sits BETWEEN the count and the Description (D8)"
)
assert 'updatedTd.textContent = f.updated_at ? fmtDate(f.updated_at) : "–";' in make, (
"the null → '–' branch in the FOLDER builder too (both builders pinned)"
)
assert "if (f.updated_at) updatedTd.title = f.updated_at;" in make, (
"the ISO value on hover (only when there is one)"
)
append_i = make.find("tr.appendChild(updatedTd)", count_i)
desc_i = make.find(UPDATED_TAIL, append_i)
assert -1 < append_i < desc_i, "appended before the Description cell"
assert "updatedTd.innerHTML" not in make, "textContent only (XSS contract)"
def test_module_docstring_carries_the_phase_106_contract() -> None:
"""The house module-docstring convention: the phase-106 section
records the Created column (before Indexed), the Updated column
(between Documents and Description), and the untouched stat
cards (the indexed_at 'last indexed' semantics stay)."""
doc = _js()[: _js().find("import { fetchIsAdmin }")]
for frag in (
"Phase 106 (task 08, D8)",
"Created column BEFORE Indexed",
"BETWEEN\n * Documents and Description",
"D9",
"UNTOUCHED",
):
assert frag in doc, f"the module docstring lost: {frag!r}"
# ---------- document.js: the shared core's Created badge ----------
def test_meta_row_badge_order_is_created_before_indexed() -> None:
"""renderDocument (the ONE shared core — the modal AND
/document.html render through it, no per-surface copy): the
.doc-meta badge row carries the ``doc-created`` badge BEFORE the
``doc-indexed`` one (D8 verbatim — the date at the top of a
clicked document)."""
render = _fn(_doc_js(), "renderDocument")
block = render[render.find("metaEl.replaceChildren(") :]
created_i = block.find('metaBadge("doc-created"')
indexed_i = block.find('metaBadge("doc-indexed"')
chunks_i = block.find('metaBadge("doc-chunks"')
assert -1 < created_i < indexed_i < chunks_i, (
"the Created badge BEFORE Indexed, both before Chunks (D8)"
)
# The label + the locale-date template (the Indexed idiom).
assert "Created ${fmtDate(doc.created_at)}" in block, (
"the 'Created <date>' label + fmtDate(doc.created_at) template"
)
assert "Indexed ${fmtDate(doc.indexed_at)}" in block, (
"the Indexed badge is unchanged (the idiom the Created one copies)"
)
def test_created_badge_carries_the_full_iso_title() -> None:
"""The badge's ``title`` attribute carries the FULL ISO timestamp
(the titleEl ellipsis-precision idiom — the meta row may clip,
the exact value stays reachable): the created badge passes
``doc.created_at`` as metaBadge's title argument, and metaBadge
sets it via setAttribute (only when provided — the other badges
keep the two-argument shape, byte-identical)."""
js = _doc_js()
render = _fn(js, "renderDocument")
call_start = render.find("metaEl.replaceChildren(")
# The call ends at the first `);` AFTER the last badge (the
# doc-chunks one) — the comment above the created badge may carry
# parentheses, so slicing from the call top would be brittle.
chunks_i = render.find('metaBadge("doc-chunks"', call_start)
block = render[call_start : render.find(");", chunks_i) + 2]
created_i = block.find('metaBadge("doc-created"')
created_call = block[created_i : block.find("),", created_i) + 1]
assert created_call.endswith(", doc.created_at)"), (
"the created badge passes doc.created_at as its title"
)
badge = _fn(js, "metaBadge")
assert "function metaBadge(cls, text, title)" in badge, (
"metaBadge's optional title parameter"
)
assert 'el.setAttribute("title", title)' in badge
assert "title !== undefined" in badge, (
"the guard keeps the other badges' two-argument shape"
)
# No other badge passes a title (the pre-phase badges keep the
# two-argument shape — exactly one comma in the call).
for cls_ in ("doc-source-badge", "format-badge", "doc-indexed", "doc-chunks"):
i = block.find(f'metaBadge("{cls_}"')
assert i != -1, f"the {cls_} badge must stay in the meta row"
call = block[i : block.find("),", i) + 1]
assert call.count(",") == 1, f"{cls_} keeps the two-argument shape"
def test_core_docstring_and_comment_cite_phase_106_d8() -> None:
"""House comment style: the module docstring + the badge-row
comment record the phase-106 (task 08, D8) created-before-indexed
position and the ONE-shared-core guarantee (modal + page)."""
js = _doc_js()
doc = js.split("*/", 1)[0]
assert "Phase 106 (task 08, D8)" in doc
assert "BEFORE the Indexed one" in doc
assert "task 05" in doc, "the created_at payload provenance (task 05)"
render = _fn(js, "renderDocument")
assert "Phase 106 (task 08, D8)" in render, "the inline comment at the insertion site"
# ---------- styles.css: the .doc-created rule + the clamp move ----------
def test_doc_created_rule_present_with_provenance_and_contrast() -> None:
"""styles.css carries the ``.doc-created`` rule (the doc-indexed
badge family — the meta row's ink-soft text) with the phase-106
D8 provenance comment + the recorded WCAG pair (5.1:1 on
--surface — the same pair the Indexed badge inherits via
.doc-meta / .doc-modal-meta)."""
css = _css()
rule = _css_rule(css, ".doc-created")
assert "color: var(--ink-soft)" in rule, "the meta-row family (the Indexed look)"
# The provenance comment sits DIRECTLY above the rule (the
# house style: phase + decision + the verified contrast pair).
rule_i = css.find(".doc-created {")
comment_start = css.rfind("/*", 0, rule_i)
comment_end = css.find("*/", comment_start)
assert -1 < comment_start < rule_i and comment_end < rule_i, (
"a comment block must sit directly above the rule"
)
header = css[comment_start:comment_end]
assert "phase 106" in header.lower() and "D8" in header, (
"the provenance comment cites phase 106 + D8"
)
assert "5.1:1" in header, "the verified contrast pair is recorded (house style)"
# The rule sits next to the meta-row badge family (after
# .doc-chunks, before the .doc-shell block).
assert (
css.find(".doc-chunks {") < rule_i < css.find(".doc-shell {")
), "next to the existing meta-row badge rules"
def test_folders_description_clamp_follows_the_moved_cell() -> None:
"""The column-count change must not break the table's rules: the
Description one-line clamp (phase 99) follows the cell, which
moved to the 4th (the Updated column took 3rd) — no stale
``td:nth-child(3)`` folders rule may remain, and the new Updated
cell's ink pair (the table ink on --surface, 13.8:1) is recorded
in the house comment."""
css = _css()
assert ".kb-folders-table td:nth-child(4) {" in css, (
"the Description clamp moved with the cell (4th column)"
)
assert ".kb-folders-table td:nth-child(3)" not in css, (
"no stale 3rd-column folders rule (the Updated cell is there now)"
)
i = css.find(".kb-folders-table td:nth-child(4)")
comment = css.rfind("/*", 0, i)
end = css.find("*/", comment)
assert comment > -1 and "phase 106" in css[comment:end].lower(), (
"a phase-106 comment explains the Updated column + the clamp move"
)
assert "13.8:1" in css[comment:end], (
"the Updated cell's ink pair is recorded (table ink on --surface)"
)
+5
View File
@@ -7,6 +7,7 @@ prompts, and the ``plan_turn`` wiring (notes → prompt + ``tuning_count``).
from __future__ import annotations
import uuid
from datetime import UTC, datetime
import pytest
@@ -30,6 +31,10 @@ def _doc(title: str, content: str) -> Document:
title=title,
content=content,
content_hash="0" * 64,
# Phase 106, D5: the HIGH block formats the row's created_at
# UTC date part — the detached fixture carries it (the NOT NULL
# DB column guarantees it for real rows).
created_at=datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC),
)
+6 -2
View File
@@ -107,14 +107,18 @@ def test_gate_runs_after_the_phase36_base_construction() -> None:
36 (the anonymous byte-for-byte shape): the base construction
(className, the bare h2 label, the .doc-summary-text node, the
append) precedes the gate call, and the admin wiring runs ONLY in
the gate's success branch (``if (admin) wireSummaryEdit(...)``)."""
the gate's success branch (``if (admin) wireSummaryEdit(...)``).
The summary gate is searched AFTER the section mount — phase 106
(task 09) added the date editor's own ``docAdminReady()`` gate
earlier in renderDocument (one gate per admin affordance, both on
the same cached whoami promise)."""
js = _js()
base = js.find('section.className = "doc-summary"')
label = js.find('title.textContent = "Summary"')
text_node = js.find('body.className = "doc-summary-text"')
append = js.find("section.append(title, body)")
mount = js.find("contentEl.appendChild(section)")
gate = js.find("void docAdminReady().then(")
gate = js.find("void docAdminReady().then(", mount)
wiring = js.find("if (admin) wireSummaryEdit(section, doc);")
assert 0 < base < label < text_node < append < mount < gate < wiring, (
"phase-36 base construction first; the admin affordance is a "
+2
View File
@@ -41,6 +41,7 @@ import asyncio
import re
import threading
from collections.abc import Callable, Iterator
from datetime import datetime
from pathlib import Path
import pytest
@@ -758,6 +759,7 @@ class _GatedImport:
progress: Callable[[str, str, int, int], None] | None = None,
ignore_by_root: dict[str, list[str]] | None = None, # phase 89
include_hidden_by_root: dict[str, bool] | None = None, # phase 105
doc_dates_by_root: dict[str, dict[str, datetime]] | None = None, # phase 106
) -> ImportSummary:
self.prune_flags.append(prune)
if progress is not None: