**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/`).
473 lines
19 KiB
Python
473 lines
19 KiB
Python
"""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
|