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).
This commit is contained in:
@@ -310,9 +310,9 @@ def test_docs_tree_indexed_only_source_after_registered(admin_client, db) -> Non
|
||||
assert (alpha["documents"], alpha["children"], alpha["summary"]) == (0, [], None)
|
||||
assert alpha["summary_pending"] is False # 0 documents — never pending
|
||||
assert midx["documents"] == 1
|
||||
assert midx["summary_pending"] is False # 1 document — below the minimum
|
||||
assert midx["summary_pending"] is True # 1 document — the ≥ 1 minimum, no row
|
||||
assert zeta["documents"] == 1
|
||||
assert zeta["summary_pending"] is False # 1 document — below the minimum
|
||||
assert zeta["summary_pending"] is True # 1 document — the ≥ 1 minimum, no row
|
||||
assert [c["path"] for c in midx["children"]] == ["m1.md"]
|
||||
|
||||
_truncate_tree_tables(db)
|
||||
@@ -321,16 +321,16 @@ def test_docs_tree_indexed_only_source_after_registered(admin_client, db) -> Non
|
||||
def test_docs_tree_summary_pending_on_source_and_folder_nodes(admin_client, db) -> None:
|
||||
"""The endpoint returns ``summary_pending`` on SOURCE + FOLDER
|
||||
nodes (phase 98, task 03 — D3): true iff the recursive count is
|
||||
≥ 2 AND no stored row; false WITH a stored row (any — the endpoint
|
||||
cannot tell AI from manual); false for a < 2-document folder
|
||||
(never pending) — including one NESTED. File nodes carry no flag."""
|
||||
≥ 1 AND no stored row — the NESTED 1-doc folder included (the ≥ 1
|
||||
rule); false WITH a stored row (any — the endpoint cannot tell AI
|
||||
from manual). File nodes carry no flag."""
|
||||
_truncate_tree_tables(db)
|
||||
base = datetime.now(UTC)
|
||||
db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git", added_at=base))
|
||||
# k8s → 3 documents (talos + cluster + charts), NO stored row → pending
|
||||
_seed_doc(db, "Homelab", "k8s/talos.md", "Talos", 1, base)
|
||||
_seed_doc(db, "Homelab", "k8s/cluster.md", "Cluster", 1, base)
|
||||
# k8s/helm → 1 document — below the 2-doc minimum, never pending
|
||||
# k8s/helm → 1 document — a candidate at the ≥ 1 rule (no row)
|
||||
_seed_doc(db, "Homelab", "k8s/helm/charts.md", "Charts", 1, base)
|
||||
# wiki → 2 documents, WITH a stored row → not pending
|
||||
_seed_doc(db, "Homelab", "wiki/one.md", "One", 1, base)
|
||||
@@ -360,7 +360,7 @@ def test_docs_tree_summary_pending_on_source_and_folder_nodes(admin_client, db)
|
||||
assert k8s["summary_pending"] is True # 3 docs, no stored row
|
||||
helm = k8s["children"][0]
|
||||
assert (helm["kind"], helm["path"]) == ("folder", "k8s/helm")
|
||||
assert helm["summary_pending"] is False # 1 doc — never pending
|
||||
assert helm["summary_pending"] is True # 1 doc — pending at the ≥ 1 rule
|
||||
assert wiki["summary"] == "Wiki pages."
|
||||
assert wiki["summary_pending"] is False # 2 docs, but a stored row covers it
|
||||
# File nodes carry no pending flag at all (the file table has no
|
||||
@@ -397,8 +397,9 @@ def test_docs_tree_pending_set_equals_missing_folder_summaries(admin_client, db)
|
||||
added_at=base + timedelta(hours=2),
|
||||
)
|
||||
)
|
||||
# Alpha: a/b holds 2 docs, c holds 1 (NEVER a candidate), the root
|
||||
# holds 4 — candidates (Alpha, ""), (Alpha, "a"), (Alpha, "a/b").
|
||||
# Alpha: a/b holds 2 docs, c holds 1 (a candidate at the ≥ 1
|
||||
# rule), the root holds 4 — candidates (Alpha, ""), (Alpha, "a"),
|
||||
# (Alpha, "a/b"), (Alpha, "c").
|
||||
_seed_doc(db, "Alpha", "a/b/c1.md", "C1", 1, base)
|
||||
_seed_doc(db, "Alpha", "a/b/c2.md", "C2", 1, base)
|
||||
_seed_doc(db, "Alpha", "c/solo.md", "Solo", 1, base)
|
||||
@@ -416,6 +417,7 @@ def test_docs_tree_pending_set_equals_missing_folder_summaries(admin_client, db)
|
||||
("Alpha", ""): "Alpha root.",
|
||||
("Alpha", "a"): "Alpha a.",
|
||||
("Alpha", "a/b"): "Alpha a b.",
|
||||
("Alpha", "c"): "Alpha c.",
|
||||
("Beta", ""): "Beta root.",
|
||||
("Beta", "x"): "Beta x.",
|
||||
}.items()
|
||||
@@ -441,8 +443,10 @@ def test_docs_tree_pending_set_equals_missing_folder_summaries(admin_client, db)
|
||||
# And the explicit expectation (the test is readable without the
|
||||
# helper): exactly the two deleted keys, root riding "".
|
||||
assert pending == {("Alpha", "a"), ("Beta", "")}
|
||||
# Never flagged: the < 2-doc folder (Alpha/c), the 0-document
|
||||
# registered source (Gamma), and every node that still holds a row.
|
||||
# Never flagged: the 0-document registered source (Gamma) and every
|
||||
# node that still holds a row — incl. the 1-doc Alpha/c, whose
|
||||
# stored row covers it (the ≥ 1 rule makes it a candidate, but a
|
||||
# candidate WITH a row is not missing).
|
||||
assert ("Alpha", "c") not in pending
|
||||
assert not any(name == "Gamma" for name, _ in pending)
|
||||
assert ("Alpha", "a/b") not in pending
|
||||
@@ -464,8 +468,8 @@ OLDER_STAMP = datetime(2020, 1, 1, tzinfo=UTC)
|
||||
|
||||
|
||||
def _seed_folder_pair(db, source: str, folder: str, base: datetime) -> None:
|
||||
"""Two documents under ``source/folder`` — the ≥ 2-document
|
||||
minimum a folder must hold to be summarizable."""
|
||||
"""Two documents under ``source/folder`` — a summarizable folder
|
||||
(≥ 1 doc at the current minimum; two keep the pair realistic)."""
|
||||
_seed_doc(db, source, f"{folder}/one.md", f"{folder} one", 1, base)
|
||||
_seed_doc(db, source, f"{folder}/two.md", f"{folder} two", 2, base)
|
||||
|
||||
@@ -543,15 +547,17 @@ def test_folder_summary_create_where_no_row_exists(
|
||||
admin_client: TestClient, db
|
||||
) -> None:
|
||||
"""A manual description can be CREATED where no row exists — a
|
||||
< 2-document folder the generator never wrote (or its fail-soft
|
||||
miss): insert, not update, with ``manually_edited = true``."""
|
||||
folder the generator has not written yet (its fail-soft miss, or
|
||||
a pre-sync state): insert, not update, with
|
||||
``manually_edited = true``."""
|
||||
_truncate_tree_tables(db)
|
||||
base = datetime.now(UTC)
|
||||
db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git", added_at=base))
|
||||
_seed_doc(db, "Homelab", "solo/only.md", "Only", 1, base) # 1-doc folder
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
assert _get_folder_row(db, "Homelab", "solo") is None # no AI row for < 2 docs
|
||||
# No AI row yet (the generator has not run in this test).
|
||||
assert _get_folder_row(db, "Homelab", "solo") is None
|
||||
|
||||
r = admin_client.patch(
|
||||
"/api/folders/summary",
|
||||
|
||||
Reference in New Issue
Block a user