Files
brain-of-reese/tests/integration/test_import_docs_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

473 lines
19 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.
"""Integration test: ``import_docs`` regenerates the KB overview (phase 31, task 04).
Drives ``scripts.import_docs.main()`` end to end against the local compose
Postgres with a deterministic fake LLM (no live aipi, no git — explicit
``--source`` dirs and fresh settings, the phase 28 test's mocking style),
covering the change-gated overview trigger:
- a KB-changing import → ``kb_overview`` row written, the ``lite`` ``chat``
called exactly once, summary line ends ``overview=updated``;
- an unchanged re-import → ``chat`` **not** called again, ``overview=skipped``;
- a ``lite`` failure → exit code still ``0`` (the import itself was fine),
``overview=failed``, the previous row untouched;
- a ``--limit`` debug run with changes → ``overview=skipped``;
- an empty source run with no row → no row created, ``overview=skipped``.
Phase 53 (task 02): the sources-version bump sits alongside the
overview gate — a KB-changing run bumps ``sources_meta`` exactly once
(``sources_version=<n>`` on the summary line), including a **prune-only**
run: the invalidation gate ``added + updated + pruned > 0`` is
deliberately broader than the overview's (a pruned doc can invalidate a
saved answer that cited it, while the outline stays). ``--limit`` runs
and unchanged re-runs never bump (``sources_version=skipped``), and a
failed ``lite`` never rolls the bump back. The counter is pinned to the
migration-0010 seed (0) around every test by
:func:`_reset_sources_version`.
Phase 94 (task 02, line-extension house rule): the summary line now
ends with the folder-summary stats —
``folder_summaries=<generated>/<failed>/<pruned>`` when the gate fired
(this fixture's 2-doc source holds exactly ONE qualifying subtree: the
source root) or ``folder_summaries=skipped`` otherwise — so the line
pinned here gains that token, and a KB-changing run burns exactly ONE
extra ``lite`` call beyond the phase-118 document summaries (the
source-root folder summary; markdown files get document summaries too
since phase 118, A2 — so this 2-doc markdown source burns TWO doc-summary
calls on a fresh import).
"""
from __future__ import annotations
import logging
import os
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.db import SessionLocal, db_available
from app.models import KbOverview
from app.rag.llm import LLMError
from app.rag.sources_meta import current_sources_version
from scripts import import_docs
from tests.e2e.mock_llm import TOKEN_RE
from tests.fakes import FakeEmbedder
class FailingChatEmbedder(FakeEmbedder):
"""A ``lite`` model that always fails (drives the fail-soft path)."""
async def chat(self, messages: list[dict[str, str]], model: str | None = None) -> str:
self.chat_calls.append(list(messages))
raise LLMError("simulated lite-model failure (test sentinel)")
class OverviewDigestEmbedder(FakeEmbedder):
"""A ``lite`` stand-in reproducing the E2E mock's ``KB_OVERVIEW_MODE``
branch (``tests/e2e/mock_llm.py``): the overview call returns the
document list's first 8 tokens (lowercased, the mock's
``TOKEN_RE``) as the outline. The digest is ORDER-DEPENDENT, so the
stored row names exactly the documents that reached the model — the
phase 142 repro's witness. Every other call falls through to
:class:`FakeEmbedder`'s deterministic ``Summary of …`` digest."""
async def chat(self, messages: list[dict[str, str]], model: str | None = None) -> str:
system = next((m["content"] for m in messages if m.get("role") == "system"), "")
if "KB_OVERVIEW_MODE" in system:
self.chat_calls.append(list(messages)) # the base appends only on its path
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
return "Knowledge base outline:\n- " + " ".join(
TOKEN_RE.findall(user.lower())[:8]
)
return await super().chat(messages, model)
def _row(db: Session) -> KbOverview | None:
"""The stored ``kb_overview`` row (freshly reloaded)."""
db.expire_all()
return db.get(KbOverview, 1)
def _version(db: Session) -> int:
"""The ``sources_meta`` generation (phase 53; freshly reloaded)."""
db.expire_all()
return current_sources_version(db)
def _run_main(
monkeypatch: pytest.MonkeyPatch,
llm: FakeEmbedder,
argv: list[str],
capsys: pytest.CaptureFixture[str],
) -> tuple[int, str]:
"""Run ``import_docs.main`` with fresh settings, a fake LLM, and a
fail-loud git mock (``--source`` always wins, so git must stay idle)."""
monkeypatch.setattr(
import_docs,
"get_settings",
lambda: Settings(_env_file=None), # pyright: ignore[reportCallIssue]
)
def _no_git(url: str, dest: Path | str) -> Path:
raise AssertionError("git sync must not run with explicit --source")
monkeypatch.setattr(import_docs, "clone_or_pull", _no_git)
monkeypatch.setattr(import_docs, "LLMClient", lambda: llm)
rc = import_docs.main(argv)
return rc, capsys.readouterr().out
@pytest.fixture()
def src(tmp_path: Path) -> Path:
"""A source dir with two markdown docs (phase 118, A2: both get
document summaries — two extra ``chat`` calls over pre-118)."""
root = tmp_path / "MyDocs"
root.mkdir()
(root / "alpha.md").write_text("# Alpha\n\nFirst document.\n", encoding="utf-8")
(root / "beta.md").write_text("# Beta\n\nSecond document.\n", encoding="utf-8")
return root
@pytest.fixture()
def owner_kb(tmp_path: Path) -> Path:
"""Phase 142, task 01 — a KB shaped like the owner's: one source, an
OLDER batch whose paths sort FIRST (``old/01..03.md``) and a NEWER
batch whose paths sort LAST (``zz_new/01..03.md``) — the phase-dir
shape where ``131_…`` sorts after ``113_…``. Both batches get
explicit, staggered file mtimes (``os.utime``): phase 106 sources
``created_at`` from the local file's mtime, so recency strictly
OPPOSES the lexicographic path order (old/01 is the oldest and sorts
first; zz_new/03 is the newest and sorts last). The dates are
RELATIVE to the run (old batch ≈ 90 days back, new batch ≈ 1–3 days
back, each file strictly later than the previous): they stay in the
past forever (a fixed calendar date would drift beyond
``normalize_doc_date``'s future-skew tolerance and collapse to
"today") while the staggered order — and thus the digest — is a
pure function of the fixture, stable across runs."""
root = tmp_path / "MyDocs"
old = root / "old"
new = root / "zz_new"
old.mkdir(parents=True)
new.mkdir()
now = datetime.now(UTC)
for i in (1, 2, 3):
f = old / f"{i:02d}.md"
f.write_text(f"# Old {i:02d}\n\nOld body {i}.\n", encoding="utf-8")
ts = (now - timedelta(days=92 - i)).timestamp()
os.utime(f, (ts, ts))
for i in (1, 2, 3):
f = new / f"{i:02d}.md"
f.write_text(f"# Zz New {i:02d}\n\nNew body {i}.\n", encoding="utf-8")
ts = (now - timedelta(days=4 - i)).timestamp()
os.utime(f, (ts, ts))
return root
@pytest.fixture(autouse=True)
def _clean_kb(db: Session) -> Iterator[None]:
db.execute(text("TRUNCATE chunks, documents, kb_overview, folder_summaries"))
db.commit()
yield
db.execute(text("TRUNCATE chunks, documents, kb_overview, folder_summaries"))
db.commit()
@pytest.fixture(autouse=True)
def _reset_sources_version() -> Iterator[None]:
"""Phase 53: the sources version counter is global mutable state —
pin it to the migration-0010 seed (0) around every test so the bump
assertions start from a known generation (own session: the CLI
bumps through its own short-lived ``SessionLocal``). Skips like the
``db`` fixture when Postgres is down."""
if not db_available():
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
session = SessionLocal()
try:
session.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
session.commit()
yield
finally:
session.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
session.commit()
session.close()
def test_changed_import_writes_overview_row(
db: Session,
src: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
llm = FakeEmbedder()
records: list[logging.LogRecord] = []
class _Sink(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
records.append(record)
overview_logger = logging.getLogger("app.rag.overview")
sink = _Sink()
overview_logger.addHandler(sink)
overview_logger.setLevel(logging.INFO)
try:
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
finally:
overview_logger.removeHandler(sink)
assert rc == 0
assert "added=2" in out
# Phase 94: the line gains the folder-stats token — the 2-doc source
# holds one qualifying subtree (the source root): 1 generated.
assert out.rstrip().endswith(
"overview=updated sources_version=1 folder_summaries=1/0/0"
)
assert _version(db) == 1 # phase 53: a changed import bumps exactly once
# Exactly four lite calls — the two phase-118 document summaries
# (markdown included) + the overview + the source-root folder summary
# (nothing else may touch ``chat``).
assert len(llm.chat_calls) == 4
by_role = {m["role"]: m["content"] for m in llm.chat_calls[2]}
assert "KB_OVERVIEW_MODE" in by_role["system"]
# One line per doc: source — path — title — first summary line
# (phase 118: the markdown docs are summarized too — the fake's
# deterministic digest for each).
assert "MyDocs — alpha.md — Alpha — Summary of #" in by_role["user"]
assert "MyDocs — beta.md — Beta — Summary of #" in by_role["user"]
by_role = {m["role"]: m["content"] for m in llm.chat_calls[3]}
assert "FOLDER_SUMMARY_MODE" in by_role["system"]
assert by_role["user"].splitlines()[0] == "Folder: MyDocs"
# The model's outline lands in the single row, followed by the
# phase-162 deterministic source inventory (LOCKED A2).
row = _row(db)
assert row is not None
assert row.content == "Summary of MyDocs\nSources: MyDocs (2 docs)"
assert row.updated_at is not None
# The phase's required log line (PLAN §9).
assert any("overview: regenerated docs=2 chars=" in r.getMessage() for r in records)
def test_unchanged_reimport_does_not_call_lite(
db: Session,
src: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
llm = FakeEmbedder()
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
assert rc == 0
assert out.rstrip().endswith(
"overview=updated sources_version=1 folder_summaries=1/0/0"
)
# 2 doc summaries (phase 118) + overview + source-root folder summary.
assert len(llm.chat_calls) == 4
assert _row(db) is not None
# Same hashes → no KB change → no lite call, previous outline kept —
# and nothing to backfill (both summaries are already stored).
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
assert rc == 0
assert "unchanged=2" in out
assert out.rstrip().endswith(
"overview=skipped sources_version=skipped folder_summaries=skipped"
)
assert len(llm.chat_calls) == 4 # no new lite call
row = _row(db)
assert row is not None
assert row.content == "Summary of MyDocs\nSources: MyDocs (2 docs)"
assert _version(db) == 1 # phase 53: an unchanged re-run never bumps
def test_lite_failure_is_fail_soft(
db: Session,
src: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
good = FakeEmbedder()
rc, out = _run_main(monkeypatch, good, ["--source", str(src)], capsys)
assert rc == 0
assert out.rstrip().endswith(
"overview=updated sources_version=1 folder_summaries=1/0/0"
)
previous = _row(db)
assert previous is not None
previous_content = previous.content
# A KB-changing run whose ``lite`` model fails: the import still
# succeeds (exit 0) and the previous outline stays untouched.
(src / "alpha.md").write_text("# Alpha\n\nChanged content.\n", encoding="utf-8")
bad = FailingChatEmbedder()
rc, out = _run_main(monkeypatch, bad, ["--source", str(src)], capsys)
assert rc == 0 # a failed outline must not fail the import
assert "updated=1" in out
# Phase 94: the folder batch fails too (per-folder fail-soft) — the
# failed attempt counts into the stats, the previous row stays.
assert out.rstrip().endswith(
"overview=failed sources_version=2 folder_summaries=0/1/0"
)
# The three (failed) attempts: the changed doc's summary (phase 118),
# the overview, and the folder summary — the unchanged, already-
# summarized doc burns no backfill.
assert len(bad.chat_calls) == 3
row = _row(db)
assert row is not None
assert row.content == previous_content # previous row untouched
# Phase 53: the bump commits independently of the best-effort
# overview — a failed ``lite`` never rolls the version back.
assert _version(db) == 2
def test_limit_run_skips_overview(
db: Session,
src: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
llm = FakeEmbedder()
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
assert rc == 0
assert out.rstrip().endswith(
"overview=updated sources_version=1 folder_summaries=1/0/0"
)
assert len(llm.chat_calls) == 4 # 2 doc summaries + overview + folder
# An incomplete walk must not rewrite the outline (mirrors the
# --prune-with---limit guard) — and must not advance the version.
(src / "alpha.md").write_text("# Alpha\n\nChanged content.\n", encoding="utf-8")
rc, out = _run_main(monkeypatch, llm, ["--source", str(src), "--limit", "1"], capsys)
assert rc == 0
assert "updated=1" in out
assert out.rstrip().endswith(
"overview=skipped sources_version=skipped folder_summaries=skipped"
)
# --limit walks only alpha.md: its changed summary is the sole new
# lite call; the overview + folder gates skip under --limit.
assert len(llm.chat_calls) == 5
row = _row(db)
assert row is not None
assert row.content == "Summary of MyDocs\nSources: MyDocs (2 docs)"
assert _version(db) == 1 # phase 53: --limit debug runs never bump
def test_empty_source_without_row_creates_nothing(
db: Session,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
empty = tmp_path / "EmptyDocs"
empty.mkdir()
llm = FakeEmbedder()
rc, out = _run_main(monkeypatch, llm, ["--source", str(empty)], capsys)
assert rc == 0
assert "files=0" in out
assert out.rstrip().endswith(
"overview=skipped sources_version=skipped folder_summaries=skipped"
)
assert llm.chat_calls == [] # no KB → no outline, no wasted model call
assert _row(db) is None # nothing created
assert _version(db) == 0 # nothing changed → nothing bumped
def test_prune_only_run_bumps_sources_version(
db: Session,
src: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Phase 53 (task 02): the invalidation gate is deliberately broader
than the overview's — a prune-only run (added + updated == 0,
pruned > 0) advances the version (a pruned document can invalidate
a saved answer that cited it) while the outline stays.
"""
llm = FakeEmbedder()
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
assert rc == 0
assert "added=2" in out
assert out.rstrip().endswith(
"overview=updated sources_version=1 folder_summaries=1/0/0"
)
assert _version(db) == 1
# Delete one file; a --prune run drops exactly it: no add/update,
# but pruned=1 → the version still bumps while the overview skips.
assert len(llm.chat_calls) == 4 # 2 doc summaries + overview + folder
(src / "alpha.md").unlink()
rc, out = _run_main(monkeypatch, llm, ["--source", str(src), "--prune"], capsys)
assert rc == 0
assert "pruned=1" in out
# Phase 94: the folder gate is the overview's (added + updated > 0
# or empty table) — a prune-only re-walk with a populated table
# skips generation (the remaining 1-doc source stays summarized by
# its existing root row, which still describes it).
assert out.rstrip().endswith(
"overview=skipped sources_version=2 folder_summaries=skipped"
)
assert _version(db) == 2 # the prune-only change bumped exactly once
assert _row(db) is not None # the outline row is untouched
# ---------- phase 142, task 01: the KB-shape repro (red pre-fix) ----------
def test_overview_outline_names_the_newest_docs_when_the_cap_bites(
db: Session,
owner_kb: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Phase 142, task 01 repro, flipped green by task 02 (the strict
xfail marker is removed — a pass is now a pass).
The owner's KB shape: one source whose OLDER documents sort
lexicographically FIRST (``old/*``) and whose NEWER documents sort
LAST (``zz_new/*``). With ``BOR_OVERVIEW_INPUT_MAX_CHARS`` forced to
150, the 6-doc list (275 chars) exceeds the cap and the cut keeps a
PREFIX. Pre-fix that prefix was the lexicographic one — the three
OLDER ``old/*`` docs — so the stored outline's 8-token digest named
the old docs (``mydocs old 01 md old 01 summary of``) and the newer
``zz_new/*`` docs never reached the model (the "summaries only up to
phase 113" failure at fixture scale). POST-fix the ``created_at``-
desc ordering (task 02) puts the newest docs first, so they survive
the cap and are the ones the outline names
(``mydocs zz new 03 md zz new 03``).
"""
llm = OverviewDigestEmbedder()
get_settings.cache_clear()
monkeypatch.setenv("BOR_OVERVIEW_INPUT_MAX_CHARS", "150")
try:
rc, out = _run_main(monkeypatch, llm, ["--source", str(owner_kb)], capsys)
finally:
get_settings.cache_clear()
assert rc == 0
assert "added=6" in out
# 6 doc summaries (phase 118) + the overview + 3 folder summaries
# (source root, old/, zz_new/) — the overview gate fired (added > 0).
assert "overview=updated" in out
assert len(llm.chat_calls) == 10
overview_call = next(
m for m in llm.chat_calls
if "KB_OVERVIEW_MODE" in m[0]["content"]
)
user = next(m["content"] for m in overview_call if m["role"] == "user")
assert "[…truncated…]" in user # the 150-char cap bit (the repro's precondition)
row = _row(db)
assert row is not None
# POST-fix digest: the capped list leads with the NEWEST docs —
# "MyDocs — zz_new/03.md — Zz New 03 — Summary of #" → first 8
# tokens "mydocs zz new 03 md zz new 03". Pre-fix the stored row
# would have been "mydocs old 01 md old 01 summary of" (the oldest
# batch) — the "summaries only up to phase 113" shape this pin
# forbids.
assert row.content == (
"Knowledge base outline:\n- mydocs zz new 03 md zz new 03\n"
"Sources: MyDocs (6 docs)"
)
assert "old" not in row.content # no oldest-batch token may survive the cut