phase: 97_kb_tree_catalog
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:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user