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/`.
870 lines
33 KiB
Python
870 lines
33 KiB
Python
"""Unit: KB overview generator (phase 31, task 02).
|
||
|
||
The prompt tests are pure (no DB): ``KB_OVERVIEW_MODE`` system prompt,
|
||
per-document user lines (source/path/title/first summary line), and the
|
||
input cap with the shared ``[…truncated…]`` marker. The loader and
|
||
regenerator tests run against the local compose Postgres (preferred —
|
||
real single-row upsert), skipping with clear instructions when the stack
|
||
is not up — same pattern as ``test_importer.py``. The phase-162 section
|
||
pins the deterministic ``Sources: …`` inventory tail (LOCKED A2): the
|
||
format/order/counts, the 1-doc survival, the happy-path stored content,
|
||
the byte-identical zero-doc / ``LLMError`` paths, and the byte-identical
|
||
model input.
|
||
|
||
And the phase-191 display names (task 05, D4): the INPUT lines lead with
|
||
the owner's ``display_name`` when the source has a label row (the raw
|
||
source name is replaced in the ``lite`` model's input ONLY — the stored
|
||
row and its ``Sources: …`` tail keep the derived name), byte-identical
|
||
for every unnamed source, and fed by exactly ONE ``all_labels`` read per
|
||
run (the marker pins at the end).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import inspect
|
||
import logging
|
||
import uuid
|
||
from datetime import UTC, datetime, timedelta
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from sqlalchemy import select, text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.config import Settings, get_settings
|
||
from app.models import Document, KbOverview
|
||
from app.rag import overview as overview_module
|
||
from app.rag.llm import LLMError
|
||
from app.rag.overview import (
|
||
KB_OVERVIEW_MODE,
|
||
OVERVIEW_INSTRUCTION,
|
||
SYSTEM_PROMPT,
|
||
_source_inventory,
|
||
build_overview_prompt,
|
||
load_kb_overview,
|
||
regenerate_overview,
|
||
)
|
||
from app.rag.retriever import TRUNCATION_MARKER
|
||
from app.rag.source_labels import all_labels as real_all_labels
|
||
from app.rag.source_labels import set_label
|
||
|
||
REPLY = "- Homelab\n - Kubernetes (k3s)\n- Deployments\n - Borg backups"
|
||
|
||
|
||
class _FakeLLM:
|
||
"""Duck-typed stand-in for ``LLMClient`` (``chat`` + ``settings``).
|
||
|
||
Records the messages and the ``model`` kwarg it was called with;
|
||
returns a canned reply or raises (e.g. :class:`LLMError`).
|
||
"""
|
||
|
||
def __init__(self, reply: str = REPLY, fail: Exception | None = None) -> None:
|
||
self._reply = reply
|
||
self._fail = fail
|
||
self.settings = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
|
||
self.messages: list[dict[str, str]] = []
|
||
self.model: str | None = None
|
||
self.calls = 0
|
||
|
||
async def chat(
|
||
self, messages: list[dict[str, str]], model: str | None = None
|
||
) -> str:
|
||
self.calls += 1
|
||
self.messages = list(messages)
|
||
self.model = model
|
||
if self._fail is not None:
|
||
raise self._fail
|
||
return self._reply
|
||
|
||
|
||
# ---------- build_overview_prompt: system ----------
|
||
|
||
|
||
def test_system_prompt_has_marker_and_locked_instruction() -> None:
|
||
assert SYSTEM_PROMPT.startswith(KB_OVERVIEW_MODE)
|
||
assert OVERVIEW_INSTRUCTION in SYSTEM_PROMPT
|
||
for fragment in (
|
||
"compact plain-text outline",
|
||
"basic categories and topics",
|
||
"Group by source",
|
||
"use `-` bullet lines",
|
||
"~1500 characters",
|
||
"no markdown headings",
|
||
"no topics not present in the list",
|
||
):
|
||
assert fragment in SYSTEM_PROMPT
|
||
system, _ = build_overview_prompt([])
|
||
assert system == SYSTEM_PROMPT
|
||
assert KB_OVERVIEW_MODE in system # the marker the E2E mock keys on
|
||
|
||
|
||
# ---------- build_overview_prompt: user lines ----------
|
||
|
||
|
||
def test_user_lines_carry_source_path_title_and_first_summary_line() -> None:
|
||
rows = [
|
||
("Homelab", "kubernetes/k3s.md", "K3s Cluster", None),
|
||
(
|
||
"Deployments",
|
||
"backups/borg.yaml",
|
||
"Borg Backups",
|
||
"Backups run nightly via the borg schedule.\nSource: Deployments/backups/borg.yaml",
|
||
),
|
||
]
|
||
system, user = build_overview_prompt(rows)
|
||
assert system == SYSTEM_PROMPT
|
||
assert user == (
|
||
"Homelab — kubernetes/k3s.md — K3s Cluster\n"
|
||
"Deployments — backups/borg.yaml — Borg Backups — "
|
||
"Backups run nightly via the borg schedule."
|
||
)
|
||
|
||
|
||
def test_user_line_omits_summary_field_when_absent() -> None:
|
||
_, user = build_overview_prompt([("Homelab", "a.md", "Title A", None)])
|
||
assert user == "Homelab — a.md — Title A"
|
||
assert not user.endswith(" — ") # no dangling dash
|
||
|
||
|
||
def test_user_line_omits_summary_field_when_blank() -> None:
|
||
_, user = build_overview_prompt([("Homelab", "a.md", "Title A", " \n\t ")])
|
||
assert user == "Homelab — a.md — Title A"
|
||
|
||
|
||
def test_user_line_uses_only_first_summary_line() -> None:
|
||
"""Multi-line summaries contribute their first line only (the
|
||
model-written lead sentence; the ``Source: …`` pointer is last)."""
|
||
_, user = build_overview_prompt(
|
||
[
|
||
(
|
||
"Homelab",
|
||
"a.yaml",
|
||
"Title A",
|
||
"First line.\nSecond line.\nSource: Homelab/a.yaml",
|
||
)
|
||
]
|
||
)
|
||
assert user == "Homelab — a.yaml — Title A — First line."
|
||
assert "Second line" not in user
|
||
assert "Source:" not in user
|
||
|
||
|
||
def test_zero_rows_yield_empty_user() -> None:
|
||
_, user = build_overview_prompt([])
|
||
assert user == ""
|
||
|
||
|
||
# ---------- build_overview_prompt: input cap ----------
|
||
|
||
|
||
def test_user_prompt_truncated_with_marker_when_over_custom_cap() -> None:
|
||
rows = [("S", f"p{i}.md", f"T{i}", None) for i in range(10)]
|
||
_, full = build_overview_prompt(rows, max_chars=10_000)
|
||
cap = 25
|
||
_, user = build_overview_prompt(rows, max_chars=cap)
|
||
assert user == full[:cap] + "\n" + TRUNCATION_MARKER
|
||
assert user.endswith(TRUNCATION_MARKER)
|
||
assert len(user) > cap # the marker makes the cut visible past the cap
|
||
|
||
|
||
def test_user_prompt_at_exact_cap_not_truncated() -> None:
|
||
rows = [("S", "p.md", "T", None)] # "S — p.md — T" = 12 chars
|
||
_, user = build_overview_prompt(rows, max_chars=12)
|
||
assert user == "S — p.md — T"
|
||
assert TRUNCATION_MARKER not in user
|
||
|
||
|
||
def test_user_prompt_truncated_at_default_cap() -> None:
|
||
"""No explicit cap → ``BOR_OVERVIEW_INPUT_MAX_CHARS`` (read from the
|
||
live settings, so the test holds for any configured value)."""
|
||
cap = get_settings().overview_input_max_chars
|
||
rows = [("S", f"p{i}.md", "T", None) for i in range(3_000)]
|
||
_, user = build_overview_prompt(rows)
|
||
assert user.endswith(TRUNCATION_MARKER)
|
||
body = user.removesuffix("\n" + TRUNCATION_MARKER)
|
||
assert len(body) == cap # cut exactly at the cap, marker on its own line
|
||
assert "p2999.md" not in body # the overflow never reaches the model
|
||
|
||
|
||
# ---------- phase 142, task 01: cap-ordering repro -----------------------
|
||
|
||
|
||
def _phase_dir_rows() -> list[tuple[str, str, str, str]]:
|
||
"""20 rows in the owner's KB phase-directory shape (task 01).
|
||
|
||
``01_old_a.md`` … ``10_old_j.md`` are the OLDER documents and
|
||
``11_new_k.md`` … ``20_new_t.md`` the NEWER ones (the suffix is the
|
||
i-th letter, a–t), but the zero-padded
|
||
numbering makes the newest sort lexicographically LAST — the owner's
|
||
``113_…`` vs ``128_…`` vs ``131_…`` shape (``131_…`` sorts after
|
||
``13_…``, so the newest phases trail the lexicographic list). Each
|
||
rendered line (``source — path — title — summary``) is exactly 100
|
||
chars, so a 500-char cap fits five lines plus four newlines (504 —
|
||
one line over budget): the cut is guaranteed to land mid-list, and
|
||
the lexicographic first (the oldest) vs last (the newest) contrast
|
||
is what the repro asserts on.
|
||
"""
|
||
rows: list[tuple[str, str, str, str]] = []
|
||
for i in range(1, 21):
|
||
kind = "old" if i <= 10 else "new"
|
||
path = f"{i:02d}_{kind}_{chr(ord('a') + i - 1)}.md"
|
||
title = f"Phase {i:02d}"
|
||
prefix = f"s — {path} — {title} — "
|
||
rows.append(("s", path, title, "x" * (100 - len(prefix))))
|
||
return rows
|
||
|
||
|
||
def test_cap_keeps_the_given_prefix_and_cuts_the_tail() -> None:
|
||
"""Phase 142, task 01 — the cap behavior pinned AS-IS (green before
|
||
AND after the fix — the cap itself is unchanged, only the ordering
|
||
of the list feeding it is). Given rows in lexicographic order
|
||
(``01_old_a.md`` first, ``20_new_t.md`` last), a 500-char cap keeps
|
||
exactly the lexicographic FIRST lines and drops the tail: ``01_old_a``
|
||
survives, ``20_new_t`` is cut, and the shared marker makes the cut
|
||
visible on its own line (the current behavior, byte-for-byte).
|
||
"""
|
||
rows = _phase_dir_rows()
|
||
_, full = build_overview_prompt(rows, max_chars=10_000)
|
||
cap = 500
|
||
_, user = build_overview_prompt(rows, max_chars=cap)
|
||
assert "01_old_a.md" in user # the lexicographic prefix survives the cut
|
||
assert "20_new_t.md" not in user # the lexicographic tail never reaches the model
|
||
assert user.endswith("\n" + TRUNCATION_MARKER) # the cut is visible
|
||
body = user.removesuffix("\n" + TRUNCATION_MARKER)
|
||
assert len(body) == cap # cut exactly at the cap
|
||
assert body == full[:cap] # a plain prefix cut of the joined list
|
||
|
||
|
||
# ---------- load_kb_overview ----------
|
||
|
||
|
||
def test_load_kb_overview_no_row_empty(db: Session) -> None:
|
||
db.execute(text("DELETE FROM kb_overview"))
|
||
db.commit()
|
||
assert load_kb_overview(db) == ""
|
||
|
||
|
||
def test_load_kb_overview_returns_trimmed_content(db: Session) -> None:
|
||
db.execute(text("DELETE FROM kb_overview"))
|
||
db.add(KbOverview(id=1, content=f" {REPLY} \n"))
|
||
db.commit()
|
||
assert load_kb_overview(db) == REPLY
|
||
|
||
|
||
def test_load_kb_overview_whitespace_only_row_empty(db: Session) -> None:
|
||
db.execute(text("DELETE FROM kb_overview"))
|
||
db.add(KbOverview(id=1, content=" \n\t "))
|
||
db.commit()
|
||
assert load_kb_overview(db) == ""
|
||
|
||
|
||
# ---------- regenerate_overview ----------
|
||
|
||
|
||
def _add_doc(
|
||
db: Session,
|
||
source: str,
|
||
path: str,
|
||
title: str,
|
||
summary: str | None = None,
|
||
created_at: datetime | None = None,
|
||
) -> Document:
|
||
doc = Document(
|
||
id=uuid.uuid4(),
|
||
source=source,
|
||
path=path,
|
||
full_path=f"/tmp/{path}",
|
||
title=title,
|
||
content="body",
|
||
content_hash="0" * 64,
|
||
summary=summary,
|
||
)
|
||
if created_at is not None:
|
||
doc.created_at = created_at
|
||
db.add(doc)
|
||
db.commit()
|
||
return doc
|
||
|
||
|
||
def _truncate_documents(db: Session) -> None:
|
||
db.execute(text("TRUNCATE chunks, documents"))
|
||
db.commit()
|
||
|
||
|
||
def _overview_row(db: Session) -> KbOverview | None:
|
||
return db.get(KbOverview, 1)
|
||
|
||
|
||
@pytest.fixture()
|
||
def clean_overview(db: Session):
|
||
db.execute(text("DELETE FROM kb_overview"))
|
||
db.commit()
|
||
yield
|
||
db.execute(text("DELETE FROM kb_overview"))
|
||
db.commit()
|
||
|
||
|
||
def test_regenerate_happy_path_creates_single_row_id_1(
|
||
db: Session, clean_overview, caplog: pytest.LogCaptureFixture
|
||
) -> None:
|
||
_truncate_documents(db)
|
||
try:
|
||
_add_doc(db, "Homelab", "b.md", "B")
|
||
_add_doc(db, "Homelab", "a.md", "A", "A summary.\nSource: Homelab/a.md")
|
||
llm = _FakeLLM()
|
||
with caplog.at_level(logging.INFO, logger="app.rag.overview"):
|
||
ok = asyncio.run(regenerate_overview(llm, db))
|
||
assert ok is True
|
||
assert llm.calls == 1
|
||
row = db.get(KbOverview, 1)
|
||
assert row is not None, "the single row must be upserted"
|
||
assert row.id == 1
|
||
# Phase 162 (LOCKED A2): the stored content carries the
|
||
# deterministic source-inventory tail after the model text.
|
||
assert row.content == REPLY + "\nSources: Homelab (2 docs)"
|
||
assert row.updated_at is not None
|
||
age = datetime.now(UTC) - row.updated_at
|
||
assert age.total_seconds() < 300, "updated_at must be a fresh UTC timestamp"
|
||
assert f"overview: regenerated docs=2 chars={len(row.content)}" in caplog.text
|
||
finally:
|
||
_truncate_documents(db)
|
||
|
||
|
||
def test_regenerate_updates_existing_row_in_place(db: Session, clean_overview) -> None:
|
||
_truncate_documents(db)
|
||
past = datetime(2020, 1, 1, 12, 0, 0, tzinfo=UTC)
|
||
db.add(KbOverview(id=1, content="old outline", updated_at=past))
|
||
db.commit()
|
||
try:
|
||
_add_doc(db, "Homelab", "a.md", "A")
|
||
ok = asyncio.run(regenerate_overview(_FakeLLM(), db))
|
||
assert ok is True
|
||
count = db.scalar(text("SELECT count(*) FROM kb_overview"))
|
||
assert count == 1, "still exactly one row after the re-generation"
|
||
row = db.get(KbOverview, 1)
|
||
assert row is not None
|
||
assert row.content == REPLY + "\nSources: Homelab (1 doc)" # replaced, not appended
|
||
assert row.updated_at is not None and row.updated_at > past
|
||
finally:
|
||
_truncate_documents(db)
|
||
|
||
|
||
def test_regenerate_orders_documents_newest_first_then_source_path(
|
||
db: Session, clean_overview
|
||
) -> None:
|
||
"""Phase 142, task 02 — the generator's document list is RECENT-FIRST:
|
||
``created_at`` descending, with the ``(source, path)`` tie-break
|
||
keeping same-date documents in the pre-phase-142 lexicographic
|
||
order. Six documents in two ``created_at`` batches (inserted out of
|
||
order, sources interleaved across the batches): the SELECT must
|
||
return the NEWER batch first, ``(source, path)`` within each batch —
|
||
and the date order must dominate the source/path order (pre-fix the
|
||
plain ``order_by(source, path)`` put ``S1 — a.md — Old S1a`` first)."""
|
||
_truncate_documents(db)
|
||
try:
|
||
old = datetime(2026, 1, 1, tzinfo=UTC)
|
||
new = datetime(2026, 6, 1, tzinfo=UTC)
|
||
# The NEWER batch (2026-06-01) — inserted in a jumbled order.
|
||
_add_doc(db, "S2", "z.md", "New S2z", created_at=new)
|
||
_add_doc(db, "S1", "y.md", "New S1y", created_at=new)
|
||
_add_doc(db, "S2", "a.md", "New S2a", created_at=new)
|
||
# The OLDER batch (2026-01-01) — also jumbled.
|
||
_add_doc(db, "S2", "b.md", "Old S2b", created_at=old)
|
||
_add_doc(db, "S1", "c.md", "Old S1c", created_at=old)
|
||
_add_doc(db, "S1", "a.md", "Old S1a", created_at=old)
|
||
llm = _FakeLLM()
|
||
assert asyncio.run(regenerate_overview(llm, db)) is True
|
||
finally:
|
||
_truncate_documents(db)
|
||
|
||
user = next(m["content"] for m in llm.messages if m["role"] == "user")
|
||
# Newest batch first (all three 2026-06-01 docs before any 2026-01-01
|
||
# doc), (source, path) within a batch — the pre-fix lexicographic
|
||
# order (S1/a, S1/c, S1/y, S2/a, S2/b, S2/z) would fail this pin.
|
||
assert user == (
|
||
"S1 — y.md — New S1y\n"
|
||
"S2 — a.md — New S2a\n"
|
||
"S2 — z.md — New S2z\n"
|
||
"S1 — a.md — Old S1a\n"
|
||
"S1 — c.md — Old S1c\n"
|
||
"S2 — b.md — Old S2b"
|
||
)
|
||
|
||
|
||
def test_document_list_reaching_the_model_leads_with_newest_docs(
|
||
db: Session, clean_overview
|
||
) -> None:
|
||
"""Phase 142, task 01 repro, flipped green by task 02 (the strict
|
||
xfail marker is removed — a pass is now a pass).
|
||
|
||
The document list reaching the lite model must LEAD with the NEWEST
|
||
rows (``created_at`` desc — the document's date, phase 106) so that,
|
||
when the cap bites, the newest documents — not the lexicographically
|
||
first — are the ones the outline covers. Pre-fix the query was plain
|
||
``order_by(source, path)``: with the phase-dir rows below (recency
|
||
strictly OPPOSITE to the path order) that put ``01_old_a.md`` — the
|
||
OLDEST — first and ``20_new_t.md`` — the NEWEST — last, the traced
|
||
"summaries only up to phase 113" failure at unit scale. The
|
||
assertion is the POST-FIX order task 02's ``created_at``-desc
|
||
ordering delivers.
|
||
"""
|
||
_truncate_documents(db)
|
||
try:
|
||
base = datetime(2026, 1, 1, tzinfo=UTC)
|
||
for i, (source, path, title, summary) in enumerate(_phase_dir_rows()):
|
||
_add_doc(db, source, path, title, summary)
|
||
# Row i (0-based) is i days after the base: index 0
|
||
# (``01_old_a.md``) is the OLDEST, index 19 (``20_new_t.md``)
|
||
# the NEWEST — recency strictly opposite to the path order.
|
||
db.execute(
|
||
text("UPDATE documents SET created_at = :d WHERE path = :p"),
|
||
{"d": base + timedelta(days=i), "p": path},
|
||
)
|
||
db.commit()
|
||
llm = _FakeLLM()
|
||
assert asyncio.run(regenerate_overview(llm, db)) is True
|
||
finally:
|
||
_truncate_documents(db)
|
||
|
||
user = next(m["content"] for m in llm.messages if m["role"] == "user")
|
||
lines = user.splitlines()
|
||
assert len(lines) == 20 # no cap at this size — the ORDER is what is asserted
|
||
assert lines[0].startswith("s — 20_new_t.md"), (
|
||
"the list must lead with the NEWEST doc (20_new_t) — pre-fix it "
|
||
"leads with the lexicographic prefix (01_old_a, the oldest)"
|
||
)
|
||
assert lines[-1].startswith("s — 01_old_a.md"), (
|
||
"the oldest doc must sort LAST — pre-fix it sorts FIRST"
|
||
)
|
||
|
||
|
||
def test_regenerate_calls_the_configured_summary_model(
|
||
db: Session, clean_overview
|
||
) -> None:
|
||
_truncate_documents(db)
|
||
try:
|
||
_add_doc(db, "Homelab", "a.md", "A")
|
||
llm = _FakeLLM()
|
||
assert asyncio.run(regenerate_overview(llm, db)) is True
|
||
finally:
|
||
_truncate_documents(db)
|
||
|
||
assert llm.model == llm.settings.llm_summary_model # the ``lite`` default
|
||
assert llm.model == "lite"
|
||
assert [m["role"] for m in llm.messages] == ["system", "user"]
|
||
assert KB_OVERVIEW_MODE in llm.messages[0]["content"]
|
||
assert "A" in llm.messages[1]["content"]
|
||
|
||
|
||
def test_regenerate_zero_docs_leaves_existing_row_untouched(
|
||
db: Session, clean_overview
|
||
) -> None:
|
||
db.execute(text("DELETE FROM kb_overview"))
|
||
db.add(KbOverview(id=1, content="old outline"))
|
||
db.commit()
|
||
_truncate_documents(db)
|
||
llm = _FakeLLM()
|
||
ok = asyncio.run(regenerate_overview(llm, db))
|
||
assert ok is False
|
||
assert llm.calls == 0, "no KB → no wasted lite-model call"
|
||
row = db.get(KbOverview, 1)
|
||
assert row is not None and row.content == "old outline" # untouched
|
||
|
||
|
||
def test_regenerate_zero_docs_without_row_creates_nothing(db: Session, clean_overview) -> None:
|
||
_truncate_documents(db)
|
||
llm = _FakeLLM()
|
||
ok = asyncio.run(regenerate_overview(llm, db))
|
||
assert ok is False
|
||
assert llm.calls == 0
|
||
assert _overview_row(db) is None, "no row must be invented for an empty KB"
|
||
|
||
|
||
def test_regenerate_llm_error_leaves_previous_row_intact(
|
||
db: Session, clean_overview, caplog: pytest.LogCaptureFixture
|
||
) -> None:
|
||
db.execute(text("DELETE FROM kb_overview"))
|
||
db.add(KbOverview(id=1, content="old outline"))
|
||
db.commit()
|
||
_truncate_documents(db)
|
||
try:
|
||
_add_doc(db, "Homelab", "a.md", "A")
|
||
llm = _FakeLLM(fail=LLMError("simulated lite-model failure"))
|
||
with caplog.at_level(logging.ERROR, logger="app.rag.overview"):
|
||
ok = asyncio.run(regenerate_overview(llm, db))
|
||
assert ok is False, "fail-soft: the import must not be dragged down"
|
||
row = db.get(KbOverview, 1)
|
||
assert row is not None and row.content == "old outline", (
|
||
"the previous outline stays — an old outline is better than none"
|
||
)
|
||
assert "overview: regeneration failed —" in caplog.text
|
||
assert "simulated lite-model failure" in caplog.text
|
||
finally:
|
||
_truncate_documents(db)
|
||
|
||
|
||
def test_regenerate_llm_error_without_row_creates_nothing(
|
||
db: Session, clean_overview
|
||
) -> None:
|
||
_truncate_documents(db)
|
||
try:
|
||
_add_doc(db, "Homelab", "a.md", "A")
|
||
llm = _FakeLLM(fail=LLMError("simulated lite-model failure"))
|
||
ok = asyncio.run(regenerate_overview(llm, db))
|
||
finally:
|
||
_truncate_documents(db)
|
||
|
||
assert ok is False
|
||
assert _overview_row(db) is None
|
||
|
||
|
||
def test_regenerate_opens_own_session_when_none(db: Session, clean_overview) -> None:
|
||
"""``session=None`` → a private ``SessionLocal`` session is opened,
|
||
committed, and closed (the import-script call path)."""
|
||
_truncate_documents(db)
|
||
try:
|
||
_add_doc(db, "Homelab", "a.md", "A")
|
||
ok = asyncio.run(regenerate_overview(_FakeLLM()))
|
||
finally:
|
||
_truncate_documents(db)
|
||
|
||
assert ok is True
|
||
row = db.get(KbOverview, 1) # fresh lookup via the test session
|
||
assert row is not None and row.content == REPLY + "\nSources: Homelab (1 doc)"
|
||
|
||
|
||
# ---------- phase 162: the deterministic source inventory (LOCKED A2) -------
|
||
|
||
|
||
def test_source_inventory_format_counts_and_catalog_order() -> None:
|
||
"""Exact format, singular/plural, and catalog order: one entry per
|
||
DISTINCT source — ``<name> (<k> doc|docs)`` joined with ``, ``
|
||
behind ``Sources: ``, ordered ``source, path`` (the scrambled
|
||
input rows prove the catalog order is the OUTPUT order, not the
|
||
recency-first order of the rows the generator reads)."""
|
||
rows = [
|
||
("b", "x.md", "X", None),
|
||
("a", "y.md", "Y", None),
|
||
("c", "p1.md", "P1", None),
|
||
("b", "z.md", "Z", None),
|
||
("c", "p2.md", "P2", None),
|
||
("c", "p3.md", "P3", None),
|
||
]
|
||
assert _source_inventory(rows) == "Sources: a (1 doc), b (2 docs), c (3 docs)"
|
||
|
||
|
||
def test_source_inventory_keeps_a_one_doc_source_visible() -> None:
|
||
"""The 2026-10-07 ``ikea_instructions`` case: a 1-doc source among
|
||
many appears with ``(1 doc)`` — no source is invisible to
|
||
``<knowledge_base>``, whatever the ``lite`` outline says."""
|
||
rows = [
|
||
("gufo", "a.md", "A", None),
|
||
(
|
||
"ikea_instructions",
|
||
"besta-frame-white__AA-1272068-11-100.md",
|
||
"Assembly Instructions",
|
||
None,
|
||
),
|
||
("gufo", "b.md", "B", None),
|
||
("players_handbook", "core.md", "Core", None),
|
||
]
|
||
assert _source_inventory(rows) == (
|
||
"Sources: gufo (2 docs), ikea_instructions (1 doc), "
|
||
"players_handbook (1 doc)"
|
||
)
|
||
|
||
|
||
def test_regenerate_appends_the_inventory_tail_to_the_stored_content(
|
||
db: Session, clean_overview, caplog: pytest.LogCaptureFixture
|
||
) -> None:
|
||
"""Happy path, exact pin on the fake's fixed output: the stored row
|
||
= model text (trailing newlines rstripped) + ``\\n`` + the inventory
|
||
line; the log's chars = the FULL stored content, tail included."""
|
||
_truncate_documents(db)
|
||
try:
|
||
_add_doc(db, "players_handbook", "rpg.md", "RPG")
|
||
_add_doc(db, "Homelab", "b.md", "B")
|
||
_add_doc(db, "Homelab", "a.md", "A")
|
||
llm = _FakeLLM(reply=REPLY + "\n\n")
|
||
expected = REPLY + "\nSources: Homelab (2 docs), players_handbook (1 doc)"
|
||
with caplog.at_level(logging.INFO, logger="app.rag.overview"):
|
||
ok = asyncio.run(regenerate_overview(llm, db))
|
||
assert ok is True
|
||
row = db.get(KbOverview, 1)
|
||
assert row is not None
|
||
assert row.content == expected # no blank line before the tail
|
||
assert f"overview: regenerated docs=3 chars={len(expected)}" in caplog.text
|
||
finally:
|
||
_truncate_documents(db)
|
||
|
||
|
||
def test_regenerate_model_input_never_gains_the_inventory_tail(
|
||
db: Session, clean_overview
|
||
) -> None:
|
||
"""The model input is byte-identical to the pre-phase prompt: the
|
||
user message the fake received equals ``build_overview_prompt`` for
|
||
the same catalogue rows — the tail is never sent to ``lite``."""
|
||
_truncate_documents(db)
|
||
try:
|
||
_add_doc(db, "Homelab", "a.md", "A", "A summary.\nSource: Homelab/a.md")
|
||
_add_doc(db, "Deployments", "b.yaml", "B")
|
||
llm = _FakeLLM()
|
||
assert asyncio.run(regenerate_overview(llm, db)) is True
|
||
rows = db.execute(
|
||
select(Document.source, Document.path, Document.title, Document.summary)
|
||
.order_by(Document.created_at.desc(), Document.source, Document.path)
|
||
).all()
|
||
finally:
|
||
_truncate_documents(db)
|
||
|
||
user = next(m["content"] for m in llm.messages if m["role"] == "user")
|
||
_, expected_user = build_overview_prompt([tuple(r) for r in rows])
|
||
assert user == expected_user
|
||
assert "Sources:" not in user
|
||
|
||
|
||
def test_regenerate_zero_docs_keeps_tailless_pre_phase_row(
|
||
db: Session, clean_overview
|
||
) -> None:
|
||
"""Zero-doc early return: byte-identical pre-phase behavior — the
|
||
pre-phase (tail-less) row survives untouched, no tail is invented."""
|
||
db.execute(text("DELETE FROM kb_overview"))
|
||
pre_phase_row = "- Old outline\n- from before phase 162"
|
||
db.add(KbOverview(id=1, content=pre_phase_row))
|
||
db.commit()
|
||
_truncate_documents(db)
|
||
llm = _FakeLLM()
|
||
ok = asyncio.run(regenerate_overview(llm, db))
|
||
assert ok is False
|
||
assert llm.calls == 0
|
||
row = db.get(KbOverview, 1)
|
||
assert row is not None and row.content == pre_phase_row
|
||
assert "Sources:" not in row.content
|
||
|
||
|
||
def test_regenerate_llm_error_preserves_an_existing_inventory_tail(
|
||
db: Session, clean_overview
|
||
) -> None:
|
||
"""The tail is CONTENT: a previous row that already carried a
|
||
``Sources: …`` tail survives an ``LLMError`` byte-identically —
|
||
including the tail (an old outline is better than none)."""
|
||
db.execute(text("DELETE FROM kb_overview"))
|
||
previous = "- Old outline\nSources: Homelab (3 docs)"
|
||
db.add(KbOverview(id=1, content=previous))
|
||
db.commit()
|
||
_truncate_documents(db)
|
||
try:
|
||
_add_doc(db, "Homelab", "a.md", "A")
|
||
llm = _FakeLLM(fail=LLMError("simulated lite-model failure"))
|
||
ok = asyncio.run(regenerate_overview(llm, db))
|
||
assert ok is False
|
||
row = db.get(KbOverview, 1)
|
||
assert row is not None and row.content == previous
|
||
finally:
|
||
_truncate_documents(db)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Phase 191 (D4, task 05): the input lines carry the owner's display name
|
||
# ---------------------------------------------------------------------------
|
||
|
||
#: The owner's alias for the ``gitea-infra`` source (phase 191 D1 — an
|
||
#: alias, never the identity).
|
||
GIT_SERVER = "the git server"
|
||
|
||
|
||
@pytest.fixture()
|
||
def clean_source_labels(db: Session):
|
||
"""Empty ``source_labels`` before and after (migration-0030 state)."""
|
||
db.execute(text("DELETE FROM source_labels"))
|
||
db.commit()
|
||
yield
|
||
db.execute(text("DELETE FROM source_labels"))
|
||
db.commit()
|
||
|
||
|
||
# ---------- build_overview_prompt: the names map (pure) ----------
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"names",
|
||
[None, {}, {"Other": "another name"}],
|
||
ids=["names=None", "names={}", "no-entry-for-the-source"],
|
||
)
|
||
def test_input_lines_without_a_names_entry_are_byte_identical_to_pre_phase(
|
||
names: dict[str, str] | None,
|
||
) -> None:
|
||
"""THE kill pin (phase 191 D4): no map, an empty map and a map that
|
||
misses the row's source each build the pre-phase input byte-for-byte —
|
||
the same cap, the same separator, the same order."""
|
||
rows = [
|
||
("Homelab", "k8s.md", "Kubernetes", "A cluster.\nSource: Homelab/k8s.md"),
|
||
("Homelab", "borg.md", "Borg", None),
|
||
]
|
||
pre = build_overview_prompt(rows, max_chars=10_000)
|
||
assert build_overview_prompt(rows, max_chars=10_000, names=names) == pre
|
||
|
||
|
||
def test_input_line_leads_with_the_display_name_when_named() -> None:
|
||
"""A named source: the display name REPLACES the raw source name in the
|
||
lite model's input — the path / title / summary halves untouched."""
|
||
rows = [("gitea-infra", "docs/install.md", "Install", "The install notes.")]
|
||
_system, user = build_overview_prompt(rows, names={"gitea-infra": GIT_SERVER})
|
||
assert user == f"{GIT_SERVER} — docs/install.md — Install — The install notes."
|
||
assert "gitea-infra" not in user
|
||
|
||
|
||
def test_input_line_without_an_entry_keeps_the_raw_source_name() -> None:
|
||
"""Mixed catalogue: only the named source's lines change — every other
|
||
line stays exactly as the pre-phase builder wrote it."""
|
||
rows = [
|
||
("gitea-infra", "a.md", "A", "Sum A."),
|
||
("Homelab", "b.md", "B", "Sum B."),
|
||
]
|
||
_system, user = build_overview_prompt(rows, names={"gitea-infra": GIT_SERVER})
|
||
assert user.splitlines() == [
|
||
f"{GIT_SERVER} — a.md — A — Sum A.",
|
||
"Homelab — b.md — B — Sum B.",
|
||
]
|
||
|
||
|
||
def test_named_summary_less_line_has_no_dangling_dash() -> None:
|
||
"""The pre-phase "no summary → no trailing field" rule survives the
|
||
name swap."""
|
||
_system, user = build_overview_prompt(
|
||
[("gitea-infra", "a.md", "A", None)], names={"gitea-infra": GIT_SERVER}
|
||
)
|
||
assert user == f"{GIT_SERVER} — a.md — A"
|
||
|
||
|
||
def test_named_entry_with_an_empty_display_name_falls_back_to_the_source() -> None:
|
||
"""Defensive: an empty name in the map is "no name" (the API trims
|
||
empty submissions to NULL, so this never reaches the wire — the input
|
||
line must never lead with a blank)."""
|
||
_system, user = build_overview_prompt(
|
||
[("gitea-infra", "a.md", "A", None)], names={"gitea-infra": ""}
|
||
)
|
||
assert user == "gitea-infra — a.md — A"
|
||
|
||
|
||
def test_names_take_part_in_the_input_cap() -> None:
|
||
"""The cap counts the whole line (the name included) — the truncation
|
||
contract is unchanged: cut at the cap, marker on its own line."""
|
||
rows = [("gitea-infra", "a.md", "A", None)]
|
||
_system, plain = build_overview_prompt(rows, max_chars=10)
|
||
_system, named = build_overview_prompt(rows, max_chars=10, names={"gitea-infra": GIT_SERVER})
|
||
assert named != plain
|
||
assert named.endswith(TRUNCATION_MARKER)
|
||
assert named.startswith(GIT_SERVER[:10])
|
||
|
||
|
||
# ---------- regenerate_overview: the ONE all_labels read (DB) ----------
|
||
|
||
|
||
def test_regenerate_model_input_carries_the_display_name(
|
||
db: Session, clean_overview, clean_source_labels
|
||
) -> None:
|
||
"""The wire half of D4: the single read feeds the input lines, and the
|
||
unnamed source's line is untouched."""
|
||
_truncate_documents(db)
|
||
set_label(db, "gitea-infra", display_name=GIT_SERVER)
|
||
db.commit()
|
||
try:
|
||
base = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC)
|
||
_add_doc(db, "gitea-infra", "a.md", "A", "Sum A.", created_at=base + timedelta(days=1))
|
||
_add_doc(db, "Homelab", "b.md", "B", "Sum B.", created_at=base)
|
||
llm = _FakeLLM()
|
||
assert asyncio.run(regenerate_overview(llm, db)) is True
|
||
user = llm.messages[1]["content"]
|
||
assert user.splitlines() == [
|
||
f"{GIT_SERVER} — a.md — A — Sum A.",
|
||
"Homelab — b.md — B — Sum B.",
|
||
]
|
||
finally:
|
||
_truncate_documents(db)
|
||
|
||
|
||
def test_regenerate_stored_content_keeps_the_derived_names(
|
||
db: Session, clean_overview, clean_source_labels
|
||
) -> None:
|
||
"""The ALIAS contract (D1) on the stored row: the deterministic
|
||
``Sources: …`` inventory tail names sources by their DERIVED name —
|
||
a rename never changes the identity the rest of the app keys on."""
|
||
_truncate_documents(db)
|
||
set_label(db, "gitea-infra", display_name=GIT_SERVER, label="self-hosted forge")
|
||
db.commit()
|
||
try:
|
||
_add_doc(db, "gitea-infra", "a.md", "A")
|
||
assert asyncio.run(regenerate_overview(_FakeLLM(), db)) is True
|
||
row = db.get(KbOverview, 1)
|
||
assert row is not None
|
||
assert row.content == REPLY + "\nSources: gitea-infra (1 doc)"
|
||
assert GIT_SERVER not in row.content
|
||
finally:
|
||
_truncate_documents(db)
|
||
|
||
|
||
def test_regenerate_without_label_rows_is_byte_identical_to_pre_phase(
|
||
db: Session, clean_overview, clean_source_labels
|
||
) -> None:
|
||
"""No label rows (the whole-KB kill pin): the model input and the
|
||
stored content are exactly the pre-phase bytes."""
|
||
_truncate_documents(db)
|
||
try:
|
||
_add_doc(db, "Homelab", "a.md", "A", "Sum A.")
|
||
llm = _FakeLLM()
|
||
assert asyncio.run(regenerate_overview(llm, db)) is True
|
||
assert llm.messages[1]["content"] == "Homelab — a.md — A — Sum A."
|
||
row = db.get(KbOverview, 1)
|
||
assert row is not None and row.content == REPLY + "\nSources: Homelab (1 doc)"
|
||
finally:
|
||
_truncate_documents(db)
|
||
|
||
|
||
def test_regenerate_reads_the_label_rows_once(
|
||
db: Session, clean_overview, clean_source_labels, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""ONE ``all_labels`` read per run (the task's single-query contract):
|
||
the regenerator calls it exactly once and derives the name map from
|
||
those views — no second query."""
|
||
_truncate_documents(db)
|
||
set_label(db, "gitea-infra", display_name=GIT_SERVER)
|
||
db.commit()
|
||
calls: list[int] = []
|
||
|
||
def _counting_all_labels(session: Session) -> dict:
|
||
calls.append(1)
|
||
return real_all_labels(session)
|
||
|
||
monkeypatch.setattr(
|
||
"app.rag.overview.all_labels",
|
||
_counting_all_labels,
|
||
)
|
||
try:
|
||
_add_doc(db, "gitea-infra", "a.md", "A")
|
||
_add_doc(db, "gitea-infra", "b.md", "B")
|
||
assert asyncio.run(regenerate_overview(_FakeLLM(), db)) is True
|
||
assert len(calls) == 1
|
||
finally:
|
||
_truncate_documents(db)
|
||
|
||
|
||
# ---------- the marker pins: one read, in the right function ----------
|
||
|
||
OVERVIEW_PY = Path(overview_module.__file__).resolve().read_text(encoding="utf-8")
|
||
|
||
|
||
def test_overview_module_issues_exactly_one_all_labels_read() -> None:
|
||
"""The marker pin: the ONE ``all_labels(` call in the module — the
|
||
overview builder never queries labels on its own."""
|
||
assert OVERVIEW_PY.count("all_labels(") == 1
|
||
|
||
|
||
def test_the_single_read_lives_in_regenerate_overview() -> None:
|
||
"""The read sits in the caller (which already holds the session), not
|
||
in the pure builder — so the builder stays a pure function of its
|
||
arguments and one read covers the whole run."""
|
||
src = inspect.getsource(regenerate_overview)
|
||
assert src.count("all_labels(") == 1
|
||
assert "all_labels(" not in inspect.getsource(build_overview_prompt)
|