Files
brain-of-reese/app/rag/folder_summaries.py
T
ducoterra 6e654df387
Build and Push Containers / build-and-push-app (push) Successful in 2m25s
Build and Push Containers / build-and-push-db (push) Successful in 15s
phase: 127_sync_incremental_folder_summaries
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`
2026-09-27 21:12:25 -04:00

606 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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