feat(rag): summarize single-document folders (MIN_DOCS_PER_FOLDER 2 → 1)
Build and Push Containers / build-and-push-app (push) Successful in 2m10s
Build and Push Containers / build-and-push-db (push) Successful in 13s

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:
2026-09-14 08:57:57 -04:00
parent 3a81793565
commit 35d65d2f25
13 changed files with 297 additions and 212 deletions
+10 -8
View File
@@ -576,7 +576,7 @@ def build_kb_tree(
* **Pending** — ``summary_pending`` on the SOURCE and every FOLDER
node (phase 98, decision D3 — ONE concept): true iff the node's
recursive ``documents`` count ≥
:data:`app.rag.folder_summaries.MIN_DOCS_PER_FOLDER` (2) AND it
:data:`app.rag.folder_summaries.MIN_DOCS_PER_FOLDER` (1) AND it
has NO stored ``folder_summaries`` row (AI or manual — any row;
the builder sees stored rows only). That is EXACTLY
:func:`app.rag.folder_summaries.missing_folder_summaries`'s
@@ -584,9 +584,10 @@ def build_kb_tree(
keys on the next sync — the marker is honest: "waiting to
generate", and the integration cross-check pins the tree's
pending set to that function so the marker can never drift from
the gap-fill). A < 2-document folder is NEVER pending (it never
gets a summary — its one file line IS its description), and a
registered 0-document source never is.
the gap-fill). A 0-document folder cannot exist (a folder is a
catalogue prefix only) — every existing folder with no row is
pending, single-file folders included — and a registered
0-document source never is.
ONE concept end to end: the builder reuses
:func:`app.rag.folder_summaries.folder_of` and the phase-94
@@ -674,10 +675,11 @@ def list_kb_tree(
exactly as ``app/api/chat.py`` does) + the SAME outerjoin/grouped
catalogue query ``GET /api/docs`` runs (the document ``id``
excluded — the tree has no document ids) + ALL stored
``folder_summaries`` rows (a bounded select — one row per ≥ 2-doc
folder; rows for sources the tree does not list are never
referenced by the builder) — through the pure
:func:`build_kb_tree`. ``GET /api/docs`` itself is untouched.
``folder_summaries`` rows (a bounded select — one row per
existing folder at the ≥ 1-doc rule; rows for sources the tree
does not list are never referenced by the builder) — through the
pure :func:`build_kb_tree`. ``GET /api/docs`` itself is
untouched.
"""
names = list_source_names(db)
rows = db.execute(
+2 -1
View File
@@ -353,7 +353,8 @@ async def _run_sync() -> None:
# changed KB (added + updated > 0) is a full regeneration
# (today's behavior, byte-identical); an unchanged re-sync
# takes the GAP probe instead of the old table-empty one — a
# candidate folder (at least 2 docs) with no stored row: a gap
# candidate folder (at least 1 doc — every existing folder)
# with no stored row: a gap
# fills ONLY the missing rows (only_missing=True — the
# subsumed table-empty first-sync trigger included, where
# every candidate is missing), a complete table burns zero