223 lines
7.8 KiB
Python
223 lines
7.8 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``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from sqlalchemy import text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.config import Settings
|
|
from app.models import KbOverview
|
|
from app.rag.llm import LLMError
|
|
from scripts import import_docs
|
|
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)")
|
|
|
|
|
|
def _row(db: Session) -> KbOverview | None:
|
|
"""The stored ``kb_overview`` row (freshly reloaded)."""
|
|
db.expire_all()
|
|
return db.get(KbOverview, 1)
|
|
|
|
|
|
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 (md → no summary chat calls)."""
|
|
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(autouse=True)
|
|
def _clean_kb(db: Session) -> Iterator[None]:
|
|
db.execute(text("TRUNCATE chunks, documents, kb_overview"))
|
|
db.commit()
|
|
yield
|
|
db.execute(text("TRUNCATE chunks, documents, kb_overview"))
|
|
db.commit()
|
|
|
|
|
|
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
|
|
assert out.rstrip().endswith("overview=updated")
|
|
# Exactly one lite call — the overview itself (markdown files never
|
|
# get a summary, so nothing else may touch ``chat``).
|
|
assert len(llm.chat_calls) == 1
|
|
by_role = {m["role"]: m["content"] for m in llm.chat_calls[0]}
|
|
assert "KB_OVERVIEW_MODE" in by_role["system"]
|
|
# One line per doc: source — path — title (no summary for markdown).
|
|
assert "MyDocs — alpha.md — Alpha" in by_role["user"]
|
|
assert "MyDocs — beta.md — Beta" in by_role["user"]
|
|
# The model's outline lands in the single row.
|
|
row = _row(db)
|
|
assert row is not None
|
|
assert row.content == "Summary of MyDocs"
|
|
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")
|
|
assert len(llm.chat_calls) == 1
|
|
assert _row(db) is not None
|
|
|
|
# Same hashes → no KB change → no lite call, previous outline kept.
|
|
rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys)
|
|
assert rc == 0
|
|
assert "unchanged=2" in out
|
|
assert out.rstrip().endswith("overview=skipped")
|
|
assert len(llm.chat_calls) == 1 # no new lite call
|
|
row = _row(db)
|
|
assert row is not None and row.content == "Summary of MyDocs"
|
|
|
|
|
|
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")
|
|
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
|
|
assert out.rstrip().endswith("overview=failed")
|
|
assert len(bad.chat_calls) == 1 # the (failed) attempt was made
|
|
row = _row(db)
|
|
assert row is not None
|
|
assert row.content == previous_content # previous row untouched
|
|
|
|
|
|
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")
|
|
assert len(llm.chat_calls) == 1
|
|
|
|
# An incomplete walk must not rewrite the outline (mirrors the
|
|
# --prune-with---limit guard).
|
|
(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")
|
|
assert len(llm.chat_calls) == 1 # --limit never burns a lite call
|
|
row = _row(db)
|
|
assert row is not None and row.content == "Summary of MyDocs"
|
|
|
|
|
|
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")
|
|
assert llm.chat_calls == [] # no KB → no outline, no wasted model call
|
|
assert _row(db) is None # nothing created
|