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/`.
320 lines
15 KiB
Python
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()
|