phase: 97_kb_tree_catalog
Build and Push Containers / build-and-push-app (push) Successful in 2m11s
Build and Push Containers / build-and-push-db (push) Successful in 11s

All completion criteria verified — everything is green, no defects found. Final report:

## Phase 97 final verification pass — ALL GREEN

**Verified (no code changes needed):**
- `GET /api/docs/tree` (admin), `build_kb_tree` pure builder, `PATCH /api/folders/summary`, migration 0018 (`manually_edited`, head confirmed), generator skip/keep + `kept_manual` stat, RAG tree UI + edit affordance in `sources.js`/`index.html`/`styles.css`
- `tests/e2e/test_kb_tree.py`: 8 passed — top level, drill source/folder, edit round-trip, clear, manual-desc-survives-sync, reload fallback, anonymous gate
- Integration: tree shape/order/403/empty/indexed-only + PATCH update/create/root/clear/404/403/no-LLM + stat-walk equivalence (in `test_docs_api.py`); 3-field `folder_summaries=` import token preserved

**Gates (exact commands):**
- `uv run pytest --cov=app --cov-report=term-missing` → **2053 passed**, TOTAL coverage **99%** (>90% ✓)
- `uv run ruff check . && uv run pyright` → **All checks passed / 0 errors**
- `uv run pytest tests/e2e/test_kb_tree.py -v --no-cov` → **8 passed** in isolation
- 30 story/RAG-view E2E suites run **one per process**: all passed, incl. `test_ls_tree_drilldown` (agent `ls` byte-identical ✓), `test_import_documents`, `test_edit_summaries`, `test_admin_auth`, `test_kb_overview`

**Completion criteria:** tree view ✓ · edit round-trip + clear ✓ · manual persists/clear resets ✓ · `ls` unchanged ✓ · pytest/coverage/lint ✓ · E2E isolation ✓ · commit — left to harness per protocol (working tree untouched, `git add/commit` not run)

**Deviations:** none. **Next pending phase:** none — `todo/` contains only 97 (96 already committed).
This commit is contained in:
2026-09-11 22:48:02 -04:00
parent a49be80b8e
commit ad7585d474
81 changed files with 6299 additions and 211 deletions
+117 -9
View File
@@ -441,7 +441,7 @@ def test_generate_happy_path_upserts_every_candidate_folder(
llm = _FakeLLM()
with caplog.at_level(logging.INFO, logger="app.rag.folder_summaries"):
stats = asyncio.run(generate_folder_summaries(db, llm))
assert stats == {"generated": 3, "failed": 0, "pruned": 0}
assert stats == {"generated": 3, "failed": 0, "pruned": 0, "kept_manual": 0}
assert llm.calls == 3, "one lite call per candidate folder (the solo folder: none)"
stored = _rows(db)
@@ -472,7 +472,8 @@ def test_generate_happy_path_upserts_every_candidate_folder(
assert "root.md — Root" not in a_prompt
assert (
"folder_summaries: generated=3 failed=0 pruned=0" in caplog.text
"folder_summaries: generated=3 failed=0 pruned=0 kept_manual=0"
in caplog.text
), "the stats line must be greppable (PLAN §9 ample logging)"
@@ -488,7 +489,7 @@ def test_generate_per_folder_fail_soft_keeps_previous_and_lands_others(
llm = _FakeLLM(fail_folders=("FSU/a/b",))
with caplog.at_level(logging.ERROR, logger="app.rag.folder_summaries"):
stats = asyncio.run(generate_folder_summaries(db, llm))
assert stats == {"generated": 2, "failed": 1, "pruned": 0}
assert stats == {"generated": 2, "failed": 1, "pruned": 0, "kept_manual": 0}
assert llm.calls == 3 # the failing folder was attempted too
stored = _rows(db)
@@ -547,7 +548,7 @@ def test_generate_skip_is_a_full_noop(db: Session, clean_tables) -> None:
db.commit()
llm = _FakeLLM()
stats = asyncio.run(generate_folder_summaries(db, llm, skip=True))
assert stats == {"generated": 0, "failed": 0, "pruned": 0}
assert stats == {"generated": 0, "failed": 0, "pruned": 0, "kept_manual": 0}
assert llm.calls == 0
assert _rows(db) == {("FSU", ""): "existing"}
@@ -560,7 +561,7 @@ def test_generate_empty_kb_prunes_every_row(db: Session, clean_tables) -> None:
db.commit()
llm = _FakeLLM()
stats = asyncio.run(generate_folder_summaries(db, llm))
assert stats == {"generated": 0, "failed": 0, "pruned": 2}
assert stats == {"generated": 0, "failed": 0, "pruned": 2, "kept_manual": 0}
assert llm.calls == 0
assert _rows(db) == {}
@@ -613,6 +614,113 @@ def test_generate_only_flushes_caller_commits(db: Session, clean_tables) -> None
assert MIN_DOCS_PER_FOLDER == 2 # the ≥ 2 scope rule, pinned by name
# ---------- manually_edited (phase 97, task 01) ----------
def test_manual_row_survives_regeneration(
db: Session, clean_tables, caplog: pytest.LogCaptureFixture
) -> None:
"""An owner-edited row is SKIPPED on regeneration (phase 97, task
01): the fake LLM is never called for it (no ``lite`` burn on owner
text — not even a prompt is built), its text AND ``updated_at``
stay byte-identical, ``kept_manual`` counts it, the flag is never
cleared, and the 4-field log line carries it (PLAN §9)."""
_seed_catalogue(db)
manual_text = "Owner's own words about a/."
db.add(
FolderSummary(
source="FSU", folder_path="a", summary=manual_text,
manually_edited=True,
)
)
db.commit()
stamp_before = _updated_at(db, "FSU", "a")
assert stamp_before is not None
llm = _FakeLLM()
with caplog.at_level(logging.INFO, logger="app.rag.folder_summaries"):
stats = asyncio.run(generate_folder_summaries(db, llm))
assert stats == {"generated": 2, "failed": 0, "pruned": 0, "kept_manual": 1}
assert llm.calls == 2, "the manual folder burns zero lite calls"
assert [user.splitlines()[0] for _s, user in llm.requests] == [
"Folder: FSU",
"Folder: FSU/a/b",
], "no prompt is ever built for the owner's folder"
stored = _rows(db)
assert stored[("FSU", "a")] == manual_text, "the owner's text survives"
assert _updated_at(db, "FSU", "a") == stamp_before, ("never re-stamped")
assert stored[("FSU", "")] == REPLY and stored[("FSU", "a/b")] == REPLY, (
"the non-manual candidates still regenerate (the flag is the difference)"
)
row = db.get(FolderSummary, ("FSU", "a"))
assert row is not None and row.manually_edited is True, (
"the generator never clears the flag"
)
assert (
"folder_summaries: generated=2 failed=0 pruned=0 kept_manual=1"
in caplog.text
), "the 4-field stats line must be greppable (PLAN §9 ample logging)"
def test_manual_row_survives_the_prune(db: Session, clean_tables) -> None:
"""A manual row is NEVER pruned (phase 97, task 01): two folders
drop below 2 documents — the MANUAL one keeps its row (owner
content persists until cleared — the clear deletes it, so the next
KB-changing sync regenerates an AI description) while the
NON-manual twin loses its now-stale row; the flag is the only
difference. A vanished folder's manual row is kept too, and its
non-manual twin is pruned."""
_add_doc(db, "FSU", "a/one.md", "One")
_add_doc(db, "FSU", "a/two.md", "Two")
_add_doc(db, "FSU", "b/one.md", "B One")
_add_doc(db, "FSU", "b/two.md", "B Two")
manual_text = "Owner's words about a/."
db.add(
FolderSummary(
source="FSU", folder_path="a", summary=manual_text,
manually_edited=True,
)
)
db.add(FolderSummary(source="FSU", folder_path="b", summary="ai words"))
db.add(
FolderSummary(
source="FSU", folder_path="gone/manual", summary="owner kept",
manually_edited=True,
)
)
db.add(FolderSummary(source="FSU", folder_path="gone/ai", summary="stale ai"))
db.commit()
# a/ and b/ each drop below the minimum (2 -> 1 recursive doc).
db.execute(
text(
"DELETE FROM documents WHERE source = 'FSU'"
" AND path IN ('a/two.md', 'b/two.md')"
)
)
db.commit()
llm = _FakeLLM()
stats = asyncio.run(generate_folder_summaries(db, llm))
assert stats == {"generated": 1, "failed": 0, "pruned": 2, "kept_manual": 0}
assert llm.calls == 1, "only the surviving candidate (the root) regenerates"
stored = _rows(db)
assert stored[("FSU", "a")] == manual_text, (
"the manual row survives its folder dropping below the minimum"
)
assert ("FSU", "b") not in stored, (
"the non-manual twin loses its stale row (the flag is the difference)"
)
assert stored[("FSU", "gone/manual")] == "owner kept", (
"a vanished folder's manual row is kept — owner content until cleared"
)
assert ("FSU", "gone/ai") not in stored, ("the non-manual twin is pruned")
assert stored[("FSU", "")] == REPLY # the root (2 docs) still regenerates
# ---------- missing_folder_summaries (phase 96, task 02) ----------
@@ -715,7 +823,7 @@ def test_only_missing_fills_exactly_the_missing_keys(
llm = _FakeLLM()
stats = asyncio.run(generate_folder_summaries(db, llm, only_missing=True))
assert stats == {"generated": 2, "failed": 0, "pruned": 0}
assert stats == {"generated": 2, "failed": 0, "pruned": 0, "kept_manual": 0}
assert llm.calls == 2, "one call per MISSING key — zero for present rows"
assert [user.splitlines()[0] for _s, user in llm.requests] == [
"Folder: FSU",
@@ -738,7 +846,7 @@ def test_only_missing_no_gap_burns_zero_calls(db: Session, clean_tables) -> None
stamps = {f: _updated_at(db, "FSU", f) for f in ("", "a", "a/b")}
llm = _FakeLLM()
stats = asyncio.run(generate_folder_summaries(db, llm, only_missing=True))
assert stats == {"generated": 0, "failed": 0, "pruned": 0}
assert stats == {"generated": 0, "failed": 0, "pruned": 0, "kept_manual": 0}
assert llm.calls == 0, "zero-burn: no gap, no lite call"
assert _rows(db) == before
for folder, stamp in stamps.items():
@@ -758,7 +866,7 @@ def test_only_missing_still_prunes_stale_rows(db: Session, clean_tables) -> None
llm = _FakeLLM()
stats = asyncio.run(generate_folder_summaries(db, llm, only_missing=True))
assert stats == {"generated": 2, "failed": 0, "pruned": 1}
assert stats == {"generated": 2, "failed": 0, "pruned": 1, "kept_manual": 0}
assert llm.calls == 2
stored = _rows(db)
@@ -781,7 +889,7 @@ def test_only_missing_fail_soft_keeps_prior_and_lands_others(
db.commit()
llm = _FakeLLM(fail_folders=("FSU/a/b",))
stats = asyncio.run(generate_folder_summaries(db, llm, only_missing=True))
assert stats == {"generated": 1, "failed": 1, "pruned": 0}
assert stats == {"generated": 1, "failed": 1, "pruned": 0, "kept_manual": 0}
assert llm.calls == 2 # both missing folders were attempted
stored = _rows(db)
+29 -18
View File
@@ -51,9 +51,11 @@ Phase 77 task 02 (the other data views join the refresh): RAG
(``tuning.js``) each listen for ``bor:view-refresh`` on their root and
re-run their existing load (armed only in the admin branch, after the
whoami gate — the same gate guard as History). ``sources.js``'s
``loadDocs`` clears the tbody's rows at the TOP (before the fetch —
the History pattern), so a refresh from a populated list into an empty
result leaves no ghost rows. The Chat view (``app.js``) does NOT
catalog load is re-entrant: the phase-97 ``loadTree`` (one fetch of
``GET /api/docs/tree``) renders through ``renderLevel``, which clears
BOTH row containers at the top before filling them, so a refresh from
a populated level into a sparser (or empty) one leaves no ghost rows. The Chat
view (``app.js``) does NOT
listen — the negative pin: the in-flight SSE stream and the local
conversation must survive every switch (the phase-76 contract), so
the exclusion is a contract, not an oversight.
@@ -559,25 +561,34 @@ def _pin_refresh_listener(js: str, gate: str, listener_call: str, name: str) ->
def test_rag_view_refetches_on_reshow() -> None:
"""Phase 77 task 02: the RAG (knowledge base) view re-fetches on a
user-initiated re-show — sources.js listens and re-runs
``loadDocs()``. ``loadDocs`` is now re-entrant: the tbody's rows
are cleared at the TOP, before the fetch (the History pattern from
task 01), so a refresh from a populated list into an empty result
replaces the list instead of leaving ghost rows."""
"""Phase 77 task 02 (+ phase 97 task 04): the RAG (knowledge base)
view re-fetches on a user-initiated re-show — sources.js listens
and re-runs ``loadTree()`` (the phase-97 catalog load: ONE fetch of
``GET /api/docs/tree``). The load is race-tokened (phase 79) and
the RE-ENTRANT render — ``renderLevel`` clears BOTH row containers
at the TOP before filling them (the History pattern from task 01,
extended to the folders table) — so a refresh from a populated
level into a sparser (or empty) result replaces the rows instead
of leaving ghost rows."""
js = _asset("sources.js")
_pin_refresh_listener(
js, "const admin = await fetchIsAdmin();", "() => loadDocs()", "sources.js"
js, "const admin = await fetchIsAdmin();", "() => loadTree()", "sources.js"
)
load = js.find("async function loadDocs()")
assert load != -1, "loadDocs must exist"
load = js.find("async function loadTree()")
assert load != -1, "loadTree must exist"
body = js[load : js.find("\n }", load)]
clear_i = body.find("tbody.replaceChildren()")
fetch_i = body.find('fetch("/api/docs")')
assert 0 <= clear_i < fetch_i, (
"the row clearing must precede the fetch (a populated → empty refresh "
"must not leave ghost rows)"
)
assert 'fetch("/api/docs/tree")' in body, "the load must fetch the tree endpoint"
assert "++loadSeq" in body, "the race token stays (phase 79)"
render = js.find("function renderLevel()")
assert render != -1, "renderLevel must exist"
render_body = js[render : js.find("\n }", render)]
for container in ("foldersTbody", "tbody"):
clear_i = render_body.find(f"{container}.replaceChildren()")
append_i = render_body.find(f"{container}.appendChild")
assert 0 <= clear_i < append_i, (
f"{container}: the clear must precede the fill (a populated → "
"sparser refresh must not leave ghost rows)"
)
def test_git_sources_view_refetches_on_reshow() -> None:
+4 -4
View File
@@ -285,7 +285,7 @@ def test_tick_decision_tree_order_and_branches() -> None:
'syncResult.textContent = ""',
"hideSyncError()",
'emitSyncStatus({ state: "idle" })',
"loadDocs()",
"loadTree()", # phase 97: the catalog load is the tree fetch
):
assert line in up_ok, f"the upload-success settle must carry {line!r}"
assert "fmtSyncResult" not in up_ok, "no upload counts in #sync-result (A3)"
@@ -300,7 +300,7 @@ def test_tick_decision_tree_order_and_branches() -> None:
'emitSyncStatus({ state: "idle" })',
):
assert line in up_fail, f"the upload-failed settle must carry {line!r}"
assert "loadDocs()" not in up_fail, "no KB change on a failed upload"
assert "loadTree()" not in up_fail, "no KB change on a failed upload"
assert "showSyncError" not in up_fail, "no banner on this page (A3)"
assert "applySyncFailure" not in up_fail and "showSyncModal" not in up_fail
# 7. both idle: the unchanged idle settle.
@@ -333,7 +333,7 @@ def test_reattach_adopts_a_running_upload_only() -> None:
upload run re-attaches (running state, upload kind — phase 90: no
live file, the label stays bare "Importing…" — the synthetic
running frame, the poll starts); a terminal upload is a no-op —
the fall-through is the plain idle settle (the boot-time loadDocs()
the fall-through is the plain idle settle (the boot-time loadTree()
already shows the current catalog)."""
body = _fn(_js(), "initSyncButton")
assert "await fetchIsAdmin()" in body, "admin-only (no extra fetch)"
@@ -370,7 +370,7 @@ def test_section_header_documents_the_two_job_contract() -> None:
header = js[marker : js.find("const syncBtn")]
assert "Phase 64 (task 04)" in header
assert "/api/git-sources/upload/status" in header, "the second job's endpoint"
assert "loadDocs" in header, "the A3 catalog refresh"
assert "loadTree" in header, "the A3 catalog refresh (phase 97 rename)"
assert "A3" in header and "A4" in header
+285
View File
@@ -0,0 +1,285 @@
"""Unit tests: ``app.api.docs.build_kb_tree`` (phase 97, task 02).
The PURE tree builder behind ``GET /api/docs/tree`` — the RAG view's
drill-down tree, the same tree the agent's ``ls`` walks plus file
metadata. Driven without a database (module-level function, plain
inputs): multi-source ordering (the superset rule), the 0-document
registered source, the nested recursive counts, the phase-94 existence
rule, the file/folder name collision, ordering, summaries, verbatim
file metadata, and the ``group_folder_listing`` cross-check property
("the UI shows what the agent sees") at the root and one nested level.
"""
from __future__ import annotations
from app.api.docs import TreeDocRow, build_kb_tree
from app.rag.agent import group_folder_listing
T0 = "2026-09-01T08:00:00+00:00"
T1 = "2026-09-02T08:00:00+00:00"
T2 = "2026-09-03T08:00:00+00:00"
def _folder_nodes(node) -> list:
"""The folder-kind children of a source/folder node, in order."""
return [child for child in node.children if child.kind == "folder"]
def _file_nodes(node) -> list:
"""The file-kind children of a source/folder node, in order."""
return [child for child in node.children if child.kind == "file"]
def test_multi_source_registry_order_leads_and_indexed_only_appended() -> None:
"""Sources = the registry names in order, then the distinct
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
]
tree = build_kb_tree(names, doc_rows, {})
assert [s.name for s in tree] == ["beta", "alpha", "empty", "delta", "gamma"]
# The registry sources keep their registry order even though it is
# not alphabetical; the indexed-only ones trail, sorted.
assert tree[0].name == "beta"
assert tree[1].name == "alpha"
assert tree[3].name == "delta"
assert tree[4].name == "gamma"
def test_registered_zero_document_source_lists_empty() -> None:
"""A registered source with no indexed documents lists (the
phase-70/72 invariant): ``documents: 0``, no children, summary null
when nothing is stored."""
tree = build_kb_tree(["solo"], [], {})
assert len(tree) == 1
assert tree[0].name == "solo"
assert tree[0].documents == 0
assert tree[0].children == []
assert tree[0].summary is None
def test_nested_document_counts_into_source_ancestors_and_own_folder() -> None:
"""A document under ``a/b/c/`` contributes to the source, to ``a``,
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),
]
(source,) = build_kb_tree(["S"], rows, {})
assert source.documents == 4
a = _folder_nodes(source)[0]
assert a.path == "a"
assert a.documents == 3 # deep + shallow + top
a_b = _folder_nodes(a)[0]
assert a_b.path == "a/b"
assert a_b.documents == 2 # deep + shallow
a_b_c = _folder_nodes(a_b)[0]
assert a_b_c.path == "a/b/c"
assert a_b_c.documents == 1 # deep only
assert [f.path for f in _file_nodes(a_b_c)] == ["a/b/c/deep.md"]
assert [f.path for f in _file_nodes(a)] == ["a/top.md"]
def test_existence_rule_a_file_path_is_never_a_folder() -> None:
"""A folder node appears only with a true descendant (some path
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),
]
(source,) = build_kb_tree(["S"], rows, {})
folders = _folder_nodes(source)
# Only ``x.y`` exists (``x.y/z.md`` starts with ``x.y/``); ``x`` and
# ``x.md`` are file names, not folders.
assert [f.path for f in folders] == ["x.y"]
assert [f.path for f in _file_nodes(source)] == ["x.md"]
assert [f.path for f in _file_nodes(folders[0])] == ["x.y/z.md"]
def test_file_folder_name_collision_both_appear() -> None:
"""A document sharing a directory's name: BOTH appear — the folder
node (via its descendants) and the file node (its own row); the
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
]
(source,) = build_kb_tree(["S"], rows, {})
folders = _folder_nodes(source)
files = _file_nodes(source)
assert [f.path for f in folders] == ["a"]
assert folders[0].documents == 2 # "a" itself + "a/b.md"
assert [f.path for f in files] == ["a"]
assert files[0].title == "File A"
assert [f.path for f in _file_nodes(folders[0])] == ["a/b.md"]
def test_subfolder_path_order_and_file_catalog_order() -> None:
"""Direct subfolders list in path (sorted) order; direct files keep
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
]
(source,) = build_kb_tree(["S"], rows, {})
assert [f.path for f in _folder_nodes(source)] == ["alpha", "beta", "mike", "zeta"]
# Input order is preserved for the files (z-file.md precedes
# a-file.md in the input, so it does too here — catalog order is
# the INPUT order, not a re-sort).
assert [f.path for f in _file_nodes(source)] == ["z-file.md", "a-file.md"]
def test_summaries_present_and_absent() -> None:
"""``summary`` is the stored row (source root ``""`` or folder
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),
]
summaries = {("S", ""): "Source desc.", ("S", "one"): "One desc."}
# ("S", "two") is NOT stored → null.
(source,) = build_kb_tree(["S"], rows, summaries)
assert source.summary == "Source desc."
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 = _file_nodes(one)[0]
assert set(file.model_dump()) == {"kind", "path", "title", "chunks", "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)]
(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.indexed_at == T2
def test_empty_inputs_empty_tree() -> None:
"""No registry sources and no indexed documents → an empty tree
(the RAG view's ``{"sources": []}`` case)."""
assert build_kb_tree([], [], {}) == []
def test_indexed_document_under_unlisted_source_is_impossible() -> None:
"""By construction (the superset rule) every doc source is listed —
the registry names lead and every other doc source follows; there
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),
]
tree = build_kb_tree(names, rows, {})
listed = [s.name for s in tree]
assert listed == ["reg-b", "reg-a", "aaa", "zzz"]
doc_paths = {
f.path for s in tree for f in s.children if f.kind == "file"
}
# Every document from the input is present exactly once.
assert doc_paths == {"b.md", "z.md", "a.md"}
assert sum(s.documents for s in tree) == len(rows)
# --------------------------------------------------------------------
# The cross-check property — "the UI shows what the agent sees":
# for a single-source dataset the builder's level equals
# ``app.rag.agent.group_folder_listing``'s output.
# --------------------------------------------------------------------
#: The shared single-source dataset: nested folders, root files, a
#: 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),
]
CROSS_SUMMARIES = {
("S", ""): "Source desc.",
("S", "one"): "One desc.",
("S", "zz"): "Zz desc.",
# ("S", "one/two") deliberately unstored → null at that level.
}
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_summaries = {
folder_path: summary
for (source, folder_path), summary in CROSS_SUMMARIES.items()
if source == "S"
}
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
]
def test_cross_check_root_level_matches_group_folder_listing() -> None:
"""At the source root the builder's direct subfolders / direct
files equal the agent's root level, element for element."""
(source,) = build_kb_tree(["S"], CROSS_ROWS, CROSS_SUMMARIES)
_cross_check("", source)
# And the explicit expectations (the test is readable without the
# helper): one → 4 docs (its whole subtree), zz → 2.
assert source.documents == 8
assert [(f.path, f.documents, f.summary) for f in _folder_nodes(source)] == [
("one", 4, "One desc."),
("zz", 2, "Zz desc."),
]
assert [(f.path, f.title) for f in _file_nodes(source)] == [
("note", "Note"),
("root.md", "Root"),
]
def test_cross_check_nested_level_matches_group_folder_listing() -> None:
"""One nested level (``one``): subfolder ``one/two`` (2 docs, no
stored summary) + the direct files ``one/a.md`` / ``one/b.md`` —
equal to the agent's drill into the same folder."""
(source,) = build_kb_tree(["S"], CROSS_ROWS, CROSS_SUMMARIES)
one = next(f for f in _folder_nodes(source) if f.path == "one")
_cross_check("one", one)
assert [(f.path, f.documents, f.summary) for f in _folder_nodes(one)] == [
("one/two", 2, None),
]
assert [(f.path, f.title) for f in _file_nodes(one)] == [
("one/a.md", "A"),
("one/b.md", "B"),
]
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -505,7 +505,9 @@ def test_sources_js_renders_result_line_and_banner_directly() -> None:
assert "syncResult.textContent = fmtSyncResult(status.detail)" in success, (
"the counts live in status.detail — a bare status renders all zeros"
)
assert "loadDocs()" in success, "the catalog re-fetches live on a successful sync"
assert "loadTree()" in success, (
"the catalog re-fetches live on a successful sync (phase 97: the tree)"
)
failure = _body(js, "applySyncFailure")
assert "showSyncError(error)" in failure
assert "emitSyncStatus" in _body(js, "applySyncIdle")