Files
brain-of-reese/tests/unit/test_kb_tree_builder.py
T
ducoterra 35d65d2f25
Build and Push Containers / build-and-push-app (push) Successful in 2m10s
Build and Push Containers / build-and-push-db (push) Successful in 13s
feat(rag): summarize single-document folders (MIN_DOCS_PER_FOLDER 2 → 1)
Relax the phase-94 folder-summary scope rule from ≥ 2 documents to
≥ 1: a folder (or source root) is a candidate while ANY document
lives under it, so single-file folders and single-file source roots
get their own lite-written description. A row is now pruned only
when its folder loses its last document (vanishes from the
catalogue).

The constant is the single source of truth, so the flip propagates
to the generator's candidate set, the prune pass, the
missing_folder_summaries gap probe (the next sync self-heals the
new gaps), and the KB-tree summary_pending markers (1-doc folders /
sources now read "Summary pending" until their row lands).

Docstrings/comments across app/, scripts/import_docs.py, and the
E2E fixtures updated to the ≥ 1 wording. Unit + integration tests
updated to the new semantics (the pruned-below-minimum scenario is
now a folder losing its LAST doc; single-doc folders are pinned as
candidates/pending). Full suite: 2314 passed, app coverage 99%;
ruff + pyright clean; folder-summary E2E stories pass in isolation
(ls_tree_drilldown, sync_summary_visibility, kb_tree,
kb_tree_nav, document_dates, oneshot_llm_retry).
2026-09-14 08:57:57 -04:00

549 lines
23 KiB
Python

"""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"
# 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."""
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, 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"]
# 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
# 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:
"""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, 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
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, C0),
("S", "x.y/z.md", "Z", 1, T0, C1),
]
(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, 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)
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, 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"]
# 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, 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.
(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);
# 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", "created_at", "indexed_at"
}
assert "summary" not in file.__class__.model_fields
def test_file_metadata_unchanged_in_tree() -> None:
"""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
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, 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]
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, 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 = {
("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
``(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()
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, f.created_at[:10]) for f in _file_nodes(builder_node)] == [
(path, title, date) for _source, path, title, date 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"),
]
# --------------------------------------------------------------------
# summary_pending (phase 98, task 03) — the D3 rule (ONE concept):
# a SOURCE or FOLDER node is pending iff its recursive document count
# ≥ MIN_DOCS_PER_FOLDER (1) AND it has NO stored folder_summaries row
# (AI or manual — any row). That is exactly
# ``app.rag.folder_summaries.missing_folder_summaries``'s candidate
# set — the marker never drifts from the gap-fill (the integration
# cross-check in ``tests/integration/test_docs_api.py`` pins it
# end to end). FILE nodes carry no flag.
# --------------------------------------------------------------------
def test_folder_two_docs_no_stored_row_is_pending() -> None:
"""A ≥ 2-doc folder with NO stored row → ``summary_pending`` true
(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, C0),
("S", "one/b.md", "B", 1, T0, C0),
]
(source,) = build_kb_tree(["S"], rows, {})
(one,) = _folder_nodes(source)
assert one.documents == 2
assert one.summary is None
assert one.summary_pending is True
file = _file_nodes(one)[0]
assert "summary_pending" not in file.__class__.model_fields
assert "summary_pending" not in file.model_dump()
def test_folder_with_stored_row_is_not_pending() -> None:
"""The same ≥ 2-doc folder WITH a stored row — ANY row, the builder
cannot tell AI from manual — is NOT pending (a description exists).
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, C0),
("S", "one/b.md", "B", 1, T0, C0),
]
(source,) = build_kb_tree(["S"], rows, {("S", "one"): "Manual."})
(one,) = _folder_nodes(source)
assert one.summary == "Manual."
assert one.summary_pending is False
# The source root (2 docs) has no (source, "") row of its own.
assert source.summary is None
assert source.summary_pending is True
def test_single_document_folder_is_pending() -> None:
"""The ≥ 1 rule: a 1-document folder with NO stored row IS pending
(its one file line no longer exempts it — the next sync
summarizes it), as is its source root (2 docs, no root row).
A registered 0-document source is the only never-pending case."""
rows = [
("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)
assert solo.documents == 1
assert solo.summary_pending is True
assert source.summary_pending is True
def test_name_collision_pending_follows_recursive_count() -> None:
"""The phase-94 count-rule edge: documents ``one/a`` AND ``one/a/b``
→ folder ``one/a`` exists and its recursive count is 2 (the document
whose path EQUALS the folder name counts — the ``path == folder``
arm) → pending true with no stored row, even though the folder has
only ONE direct file — pending follows the RECURSIVE count, not
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, 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]
assert one.path == "one"
assert one.documents == 2
assert one.summary_pending is True
a = _folder_nodes(one)[0]
assert a.path == "one/a"
assert a.documents == 2 # "one/a" itself + "one/a/b.md"
assert len(_file_nodes(a)) == 1 # ONE direct file — the count is not that
assert a.summary_pending is True
# Stored row on the nested folder only: it clears that node, and
# only that node.
(nested,) = build_kb_tree(["S"], rows, {("S", "one/a"): "Nested."})
one2 = _folder_nodes(nested)[0]
a2 = _folder_nodes(one2)[0]
assert a2.summary == "Nested."
assert a2.summary_pending is False
assert one2.summary_pending is True
def test_source_root_pending_and_zero_document_source_never() -> None:
"""The source root: a source with ≥ 1 docs and NO ``(source, "")``
row → the SOURCE node is pending; the stored root row clears it.
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, C0),
("Full", "y.md", "Y", 1, T0, C1),
]
full, empty = build_kb_tree(["Full", "Empty"], rows, {})
assert full.documents == 2
assert full.summary_pending is True
assert empty.documents == 0
assert empty.summary_pending is False
full2, empty2 = build_kb_tree(
["Full", "Empty"], rows, {("Full", ""): "Root desc."}
)
assert full2.summary == "Root desc."
assert full2.summary_pending is False
assert empty2.summary_pending is False
def test_two_sources_pending_independently() -> None:
"""Pending is computed per source: with one source holding a root
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, 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