All verification complete. Final report: **Phase 98 — Sync summary visibility: final verification pass** (all 5 tasks already complete; implementation verified against the design, no defects found, no code changes needed) - **Implementation checked:** `SyncStatus` phase machine (4 new keys, terminal-keep counts), `on_progress` hook in `generate_folder_summaries`, `summary_pending` on `KbTreeSource`/`KbTreeFolder` + D3 rule in `build_kb_tree`, phase-aware sync labels + pending UI in `sources.js`, `.kb-summary-pending` CSS — all match decisions D1–D5. - **Unit + integration:** `uv run pytest` → 2184 tests, 0 failed/errors (exit 0) - **Coverage:** `uv run pytest --cov=app --cov-report=term-missing` → **99%** on `app/` (criterion >90% ✓; `app/api/sync.py` and `app/rag/folder_summaries.py` at 100%) - **Lint/types:** `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings - **Phase E2E (isolation):** `uv run pytest tests/e2e/test_sync_summary_visibility.py -v --no-cov` → **3 passed** (phase machine, live label, pending markers + gap-fill self-heal) - **Regression suites (each isolated, `--no-cov`):** test_kb_tree ✓, test_ls_tree_drilldown 3 ✓, test_sync_button 3 ✓, test_sync_upload_progress 4 ✓, test_oneshot_llm_retry 2 ✓, test_local_directory_sources 3 ✓ - **Completion criteria:** all 7 verified green — status phase fields + terminal semantics; `Writing KB overview…`/`Summarizing folders… (n/m)` labels (title + aria-live); pending set == `missing_folder_summaries` (integration cross-check pinned at `test_docs_api.py:428`); CLI/`ls` byte-identity (no changes to those paths, pins green); suite/coverage/lint gates; dedicated + regression E2E. Commit left to the harness per protocol (no `git add`/`commit` run). - **Decisions/deviations:** none — no fixes were required this pass. - **Next pending phase:** `99_kb_tree_table_and_back_nav`.
524 lines
23 KiB
Python
524 lines
23 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 only for folders with
|
||
≥ 2 documents (the :data:`MIN_DOCS_PER_FOLDER` rule — a
|
||
single-document folder is fully described by its one file line, so no
|
||
``lite`` burn); after a changed sync, rows whose folder dropped below
|
||
2 documents are pruned (a pruned/renamed folder's summary would
|
||
otherwise go stale), while rows for folders that still have
|
||
≥ 2 documents 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.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from collections.abc import Callable, 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 — a single-document folder is fully
|
||
#: described by its one file line, so no ``lite`` burn (phase 94
|
||
#: ``00_phase.md`` scope rule; the prune rule applies the same count).
|
||
MIN_DOCS_PER_FOLDER = 2
|
||
|
||
#: 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;
|
||
the ≥ 2 :data:`MIN_DOCS_PER_FOLDER` rule is applied by
|
||
:func:`generate_folder_summaries`, not here.
|
||
|
||
"""
|
||
# 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 _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 ≥ 2 docs).
|
||
|
||
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 single-document folder is
|
||
never listed (it is not a candidate), and a stored row for a folder
|
||
that dropped below the minimum is NOT missing (it is stale — the
|
||
prune pass owns it).
|
||
"""
|
||
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,
|
||
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``). 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` (≥ 2) documents. Single-document
|
||
folders get no row (their one file line IS their summary).
|
||
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 ≥ 2 documents — 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).
|
||
``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 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
|