phase: 106_document_dates
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:
+85
-51
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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}")]
|
||||
|
||||
@@ -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>"
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
@@ -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)"
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user