Files
brain-of-reese/app/rag/overview.py
T
ducoterra 4f20c256d7
Build and Push Containers / build-and-push-app (push) Successful in 7m24s
Build and Push Containers / build-and-push-db (push) Successful in 36s
phase: 191_source_name_labels_tags
All gates re-verified against a stable tree. Report:

**Verified (all 6 tasks in `complete/`; this was the final pass)**
- `SourceLabel`+alembic `0030` (dev DB `alembic current` = `0030 (head)`, table present, 0 rows); admin-only `GET/PUT/DELETE /api/source-labels` (`require_admin` router-wide, `resolve_source_name`, partial-update caps/404s); Sources-page "Names, labels and tags" card (read-only derived-name `<code>`, per-row `saveInFlight`, 404 note that survives the settle, chips reuse `.git-source-hidden-count`); `question_name_hits` D2 rule; ONE `all_labels` read per turn in `chat.py` feeding both the `source_name_hit` flag and the prompt surfaces; `source_name_hit_bonus=0.005` in `_selection_order`; ` name=`/` label=` in `build_high_prompt` + `build_overview_prompt(names=…)`.
- **Completion criteria:** E2E `tests/e2e/test_source_name_labels.py` ✅ 7 passed (arms a–e: editor all kinds, control/named question, rename-never-breaks-identity, 404 note, reset) · full suite ✅ · coverage ✅ · README/AGENTS ✅ · phase dir in `complete/` ✅ · commit left to the harness.
- **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 7160 stmts / 16 missed = 99%** (>90%) · `uv run pytest tests/e2e/test_source_name_labels.py -v --no-cov` → **7 passed, exit 0** · regression E2E isolated, exit 0: `test_document_viewer`, `test_admin_auth`, `test_api_tokens`, `test_git_sources_admin`, `test_kb_tree`, `test_local_directory_sources` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors.
- **Battery (task 04 criterion):** `uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` at default vs `BOR_SOURCE_NAME_HIT_BONUS=0` → **`diff` empty (line-for-line identical)**, matching the recorded arms (dev KB is empty, so every question deflects; the observable boost is unit/E2E-pinned).
- **Documentation:** README — fixed the `/git-sources.html` rows (nav label is **Sources**) and cross-linked the phase-191 card from that section; full audit otherwise clean: all 86 `BOR_*` settings documented with defaults matching `app/config.py`, 0 broken file/dir references and 0 broken anchors in README+AGENTS, AGENTS rule 17 re-checked line-by-line against the code.
- **Notable / deviations:** (1) a **concurrent pipeline process** (`phased-execution/progress.mjs` for `191_…_00_phase`) was mutating this same working tree mid-pass — it rewrote `tests/e2e/test_source_name_labels.py` (11→7 tests), `frontend/assets/git-sources.js`, `tests/unit/test_source_labels_editor_ui.py` and `AGENTS.md` at 05:27; every gate above was (re-)run after those edits with the tree no source file newer than the green full run. (2) The task-06 report/phase text claims regression suites `test_chat_token_gate.py`, `test_git_sources_page.py`, `test_rag_browser.py` are green — **those files have never existed in git history** (those runs exit 4); I ran the real equivalents listed above instead (phase files are not mine to edit). (3) **No live changes**: the VM DB already has `source_labels` (0 rows) but the deployed app predates this uncommitted code, so deploying `/api/source-labels` to the live service is the owner's post-commit step, not part of this phase's criteria.
- **Next pending phase:** none — `191_source_name_labels_tags` is the only entry in `.agents/phases/todo/`.
2026-10-19 06:13:32 -04:00

320 lines
15 KiB
Python

"""KB overview generator (phase 31, task 02).
Builds the ``KB_OVERVIEW_MODE`` prompt from the document catalogue
(one line per document: source, path, title, first summary line —
ordered RECENT-FIRST by the document's date, phase 142 task 02),
calls the aipi ``lite`` model through the one-shot ``LLMClient.chat``
(phase 30, same model as document summaries — no new model management),
and stores the plain-text outline in the single ``kb_overview`` row
(migration 0005).
The recency-first order is what makes the input cap honest: when
``BOR_OVERVIEW_INPUT_MAX_CHARS`` cuts the list, the documents that fall
off are the OLDEST, so the outline always represents the recent state
of the KB. The sort reads ``Document.created_at`` — phase 106's
git-history/mtime-derived, owner-correctable document date (the same
date the ``read`` tool's ``date:`` line shows); ``created_at_manual``
rows are included exactly as stored, the ordering never re-derives
dates. Same-date documents tie-break by ``(source, path)``, keeping
the pre-phase-142 lexicographic order within a date (deterministic for
the mock's digest; byte-identical for single-date KBs).
Chat turns never generate an overview — they read the stored row
(:func:`load_kb_overview`, one indexed PK lookup) and inject it into the
system prompt of every turn as the ``<knowledge_base>`` section
(phase 31, task 03). Generation happens at import time (phase 31, task
04) and is **best-effort and change-gated**: callers invoke it only when
an import added/updated at least one document (or no row exists yet),
and a ``lite`` failure logs and leaves the previous outline intact — an
old outline is better than none (phase locked decisions).
After the model's outline, :func:`regenerate_overview` appends ONE
deterministic, non-LLM line — ``Sources: <name> (<k> doc|docs), …``
(phase 162, LOCKED A2). The 2026-10-07 live finding: the ``lite``
outline is lossy — it listed 3 groups of a 14-source index, so the
1-doc sources (``ikea_instructions``, ``players_handbook``) were
invisible to ``<knowledge_base>``. Small sources must be visible
without a model call, so the line is computed mechanically from the
catalogue rows (:func:`_source_inventory`) — the mock/real model
outputs never affect it, and ``build_overview_prompt`` never sees the
tail (the model input stays byte-identical). The longest-prefix cap in
``build_kb_section`` applies to the WHOLE stored text: at the default
``BOR_KB_OVERVIEW_MAX_CHARS`` (4000) the ~1500-char outline plus the
one-line tail (~15 chars/source) always fit; a smaller owner-set cap
cuts the tail first — accepted, the cap is owner-tunable.
Source names (phase 191, D4): when a source has a label row with a
``display_name``, the INPUT line of its documents leads with that name
instead of the derived source name (``build_overview_prompt``'s *names*
map, derived from the ONE ``all_labels`` read :func:`regenerate_overview`
performs) — the owner's vocabulary reaches the outline. The stored text,
the catalogue rows and the deterministic ``Sources: …`` inventory tail
keep the DERIVED name (D1 — a label is an alias, never an identity).
The ``KB_OVERVIEW_MODE`` marker follows the ``SUMMARY_MODE`` /
``DEFLECT_MODE`` convention: the deterministic E2E mock LLM keys on it
in the system prompt (wired in phase 31, task 05).
"""
from __future__ import annotations
import logging
from collections.abc import Mapping, 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.db import SessionLocal
from app.models import Document, KbOverview
from app.rag.llm import LLMError
from app.rag.retriever import TRUNCATION_MARKER
from app.rag.source_labels import all_labels
logger = logging.getLogger("app.rag.overview")
#: System-prompt marker for KB overview generation — the E2E mock LLM keys
#: on it (same convention as ``SUMMARY_MODE`` / ``DEFLECT_MODE``).
KB_OVERVIEW_MODE = "KB_OVERVIEW_MODE"
#: Locked instruction for the ``lite`` model (phase 31): the overview is
#: the agent's *a priori* picture of the knowledge base, so it must be a
#: compact plain-text outline of basic categories and topics — strictly
#: grounded in the document list, nothing invented.
OVERVIEW_INSTRUCTION = (
"From the document list below, write a compact plain-text outline of "
"the basic categories and topics this knowledge base covers. Group by "
"source where useful, use `-` bullet lines, at most ~1500 characters, "
"no markdown headings, and no topics not present in the list."
)
#: Full system prompt: marker first (the mock's key), then the instruction.
SYSTEM_PROMPT = f"{KB_OVERVIEW_MODE}: {OVERVIEW_INSTRUCTION}"
class OverviewLLM(Protocol):
"""The one-shot chat surface the overview generator needs.
:class:`app.rag.llm.LLMClient` satisfies it; unit tests pass a
duck-typed fake (``chat`` + ``settings``) instead — same pattern as
the summarizer's ``SummaryLLM`` protocol.
"""
settings: Settings
async def chat(
self, messages: list[dict[str, str]], model: str | None = None
) -> str: ...
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
outline. Blank/whitespace-only summaries yield ``''`` as well.
"""
if not summary:
return ""
for line in summary.splitlines():
stripped = line.strip()
if stripped:
return stripped
return ""
def _source_inventory(rows: Sequence[tuple[str, str, str, str | None]]) -> str:
"""The phase-162 deterministic source inventory (LOCKED A2).
Mechanical, non-LLM: one entry per **distinct** source in catalog
order (``source, path`` — first occurrence in the ``source,
path``-ordered rows), each ``<name> (<k> doc|docs)`` — singular
``doc`` at k=1, ``docs`` otherwise — joined with ``, `` behind the
``Sources: `` prefix. The ``lite`` outline stays the model's job;
this line guarantees no source — not even a 1-doc one like the
2026-10-07 ``ikea_instructions`` case — can be invisible to
``<knowledge_base>``, whatever the model outputs. The mock/real
model outputs never affect it.
*rows* is the existing ``(source, path, title, summary)``
projection :func:`regenerate_overview` already reads — no new
query. At the default ``BOR_KB_OVERVIEW_MAX_CHARS`` (4000) the
outline plus this one line always fit; a smaller owner-set cap
cuts the tail first (accepted — the cap is owner-tunable).
"""
counts: dict[str, int] = {}
for source, _path, _title, _summary in rows:
counts[source] = counts.get(source, 0) + 1
entries = ", ".join(
f"{name} ({count} {'doc' if count == 1 else 'docs'})"
for name, count in sorted(counts.items())
)
return f"Sources: {entries}"
def build_overview_prompt(
rows: Sequence[tuple[str, str, str, str | None]],
max_chars: int | None = None,
names: Mapping[str, str] | None = None,
) -> tuple[str, str]:
"""The ``(system, user)`` message pair for one overview call.
Each row is ``(source, path, title, summary)``.
* ``system`` — :data:`SYSTEM_PROMPT`: the ``KB_OVERVIEW_MODE`` marker
+ the locked instruction.
* ``user`` — one line per document,
``source — path — title — {first line of summary}``, joined with
newlines, in the EXACT order the caller passes *rows* (order-
preserving; the caller — :func:`regenerate_overview` — passes the
catalogue recent-first, phase 142 task 02). The summary field is
omitted when the document has no summary (pre-phase-30 rows, the
fail-soft path, and pre-backfill NULL rows — no dangling dash).
The list is capped at *max_chars* (default
``BOR_OVERVIEW_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).
*names* (phase 191, D4) maps a DERIVED source name to the owner's
display name for it: the input line leads with the display name
instead of the raw source name, so the ``lite`` outline is written in
the owner's vocabulary ("the git server" rather than
``gitea-infra-docs``). The alias contract (D1) is untouched — the
``path`` / ``title`` / summary halves of the line, the stored overview
row and its ``Sources: …`` inventory tail all keep the derived name;
only this model input changes. ``None`` / an empty map / no entry for
a source (or an entry with an empty name) → the pre-phase line,
byte-identical.
"""
limit = max_chars if max_chars is not None else get_settings().overview_input_max_chars
lines: list[str] = []
for source, path, title, summary in rows:
# Phase 191 (D4): the display name REPLACES the raw source name in
# the lite model's input only — never in the stored outline.
display = names.get(source) if names else None
line = f"{display or source} — {path} — {title}"
first = _first_summary_line(summary)
if first:
line += f" — {first}"
lines.append(line)
user = "\n".join(lines)
if len(user) > limit:
user = user[:limit] + "\n" + TRUNCATION_MARKER
return SYSTEM_PROMPT, user
def load_kb_overview(db: Session) -> str:
"""The stored KB outline for prompt injection (phase 31, task 03).
One indexed PK lookup (the single row, ``id = 1``) returning
``content`` trimmed. Returns ``""`` when the row is missing or
empty — the chat path then omits the ``<knowledge_base>`` section
entirely, keeping the prompt byte-identical to the pre-phase text
(phase 15 convention).
"""
row = db.get(KbOverview, 1)
if row is None:
return ""
return (row.content or "").strip()
async def regenerate_overview(
llm: OverviewLLM, session: Session | None = None
) -> bool:
"""Generate a fresh KB outline with the ``lite`` model and upsert it.
Best-effort and change-gated by the caller (phase locked decisions):
the import script (phase 31, task 04) and the admin sync (phase 32)
invoke this only when an import added/updated at least one document
(or no row exists yet). *session* may be supplied (the import
script's own session, tests); a private one is opened and closed
otherwise — same convention as ``import_sources``.
* Zero documents → the existing row (if any) is left untouched and
``False`` is returned — no KB, no outline, no wasted model call.
* Happy path → the model's text (trailing newlines rstripped) plus
the deterministic ``Sources: …`` inventory line (phase 162,
LOCKED A2 — :func:`_source_inventory`) is upserted into the
single row (``id = 1``, fresh UTC ``updated_at``), committed, and
``overview: regenerated docs=… chars=…`` is logged (chars = the
FULL stored content, tail included); returns ``True``. Idempotent
by construction: one row, always ``id = 1``. The model input
never sees the tail; the zero-doc and ``LLMError`` paths below
leave a previous row byte-identical — an existing tail is part of
the stored content and is preserved on failure.
* Phase 191 (D4): the ONE :func:`app.rag.source_labels.all_labels`
read happens AFTER the zero-doc gate (no label query when there is
nothing to outline) and feeds only the model input — the stored
content and its inventory tail stay derived-name text.
* :class:`LLMError` → ``overview: regeneration failed — …`` is
logged and ``False`` is returned with the previous row intact —
an old outline is better than none.
"""
owns_session = session is None
if session is None:
session = SessionLocal()
try:
# RECENT-FIRST (phase 142, task 02): the document's date
# (``created_at``, phase 106) descending, so when the input cap
# bites the OLDEST documents fall off — the outline always
# represents the recent KB. The (source, path) tie-break keeps
# same-date documents in the pre-phase-142 lexicographic order
# (deterministic digest; byte-identical for single-date KBs).
result = session.execute(
select(Document.source, Document.path, Document.title, Document.summary)
.order_by(Document.created_at.desc(), Document.source, Document.path)
).all()
rows: list[tuple[str, str, str, str | None]] = [
(source, path, title, summary)
for source, path, title, summary in result
]
if not rows:
logger.info(
"overview: no documents in the knowledge base — "
"leaving any existing outline untouched"
)
return False
# Phase 191 (D4, task 05): ONE all_labels read for the whole run —
# the display-name map for the model input is derived from these
# views in this loop, never re-queried (the same single-read
# contract the chat turn holds, task 04/05). Rows without a
# display name are absent from the map, so their lines stay
# byte-identical to pre-phase.
labels = all_labels(session)
name_map = {
src: view.display_name for src, view in labels.items() if view.display_name
}
system, user = build_overview_prompt(rows, names=name_map)
try:
text = await llm.chat(
[{"role": "system", "content": system}, {"role": "user", "content": user}],
model=llm.settings.llm_summary_model,
)
except LLMError as e:
logger.error("overview: regeneration failed — %s", e)
return False
# Phase 162 (LOCKED A2): the deterministic source inventory is
# appended to the STORED content after the model's outline —
# mechanical, non-LLM, so no source can be invisible to
# `<knowledge_base>`. The model input above never sees the tail.
content = text.rstrip("\n") + "\n" + _source_inventory(rows)
now = datetime.now(UTC)
row = session.get(KbOverview, 1)
if row is None:
session.add(KbOverview(id=1, content=content, updated_at=now))
else:
row.content = content
row.updated_at = now
session.commit()
logger.info("overview: regenerated docs=%d chars=%d", len(rows), len(content))
return True
finally:
if owns_session:
session.close()