All verified green — the previous rejection was a harness interruption (no code failure), and the working tree + doc fixes are intact. **Phase 127 — final verification pass: 100% green** (all 4 tasks already complete; no code changes needed this pass) - **Re-verified implementation:** `ImportSummary.changed_paths` at all 5 increment sites + `_prune`; `affected_folder_keys` + `generate_folder_summaries(changed_docs=…)` (restricted keys, prune pass both modes, hook total = affected count); sync changed-KB branch passes `changed_docs=summary.changed_paths`; CLI keeps full-regen contract - **Tests:** `uv run pytest --cov=app --cov-report=term-missing` → **3022 passed**, `app/` coverage **99%**; `uv run pytest tests/e2e/test_incremental_folder_summaries.py -v --no-cov` → **3 passed in isolation**; `uv run ruff check . && uv run pyright` → clean, **0 errors** - **Completion criteria:** one-file change rewrites only its folder chain (E2E pins siblings byte-identical, text AND `updated_at`) ✅; coverage >90% ✅; isolated E2E green ✅; no completed-phase behavior change (full suite green) ✅; atomic commit — delegated to the harness per pass rules (no git commands run by me) - **Docs:** README.md re-checked — all 7 previous-pass fixes still in place (Theme page row, drill-down `ls` + capped `read`, phase-124 answer images section + `BOR_ANSWER_IMAGES`, markdown-included summaries, 17-color palette, folder summaries in `BOR_LLM_SUMMARY_MODEL`, CLI regen note); AGENTS.md verified consistent — no changes needed - **Deviations:** none; untracked owner file `NOTES.md` left untouched (rides in the harness commit) - **Next pending phase:** `128_sync_label_mobile`
606 lines
27 KiB
Python
606 lines
27 KiB
Python
"""Folder summary generator (phase 94, task 01).
|
||
|
||
``ls`` becomes a drill-down tree (phase 94, ``00_phase.md``): the LLM
|
||
lists the synced projects, then drills into folders. Each level's
|
||
listing shows the folder's **subtree summary** — a 1–3 sentence
|
||
plain-text description of what the folder's documents cover, generated
|
||
at SYNC time by the aipi ``lite`` model (the ``KB_OVERVIEW_MODE``
|
||
contract of ``app.rag.overview``: change-gated by the caller,
|
||
fail-soft — an old summary is better than none — and E2E-mockable via
|
||
the ``FOLDER_SUMMARY_MODE`` system-prompt marker).
|
||
|
||
ONE concept end to end (:func:`group_by_folder`): a folder row's
|
||
documents are its **recursive subtree** — every document whose path
|
||
equals the folder or starts with ``folder + "/"`` — exactly the set the
|
||
``ls`` count rule (``00_phase.md``) counts. A document under ``a/b/``
|
||
therefore contributes to the ``a``, ``a/b``, and ``""`` (source root)
|
||
groups alike: each level's listing shows its own accurate subtree
|
||
summary, and the number next to a folder is the number of documents its
|
||
summary describes.
|
||
|
||
Storage: ``folder_summaries`` (migration 0017) — PK
|
||
``(source, folder_path)``; ``folder_path = ""`` is the SOURCE ROOT
|
||
(the top-level source summary). Rows exist for every folder whose
|
||
recursive subtree holds ≥ 1 document (the
|
||
:data:`MIN_DOCS_PER_FOLDER` rule — a folder is a catalogue prefix
|
||
only while some document lives under it, so EVERY existing folder is
|
||
a candidate, single-file folders and single-file source roots
|
||
included); after a changed sync, rows whose folder dropped below 1
|
||
document (vanished from the catalogue) are pruned (a pruned/renamed
|
||
folder's summary would otherwise go stale), while rows for folders
|
||
that still hold at least one document persist (an unchanged folder's
|
||
summary is still true).
|
||
|
||
Manual rows (phase 97, task 01): ``manually_edited`` (migration 0018)
|
||
marks the descriptions the OWNER edited — ``PATCH /api/folders/
|
||
summary`` (phase 97, task 03) is the ONLY writer. The generator's two
|
||
rules for a manual row: it is SKIPPED on regeneration (no ``lite``
|
||
burn on owner text — counted ``kept_manual`` in the stats) and it is
|
||
NEVER pruned (owner content persists until the owner clears it — even
|
||
for a folder that dropped below the minimum). Clearing the
|
||
description DELETES the row, so the next KB-changing sync regenerates
|
||
an AI description for that folder (the reset path).
|
||
|
||
Chat turns never generate folder summaries — the agent's ``ls`` output
|
||
(phase 94, task 03) only reads the stored rows. Generation is the
|
||
caller's job at sync time (phase 94, task 02), and :func:`generate_
|
||
folder_summaries` only flushes — the sync path owns the transaction
|
||
(the phase-53 ``bump_sources_version`` convention). The sync path
|
||
additionally passes the optional ``on_progress`` hook (phase 98,
|
||
task 01) so the sync status can report the folder being summarized;
|
||
the ``scripts/import_docs.py`` CLI passes none — the hook defaults to
|
||
``None`` and is a zero-cost no-op, leaving the CLI's log-only stats
|
||
contract untouched.
|
||
|
||
Self-heal (phase 96): an exhausted one-shot retry can still leave a
|
||
candidate folder without a row. :func:`missing_folder_summaries`
|
||
names those gaps (the generator's exact candidate computation minus
|
||
the stored keys — one concept, as with :func:`group_by_folder`), and
|
||
:func:`generate_folder_summaries`'s ``only_missing=True`` fills
|
||
EXACTLY those on the next sync — existing rows stay
|
||
byte-identical (text AND ``updated_at``), the prune pass still runs.
|
||
|
||
Restricted regeneration (phase 127, task 02): a changed-KB sync no
|
||
longer rewrites EVERY folder — the sync path passes the run's changed
|
||
documents (``ImportSummary.changed_paths``) as ``changed_docs=`` and
|
||
the generator then rewrites ONLY the candidate folders the change
|
||
touches — each document's folder chain up to the source root
|
||
(:func:`affected_folder_keys`, the SAME prefix walk
|
||
:func:`group_by_folder` pass 1 uses — one concept); every other
|
||
stored row keeps its text AND ``updated_at``. The
|
||
``scripts/import_docs.py`` CLI keeps the byte-identical full
|
||
regeneration (``changed_docs=None``).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from collections.abc import Callable, Iterable, Sequence
|
||
from datetime import UTC, datetime
|
||
from typing import Protocol
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.config import Settings, get_settings
|
||
from app.models import Document, FolderSummary
|
||
from app.rag.llm import LLMError
|
||
from app.rag.retriever import TRUNCATION_MARKER
|
||
|
||
logger = logging.getLogger("app.rag.folder_summaries")
|
||
|
||
#: System-prompt marker for folder-summary generation — the E2E mock
|
||
#: LLM keys on it (same convention as ``SUMMARY_MODE`` /
|
||
#: ``KB_OVERVIEW_MODE``, PLAN §6).
|
||
FOLDER_SUMMARY_MODE = "FOLDER_SUMMARY_MODE"
|
||
|
||
#: Locked instruction for the ``lite`` model (phase 94): the folder
|
||
#: summary is the drill-down ``ls``'s per-level picture, so it must be
|
||
#: a short plain-text description of what the folder's documents
|
||
#: cover — 1–3 sentences (a folder is a skim, not a read: the document
|
||
#: summary's 3–6 sentences would blow up a 20-folder listing),
|
||
#: strictly grounded in the listed titles/paths/summary lines.
|
||
FOLDER_SUMMARY_INSTRUCTION = (
|
||
"From the document list below, write a 1-3 sentence plain-text "
|
||
"summary of what this folder's documents cover, in natural "
|
||
"language. Do not use markdown. Do not invent anything that is not "
|
||
"in the list."
|
||
)
|
||
|
||
#: Full system prompt: marker first (the mock's key), then the
|
||
#: instruction (the ``app.rag.overview.SYSTEM_PROMPT`` shape).
|
||
SYSTEM_PROMPT = f"{FOLDER_SUMMARY_MODE}: {FOLDER_SUMMARY_INSTRUCTION}"
|
||
|
||
#: First line of the user message — the folder the summary describes:
|
||
#: ``<source>`` for the source root, ``<source>/<folder_path>`` for a
|
||
#: folder. The deterministic E2E mock keys on it to name the folder in
|
||
#: its canned reply (``tests/e2e/mock_llm.py``), so the stored row is
|
||
#: a pure function of the request.
|
||
FOLDER_HEADER_PREFIX = "Folder: "
|
||
|
||
#: A folder is summarized only while its recursive subtree holds at
|
||
#: least this many documents — 1: EVERY folder the catalogue knows
|
||
#: (a folder exists only while some document lives under it, so the
|
||
#: filter admits every group :func:`group_by_folder` returns,
|
||
#: single-file folders included) gets a row (the phase-94 ≥ 2 scope
|
||
#: rule relaxed by owner decision; the prune rule applies the same
|
||
#: count — a row is pruned when its folder loses its last document).
|
||
MIN_DOCS_PER_FOLDER = 1
|
||
|
||
#: One document row for the grouping/prompting:
|
||
#: ``(source, path, title, summary)`` — the ``app.rag.overview``
|
||
#: ``build_overview_prompt`` row shape (``summary`` is the stored
|
||
#: lite-written text or ``None`` for markdown docs / the fail-soft
|
||
#: path).
|
||
DocRow = tuple[str, str, str, str | None]
|
||
|
||
|
||
class FolderSummaryLLM(Protocol):
|
||
"""The one-shot chat surface the folder-summary generator needs.
|
||
|
||
:class:`app.rag.llm.LLMClient` satisfies it; unit tests pass a
|
||
duck-typed fake (``chat`` + ``settings``) instead — same pattern as
|
||
the overview's ``OverviewLLM`` protocol.
|
||
"""
|
||
|
||
settings: Settings
|
||
|
||
async def chat(
|
||
self, messages: list[dict[str, str]], model: str | None = None
|
||
) -> str: ...
|
||
|
||
|
||
def folder_of(path: str) -> str:
|
||
"""The directory prefix before the last ``/`` (``""`` for root-level files).
|
||
|
||
The single notion of "which folder owns this document" used by the
|
||
whole module: ``folder_of("a.md") == ""``,
|
||
``folder_of("a/b.md") == "a"``, ``folder_of("a/b/c.md") == "a/b"``.
|
||
Iterating :func:`folder_of` over its own result walks a path's
|
||
folder prefixes from nearest to farthest, ending at ``""``.
|
||
"""
|
||
idx = path.rfind("/")
|
||
return path[:idx] if idx >= 0 else ""
|
||
|
||
|
||
def group_by_folder(rows: Sequence[DocRow]) -> dict[tuple[str, str], list[DocRow]]:
|
||
"""Group document rows by the folders their recursive subtree fills.
|
||
|
||
The ONE concept of this module, documented in the module docstring:
|
||
a folder row's documents are its **recursive subtree** — every
|
||
document whose path equals the folder or starts with
|
||
``folder + "/"`` — exactly the set the ``ls`` count rule counts.
|
||
Each row therefore lands in the ``""`` (source root) group AND in
|
||
the group of every folder prefix of its path: a doc under
|
||
``a/b/`` contributes to the ``a``, ``a/b``, and ``""`` groups.
|
||
Per source the candidate rows are thus ``""`` (all of the source's
|
||
documents — the top-level source summary) plus every distinct
|
||
folder prefix of an indexed path.
|
||
|
||
*rows* are ``(source, path, title, summary)`` tuples (the
|
||
:data:`DocRow` shape, e.g. straight from the ``documents``
|
||
catalogue query). Returns ``{(source, folder_path): [rows]}`` —
|
||
group lists keep the input (catalogue) order, so downstream prompt
|
||
building is deterministic. Groups of ANY size (≥ 1) are returned;
|
||
at the :data:`MIN_DOCS_PER_FOLDER` = 1 rule every group is
|
||
already a candidate, so :func:`generate_folder_summaries`'s
|
||
filter is an inert safety net (it would bite only if the minimum
|
||
were ever raised above 1), not a behavioral gate.
|
||
|
||
"""
|
||
# Pass 1: the distinct TRUE folder prefixes of the catalogue — a
|
||
# folder is a slash-boundary prefix of at least one indexed path
|
||
# (the ``00_phase.md`` candidate definition: ``""`` + every distinct
|
||
# folder prefix, per source).
|
||
folders: set[tuple[str, str]] = set()
|
||
for row in rows:
|
||
source, path = row[0], row[1]
|
||
folder = folder_of(path)
|
||
while folder:
|
||
folders.add((source, folder))
|
||
folder = folder_of(folder)
|
||
|
||
# Pass 2: every row lands in the source-root group, in the group of
|
||
# every ancestor folder (the ``startswith folder + "/"`` arm of the
|
||
# count rule), and — when its path IS one of the source's folder
|
||
# prefixes (a file sharing its name with a directory) — in that
|
||
# folder's group too (the ``path == folder`` arm). A row therefore
|
||
# belongs to its folder's group iff its path equals the folder or
|
||
# starts with ``folder + "/"`` — exactly the set the ``ls`` count
|
||
# rule counts, for EVERY group.
|
||
groups: dict[tuple[str, str], list[DocRow]] = {}
|
||
for row in rows:
|
||
source, path = row[0], row[1]
|
||
groups.setdefault((source, ""), []).append(row)
|
||
if (source, path) in folders:
|
||
groups.setdefault((source, path), []).append(row)
|
||
folder = folder_of(path)
|
||
while folder:
|
||
groups.setdefault((source, folder), []).append(row)
|
||
folder = folder_of(folder)
|
||
return groups
|
||
|
||
|
||
def affected_folder_keys(
|
||
changed: Iterable[tuple[str, str]],
|
||
) -> set[tuple[str, str]]:
|
||
"""The candidate folder keys a changed-document set invalidates.
|
||
|
||
For each ``(source, path)``: the source-root key ``(source, "")``
|
||
plus every folder prefix of *path* — :func:`folder_of` walked from
|
||
the document's nearest folder up to ``""`` — the SAME prefix walk
|
||
:func:`group_by_folder` pass 1 uses (one concept): a doc under
|
||
``a/b/`` affects ``a/b``, ``a``, and the source root. A
|
||
root-level file (``folder_of(path) == ""``) affects ONLY the
|
||
source root. Embedded-image fragment paths (``x.pdf#page=…#img=…``,
|
||
phase 126) fold into their parent's folder chain with no special
|
||
case — ``folder_of`` never sees a ``/`` inside the fragment.
|
||
Returns a set; the generator sorts it (deterministic order).
|
||
"""
|
||
keys: set[tuple[str, str]] = set()
|
||
for source, path in changed:
|
||
keys.add((source, ""))
|
||
folder = folder_of(path)
|
||
while folder:
|
||
keys.add((source, folder))
|
||
folder = folder_of(folder)
|
||
return keys
|
||
|
||
|
||
def _first_summary_line(summary: str | None) -> str:
|
||
"""First line of a stored summary, stripped; ``''`` when absent.
|
||
|
||
The stored summary (phase 30) ends in the code-appended
|
||
``Source: …`` pointer line, so its first line is the model-written
|
||
lead sentence — the best one-line picture of the document for the
|
||
folder summary. Blank/whitespace-only summaries yield ``''`` as
|
||
well (the ``app.rag.overview`` helper, mirrored locally so each
|
||
lite-mode module stays self-contained).
|
||
"""
|
||
if not summary:
|
||
return ""
|
||
for line in summary.splitlines():
|
||
stripped = line.strip()
|
||
if stripped:
|
||
return stripped
|
||
return ""
|
||
|
||
|
||
def _folder_label(source: str, folder_path: str) -> str:
|
||
"""The user-message folder identifier (the ``FOLDER_HEADER_PREFIX`` tail).
|
||
|
||
``<source>`` for the source root (``folder_path = ""``),
|
||
``<source>/<folder_path>`` for a folder — the canonical folder
|
||
identity the E2E mock echoes into its canned reply.
|
||
"""
|
||
return source if not folder_path else f"{source}/{folder_path}"
|
||
|
||
|
||
def build_folder_summary_prompt(
|
||
source: str,
|
||
folder_path: str,
|
||
docs: Sequence[DocRow],
|
||
max_chars: int | None = None,
|
||
) -> tuple[str, str]:
|
||
"""The ``(system, user)`` message pair for one folder-summary call.
|
||
|
||
* ``system`` — :data:`SYSTEM_PROMPT`: the ``FOLDER_SUMMARY_MODE``
|
||
marker + the locked instruction.
|
||
* ``user`` — the folder header line
|
||
(``FOLDER_HEADER_PREFIX + <source>[/<folder_path>]`` — the line
|
||
the E2E mock parses to name the folder), then one line per
|
||
document, ``path — title — {first line of summary}``, joined
|
||
with newlines, in the given (catalogue) order. The summary field
|
||
is omitted when the document has none (markdown docs and the
|
||
fail-soft path — no dangling dash, the overview convention). The
|
||
whole message is capped at *max_chars* (default
|
||
``BOR_FOLDER_SUMMARY_INPUT_MAX_CHARS``): overflow is cut exactly
|
||
at the cap and the shared ``[…truncated…]`` marker is appended on
|
||
its own line, so the model never sees more than the cap and the
|
||
cut is visible (summarizer convention, phase 30).
|
||
"""
|
||
lines = [FOLDER_HEADER_PREFIX + _folder_label(source, folder_path)]
|
||
for _source, path, title, summary in docs:
|
||
line = f"{path} — {title}"
|
||
first = _first_summary_line(summary)
|
||
if first:
|
||
line += f" — {first}"
|
||
lines.append(line)
|
||
user = "\n".join(lines)
|
||
limit = (
|
||
max_chars
|
||
if max_chars is not None
|
||
else get_settings().folder_summary_input_max_chars
|
||
)
|
||
if len(user) > limit:
|
||
user = user[:limit] + "\n" + TRUNCATION_MARKER
|
||
return SYSTEM_PROMPT, user
|
||
|
||
|
||
async def summarize_folder(
|
||
source: str, folder_path: str, docs: Sequence[DocRow], llm: FolderSummaryLLM
|
||
) -> str:
|
||
"""One-shot ``lite`` summary of one folder's recursive subtree.
|
||
|
||
Builds the prompt from the folder's documents (each ``path``,
|
||
``title``, first summary line), makes ONE :meth:`LLMClient.chat`
|
||
call against ``llm.settings.llm_summary_model`` (the ``lite``
|
||
model — no new model management), and returns the model's text
|
||
trimmed. Raises :class:`LLMError` when the reply is empty after
|
||
trimming (the client already rejects empty content; re-asserted
|
||
defensively, the summarizer's rule — a silent summary must never be
|
||
stored), and propagates any :class:`LLMError` the client raises
|
||
(the generator's fail-soft path catches it per folder).
|
||
"""
|
||
system, user = build_folder_summary_prompt(source, folder_path, docs)
|
||
raw = await llm.chat(
|
||
[{"role": "system", "content": system}, {"role": "user", "content": user}],
|
||
model=llm.settings.llm_summary_model,
|
||
)
|
||
summary = raw.strip()
|
||
if not summary:
|
||
label = _folder_label(source, folder_path)
|
||
raise LLMError(
|
||
f"folder summary model returned empty content for {label} — "
|
||
"refusing to store a silent summary"
|
||
)
|
||
return summary
|
||
|
||
|
||
def _upsert(db: Session, source: str, folder_path: str, summary: str) -> None:
|
||
"""Insert or update the row for one folder (fresh UTC stamp)."""
|
||
now = datetime.now(UTC)
|
||
row = db.get(FolderSummary, (source, folder_path))
|
||
if row is None:
|
||
db.add(
|
||
FolderSummary(
|
||
source=source, folder_path=folder_path, summary=summary, updated_at=now
|
||
)
|
||
)
|
||
else:
|
||
row.summary = summary
|
||
row.updated_at = now
|
||
|
||
|
||
def _catalog_rows(db: Session) -> list[DocRow]:
|
||
"""The document catalogue in catalogue order (one query).
|
||
|
||
``(source, path, title, summary)`` ordered by ``(source, path)`` —
|
||
the EXACT query :func:`generate_folder_summaries` runs, so the gap
|
||
detector and the fill can never disagree about what the catalogue
|
||
contains (phase 96, task 02: one concept, as with
|
||
:func:`group_by_folder`).
|
||
"""
|
||
result = db.execute(
|
||
select(Document.source, Document.path, Document.title, Document.summary)
|
||
.order_by(Document.source, Document.path)
|
||
).all()
|
||
return [
|
||
(source, path, title, summary)
|
||
for source, path, title, summary in result
|
||
]
|
||
|
||
|
||
def _candidates(rows: Sequence[DocRow]) -> dict[tuple[str, str], list[DocRow]]:
|
||
"""The generator's candidate map (recursive subtree ≥
|
||
:data:`MIN_DOCS_PER_FOLDER` docs — at the current minimum of 1,
|
||
every :func:`group_by_folder` group).
|
||
|
||
The :func:`group_by_folder` groups filtered by
|
||
:data:`MIN_DOCS_PER_FOLDER` — the EXACT set a full regeneration
|
||
would cover, so a "missing" folder can never disagree with what a
|
||
regeneration would (re)generate.
|
||
"""
|
||
groups = group_by_folder(rows)
|
||
return {
|
||
key: docs for key, docs in groups.items() if len(docs) >= MIN_DOCS_PER_FOLDER
|
||
}
|
||
|
||
|
||
def _stored_keys(db: Session) -> set[tuple[str, str]]:
|
||
"""The ``(source, folder_path)`` keys that already hold a row.
|
||
|
||
One bounded key select — no count scan (the phase-94
|
||
table-emptiness probe's house pattern).
|
||
"""
|
||
return {
|
||
(source, folder_path)
|
||
for source, folder_path in db.execute(
|
||
select(FolderSummary.source, FolderSummary.folder_path)
|
||
).all()
|
||
}
|
||
|
||
|
||
def missing_folder_summaries(db: Session) -> list[tuple[str, str]]:
|
||
"""The candidate folders whose stored row is ABSENT (phase 96, 02).
|
||
|
||
The unchanged-sync self-heal gate: a one-shot reply can still
|
||
exhaust its retries and leave a candidate folder without a row, and
|
||
the phase-94 change gate only regenerated on a KB change — so the
|
||
gap persisted until the next KB change. This function names the gap:
|
||
the generator's candidate folders (the EXACT candidate computation
|
||
— one catalogue query in catalogue order, :func:`group_by_folder`,
|
||
recursive subtree ≥ :data:`MIN_DOCS_PER_FOLDER`) minus the stored
|
||
keys (one bounded select). The gate probe and the
|
||
:func:`generate_folder_summaries` fill both key off this one
|
||
concept.
|
||
|
||
Returns the missing keys sorted by ``(source, folder_path)``;
|
||
``[]`` when there is no gap — including an empty catalogue over an
|
||
empty table (no candidates, no gap). A stored row for a folder
|
||
that dropped below the minimum (vanished — 0 documents) is NOT
|
||
missing (it is stale — the prune pass owns it); a single-document
|
||
folder without a row IS listed (it is a candidate at the ≥ 1
|
||
rule).
|
||
"""
|
||
return sorted(
|
||
key for key in _candidates(_catalog_rows(db)) if key not in _stored_keys(db)
|
||
)
|
||
|
||
|
||
async def generate_folder_summaries(
|
||
db: Session,
|
||
llm: FolderSummaryLLM,
|
||
*,
|
||
skip: bool = False,
|
||
only_missing: bool = False,
|
||
changed_docs: Sequence[tuple[str, str]] | None = None,
|
||
on_progress: Callable[[int, int, str, str], None] | None = None,
|
||
) -> dict[str, int]:
|
||
"""Regenerate the stored folder summaries for the current catalogue.
|
||
|
||
The sync-path orchestrator (phase 94, task 02 calls it
|
||
change-gated, like the KB overview; ``--limit`` debug runs pass
|
||
``skip=True``).
|
||
|
||
Modes (phase 127, task 02 — the *changed_docs* kwarg):
|
||
``changed_docs=None`` (the DEFAULT) is the FULL regeneration —
|
||
every candidate folder, byte-identical to the pre-127 behavior;
|
||
the ``scripts/import_docs.py`` CLI stays on it. A GIVEN change set
|
||
(the sync path passes ``ImportSummary.changed_paths``) restricts
|
||
the generation to the CANDIDATE folders the changed documents
|
||
touch — :func:`affected_folder_keys`: a document's change
|
||
invalidates exactly its folder chain + source root, the folders
|
||
whose subtree summary text CAN change; every other stored row
|
||
keeps its summary text AND ``updated_at`` (an unaffected folder's
|
||
documents — and hence its summary — are untouched). An affected
|
||
folder with no stored row (a gap in the chain) is a candidate and
|
||
is generated; an affected folder that dropped below the minimum
|
||
is the prune pass's job (step 4), not a generation's. The two
|
||
restriction kwargs are ORTHOGONAL and no caller combines them:
|
||
``only_missing=True`` (the unchanged-sync gap fill, phase 96)
|
||
keeps its exact pre-127 semantics.
|
||
|
||
Steps, in order:
|
||
|
||
1. ``skip=True`` → a no-op: the zero stats dict is returned, the
|
||
LLM is never called, and no rows are touched.
|
||
2. Group the document catalogue by :func:`group_by_folder` (the
|
||
recursive-subtree concept) and keep the candidate folders —
|
||
the ones whose recursive subtree holds
|
||
:data:`MIN_DOCS_PER_FOLDER` (1) documents: at the current
|
||
minimum every existing folder (single-file ones included, and
|
||
the single-file source root) is a candidate.
|
||
3. For each candidate (deterministic ``(source, folder_path)``
|
||
order) call :func:`summarize_folder` and UPSERT — fail-soft PER
|
||
FOLDER: one folder's :class:`LLMError` is logged and counted,
|
||
its previous row (if any) is kept, and the remaining folders
|
||
still land (a ``lite`` outage must never fail the sync — the KB
|
||
is the product, the summaries are auxiliary). An EXISTING row
|
||
with ``manually_edited`` is SKIPPED instead of regenerated —
|
||
no ``lite`` call for owner text (no burn on the owner's words),
|
||
the row stays byte-identical (text AND ``updated_at``), and the
|
||
skip counts ``kept_manual`` (phase 97, task 01: an owner
|
||
correction is never silently rewritten). With
|
||
``only_missing=True`` the iteration is restricted to the
|
||
candidates that have NO stored row (the same gap
|
||
:func:`missing_folder_summaries` reports, derived from the SAME
|
||
stored-rows pass — one select, no second catalogue query):
|
||
existing rows stay byte-identical (summary text AND
|
||
``updated_at`` — never re-stamped, even a stale-looking one;
|
||
staleness is the changed-KB regeneration's job) and no ``lite``
|
||
call is burned for a folder that already has a summary — the
|
||
unchanged-sync self-heal fill (phase 96, task 02).
|
||
4. DELETE rows whose folder no longer has ≥ 1 document (vanished
|
||
from the catalogue) — a pruned/renamed folder's summary goes
|
||
stale and is dropped —
|
||
EXCEPT a manual row: owner content persists until the owner
|
||
clears it, even for a folder that dropped below the minimum
|
||
(phase 97, task 01; the clear deletes the row, so the next
|
||
KB-changing sync regenerates an AI description — the reset
|
||
path). Rows for folders that still qualify persist (regenerated
|
||
in step 3 — an unchanged folder's summary is still true). The
|
||
prune pass runs in BOTH modes: under ``only_missing`` on an
|
||
unchanged catalogue it is a no-op (the invariant kept), and it
|
||
still drops rows whose folder fell below the minimum.
|
||
|
||
Progress (phase 98, task 01): when *on_progress* is given, it is
|
||
called once per candidate with ``(done, total, source,
|
||
folder_path)`` in the same sorted ``(source, folder_path)`` order,
|
||
BEFORE the attempt — so an instant manual skip and a failed folder
|
||
BOTH advance the counter (the UI's position moves on either), and
|
||
``total`` is ``len(keys)`` at loop start (under ``only_missing
|
||
=True`` that is the MISSING count, not the full candidate count;
|
||
under a ``changed_docs`` restriction that is the AFFECTED count —
|
||
the sync status's ``summaries_total`` reports it through this
|
||
existing hook). ``None`` — the ``scripts/import_docs.py`` CLI
|
||
path — is a zero-cost no-op (guarded at the call site; nothing
|
||
observable changes without it).
|
||
|
||
Only flushes — the CALLER commits (the phase-53
|
||
``bump_sources_version`` convention: the sync path owns the
|
||
transaction, so a failed sync rolls the summaries back with it).
|
||
|
||
Returns the small stats dict ``{"generated", "failed", "pruned",
|
||
"kept_manual"}`` for the caller's summary-line logging (PLAN §9
|
||
ample logging) — the caller logs the mode, not the generator. The
|
||
``import_docs`` summary-line token stays its 3 fields
|
||
(``<generated>/<failed>/<pruned>`` — ``kept_manual`` is a stat, not
|
||
a token; phase 97, task 01).
|
||
"""
|
||
stats = {"generated": 0, "failed": 0, "pruned": 0, "kept_manual": 0}
|
||
if skip:
|
||
return stats
|
||
|
||
candidates = _candidates(_catalog_rows(db))
|
||
# The existing rows, fetched ONCE (phase 97, task 01): both the
|
||
# ``only_missing`` filter and the prune pass key off this single
|
||
# {(source, folder_path): row} dict — one fetch, one concept.
|
||
existing = {
|
||
(row.source, row.folder_path): row
|
||
for row in db.execute(select(FolderSummary)).scalars()
|
||
}
|
||
keys = sorted(candidates)
|
||
if changed_docs is not None:
|
||
# Phase 127 (task 02): restricted regeneration — exactly the
|
||
# CANDIDATE folders the change set touches (each document's
|
||
# folder chain + source root). Unaffected candidates keep
|
||
# their rows byte-identical (text AND ``updated_at``); a gap
|
||
# in an affected folder is a candidate and gets generated;
|
||
# an affected folder that lost its last document is no
|
||
# candidate — the prune pass below drops its row.
|
||
keys = sorted(set(candidates) & affected_folder_keys(changed_docs))
|
||
if only_missing:
|
||
keys = [key for key in keys if key not in existing]
|
||
|
||
for i, key in enumerate(keys):
|
||
source, folder_path = key
|
||
# Phase 98 (task 01): the optional progress hook fires BEFORE
|
||
# the attempt — instant manual skips and failed folders both
|
||
# advance the counter (D5), and ``total`` is the loop-start
|
||
# count (the missing count under ``only_missing``). ``None``
|
||
# (the CLI path) is a zero-cost no-op.
|
||
if on_progress is not None:
|
||
on_progress(i + 1, len(keys), source, folder_path)
|
||
stored = existing.get(key)
|
||
if stored is not None and stored.manually_edited:
|
||
# Owner-edited description (phase 97, task 01): NEVER
|
||
# overwrite it — no lite burn on owner text.
|
||
stats["kept_manual"] += 1
|
||
continue
|
||
docs = candidates[key]
|
||
try:
|
||
summary = await summarize_folder(source, folder_path, docs, llm)
|
||
except LLMError as e:
|
||
stats["failed"] += 1
|
||
logger.error(
|
||
"folder summary failed for %s/%s — %s", source, folder_path, e
|
||
)
|
||
continue
|
||
_upsert(db, source, folder_path, summary)
|
||
stats["generated"] += 1
|
||
|
||
for key, row in existing.items():
|
||
if key not in candidates and not row.manually_edited:
|
||
db.delete(row)
|
||
stats["pruned"] += 1
|
||
|
||
db.flush()
|
||
logger.info(
|
||
"folder_summaries: generated=%d failed=%d pruned=%d kept_manual=%d",
|
||
stats["generated"],
|
||
stats["failed"],
|
||
stats["pruned"],
|
||
stats["kept_manual"],
|
||
)
|
||
return stats
|