Files
brain-of-reese/tests/unit/test_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

870 lines
33 KiB
Python
Raw Blame History

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