Files
brain-of-reese/tests/unit/test_overview.py
T
ducoterra eebc824636
Build and Push Containers / build-and-push-app (push) Successful in 1m50s
Build and Push Containers / build-and-push-db (push) Successful in 14s
phase: 162_search_recall_small_sources
**Phase 162 — final verification pass (all tasks already complete; verified + docs pass)**

- Verified: `_fold` (NFKD+casefold, stdlib) inside `_normalize_name` — `BESTÅ`→`besta` question side, `BÅsta.md`→`basta` path side; A2 match classes/ranking/caps/bonus untouched; FTS/cosine/A8 untouched.
- Verified: `grep_document` folds both sides via the imported `_fold` (no second copy); regex-teaching lines byte-identical; 3 copy sites updated.
- Verified: `regenerate_overview` appends the deterministic `Sources: <name> (<k> doc|docs)` tail (happy path only; zero-doc + `LLMError` rows byte-identical; `build_overview_prompt`/`load_kb_overview`/`build_kb_section` untouched).
- Verified: `TOOLS_SECTION` grep sentence updated + D4 search-discipline sentence present **verbatim**, immediately after the grep sentence; `PERSONA`/LOW pins byte-identical (`test_prompt_lock.py`).
- Measured evidence (in `.agents/reports/162_…/task04_prefix_evidence_and_battery.md` + re-measured every run by `test_under_rank_is_measured_not_assumed`): pre-fix tokens `['build','best']` → IKEA doc rank 6 (outside top-5); post-fix `['build','besta']` → suggested #1, cosine unchanged 0.382.
- `uv run pytest tests/e2e/test_search_recall_small_sources.py -v --no-cov` → **7 passed** (isolated); neighbours `test_kb_overview{,_freshness} test_name_signal_read_chips test_grep_regex_teaching test_harness_aligned_tools test_agent_document_tools test_tool_call_compact` → **24 passed**.
- `uv run pytest --cov=app --cov-report=term-missing` → **3756 passed**, app coverage **99%** (>90%).
- `uv run ruff check .` → All checks passed; `uv run pyright` → **0 errors**.
- Completion criteria: fold both ways ✅ · `Sources:` tail ✅ · TOOLS_SECTION + D4 verbatim + re-cut pins ✅ · E2E green/battery recorded/full suite/coverage/lint ✅ · docs pass ✅ · commit + dir move = harness.
- Battery: 5 phase-119 questions byte-identical to the phase-119 record (GATE PASS); the 2 Besta questions **skipped loudly** — verified the 2026-09-05 replica dump has zero `ikea` matches (`grep -c -i ikea data/bor_live_kb_replica.dump.sql` = 0); rank gate stays with the owner's live KB.
- Docs fixed: README — overview-tail doc + `BOR_KB_OVERVIEW_MAX_CHARS` tail-cut constraint, the phase-162 search-discipline paragraph, per-phase (not per-story) E2E wording, new "Retrieval regression battery" section; AGENTS.md — rule 4 per-phase wording + battery command in Quick reference. (The overview's expected "AGENTS.md §6 tool table" does not exist in this repo — the grep contract line lives in README's tool table, already updated.)
- Deviations (recorded, not assumed): `_fold` adds `casefold` (required for the pinned `ß → ss`); `①` NFKD-decomposes to `1` (the overview's parenthetical was wrong) and is excluded by `NAME_TOKEN_MIN_LEN`; pre-fix evidence lives in the reports dir because task files are read-only for this pass.
- Owner follow-up A6 (runtime, not code): add the Tuning-tab steering note "IKEA's BESTÅ assembly PDF is a live source — search it as `BESTÅ` or `AA-1272068`".
- Next pending phase: none — 162 is the only entry in `todo/` (harness moves it to `complete/`).
2026-10-07 01:42:01 -04:00

653 lines
24 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.
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from datetime import UTC, datetime, timedelta
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.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
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)