Files
brain-of-reese/tests/unit/test_folder_summaries.py
T
ducoterra 742eef955b
Build and Push Containers / build-and-push-app (push) Successful in 2m25s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 155_folder_summary_voice
All verification is complete — the whole project is 100% green. Final report:

**Phase 155 final verification pass — complete.**

**Verified (all tasks 01–04 done in `complete/`):**
- Instruction nudge in `app/rag/folder_summaries.py` (forbidden openers as the pin, 4 constraints preserved, docstring cites the phase)
- `scripts/regen_folder_summaries.py` (import_docs idiom, full mode, cron-safe summary line, fail-loud boot)
- Unit pins, integration suite, mock voice update, dedicated E2E suite

**Test / lint / coverage:**
- `uv run pytest --cov=app -q` → 100% passed (exit 0, zero failures); coverage **99%** on `app/` (>90% ✓)
- `uv run pytest tests/e2e/test_folder_summary_voice.py -v --no-cov` → **3 passed** in isolation
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings

**Completion criteria:**
1. Marker + header prefix byte-identical — ✅ (git vs HEAD + unit pins verbatim, green)
2. Regen script full regeneration — ✅ (live smoke: exit 0, `folders=… limit=7` summary line; misconfig boot → named stderr, exit 1; `updated_at`-advance/no-prune/canned-text semantics proven by the integration suite, green)
3. Sync path untouched — ✅ (0 diffs to `app/api/sync.py` / `app/rag/importer.py`; phase-127 pins green)
4. E2E stored + rendered without forbidden openers — ✅ (3/3, exact canned-string assertions + phase-127 regression guard)
5. Full suite green, E2E isolated green, ruff + pyright clean, coverage >90% — ✅
6. Atomic commit — deferred to the harness (working tree left intact per protocol)

**Documentation:** README.md and AGENTS.md reviewed in full — verified consistent, no changes needed (README already documents the new script in the update workflow; all `.env.example` vars documented; pages table matches routes; 17 colors = 9+8; no stale mock-voice references).

**Decisions:** No live-infrastructure changes (dev-DB read + a 0-row script smoke only). The `TODO.md` working-tree edit is the owner's own, left untouched.

**Next pending phase:** `156_table_ux_polish`.
2026-10-04 16:32:50 -04:00

1543 lines
60 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: folder summary storage + generator (phase 94, task 01).
The prompt/grouping tests are pure (no DB): ``FOLDER_SUMMARY_MODE``
system prompt, the ``folder_of`` / ``group_by_folder`` recursive-subtree
concept, and the user-message cap with the shared ``[…truncated…]``
marker. The generator tests run against the local compose Postgres
(preferred — real upsert/prune on the ``folder_summaries`` table),
skipping with clear instructions when the stack is not up — same
pattern as ``test_overview.py``.
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from datetime import UTC, datetime
from typing import Any
import pytest
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.db import SessionLocal
from app.models import Document, FolderSummary
from app.rag.folder_summaries import (
FOLDER_HEADER_PREFIX,
FOLDER_SUMMARY_INSTRUCTION,
FOLDER_SUMMARY_MODE,
MIN_DOCS_PER_FOLDER,
SYSTEM_PROMPT,
affected_folder_keys,
build_folder_summary_prompt,
folder_of,
generate_folder_summaries,
group_by_folder,
missing_folder_summaries,
summarize_folder,
)
from app.rag.llm import LLMError
from app.rag.retriever import TRUNCATION_MARKER
from tests.e2e.mock_llm import compose_answer
REPLY = "Covers lab automation runbooks: inventories, playbooks, and schedules."
class _FakeLLM:
"""Duck-typed stand-in for ``LLMClient`` (``chat`` + ``settings``).
Records each ``(system, user)`` request and the ``model`` kwarg;
returns the canned reply, or raises — either a fixed exception or a
per-folder failure keyed on the user message's FIRST LINE (the
``Folder: …`` header) — EXACT match, so a nested folder's header
(``Folder: S/a/b``) can never shadow its parent's (``Folder: S/a``)
(the per-folder fail-soft tests).
"""
def __init__(
self,
reply: str = REPLY,
fail_folders: tuple[str, ...] = (),
fail: Exception | None = None,
) -> None:
self._reply = reply
self._fail_folders = tuple(fail_folders)
self._fail = fail
self.settings = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
self.calls = 0
self.model: str | None = None
self.requests: list[tuple[str, str]] = []
async def chat(
self, messages: list[dict[str, str]], model: str | None = None
) -> str:
self.calls += 1
self.model = model
system = messages[0]["content"]
user = messages[-1]["content"]
self.requests.append((system, user))
header = user.splitlines()[0] if user else ""
for folder in self._fail_folders:
if header == FOLDER_HEADER_PREFIX + folder:
raise LLMError(f"simulated lite-model failure for {folder}")
if self._fail is not None:
raise self._fail
return self._reply
# ---------- folder_of ----------
def test_folder_of_root_level_file_is_empty() -> None:
assert folder_of("a.md") == ""
def test_folder_of_one_level() -> None:
assert folder_of("a/b.md") == "a"
def test_folder_of_deep_path() -> None:
assert folder_of("a/b/c/d.md") == "a/b/c"
def test_folder_of_iterating_walks_prefixes_to_root() -> None:
"""Iterating ``folder_of`` over its own result walks the folder
prefixes nearest-first, ending at the root (the grouping walk)."""
folder = folder_of("a/b/c.md")
chain: list[str] = []
while folder:
chain.append(folder)
folder = folder_of(folder)
assert chain == ["a/b", "a"] # plus the "" root the grouping adds
# ---------- group_by_folder ----------
def test_group_by_folder_root_file_lands_only_in_source_root() -> None:
rows = [("S", "top.md", "T", None)]
groups = group_by_folder(rows)
assert set(groups) == {("S", "")}
assert groups[("S", "")] == rows
def test_group_by_folder_nested_multi_source_recursive_subtree() -> None:
"""A doc under ``a/b/`` is present in the ``a``, ``a/b``, and ``""``
groups (recursive subtree — the ``ls`` count scope, one concept);
per source the candidates are ``""`` + every distinct folder
prefix; group lists keep the input (catalogue) order."""
rows = [
("S", "a/b/c.md", "C", None),
("S", "a/b/d.md", "D", None),
("S", "a/x.md", "X", None),
("S", "top.md", "T", None),
("T", "a/b/e.md", "E", None),
]
groups = group_by_folder(rows)
assert set(groups) == {
("S", ""),
("S", "a"),
("S", "a/b"),
("T", ""),
("T", "a"),
("T", "a/b"),
}
# The recursive-subtree concept: a/b/ docs in the a/, a/b/, and ""
# groups alike — exactly the set each level's ls count shows.
assert [r[1] for r in groups[("S", "a/b")]] == ["a/b/c.md", "a/b/d.md"]
assert [r[1] for r in groups[("S", "a")]] == ["a/b/c.md", "a/b/d.md", "a/x.md"]
assert [r[1] for r in groups[("S", "")]] == [
"a/b/c.md",
"a/b/d.md",
"a/x.md",
"top.md",
]
# Multi-source: the same folder prefix under another source is a
# separate group (PK is (source, folder_path)).
assert [r[1] for r in groups[("T", "a/b")]] == ["a/b/e.md"]
assert [r[1] for r in groups[("T", "")]] == ["a/b/e.md"]
# Input (catalogue) order is preserved inside each group.
assert [r[2] for r in groups[("S", "")]] == ["C", "D", "X", "T"]
def test_group_by_folder_single_doc_folder_is_a_group_too() -> None:
"""Grouping is pure subtree membership: at the
:data:`MIN_DOCS_PER_FOLDER` = 1 rule every group (≥ 1 docs) is
already a generator candidate, so grouping and candidacy agree
(the generator's filter is an inert safety net — pinned by the
generator tests, not the grouping)."""
rows = [("S", "a/only.md", "O", None)]
groups = group_by_folder(rows)
assert len(groups[("S", "a")]) == 1 # present, but below the minimum
def test_group_by_folder_doc_path_equal_to_a_folder_prefix_counts_for_it() -> None:
"""The count rule's ``path == folder`` arm: a document whose path
IS one of the source's folder prefixes (a file sharing its name
with a directory) belongs to that folder's group too — the grouping
stays EXACTLY the set the ``ls`` count rule counts (path equal or
starting with ``folder + "/"``), while the returned keys remain the
true folder prefixes only (no file-path keys)."""
rows = [
("S", "a/b", "B", None), # a file named "b" ... (its path is a folder prefix)
("S", "a/b/c.md", "C", None), # ... and a real folder "a/b/" holding a doc
("S", "a/x.md", "X", None),
]
groups = group_by_folder(rows)
assert set(groups) == {("S", ""), ("S", "a"), ("S", "a/b")}, (
"the keys stay the true folder prefixes — the file's own path adds no key"
)
assert [r[1] for r in groups[("S", "a/b")]] == ["a/b", "a/b/c.md"]
assert [r[1] for r in groups[("S", "a")]] == ["a/b", "a/b/c.md", "a/x.md"]
assert [r[1] for r in groups[("S", "")]] == ["a/b", "a/b/c.md", "a/x.md"]
def test_group_by_folder_plain_file_path_is_not_a_group_key() -> None:
"""A file path that is NO folder prefix (no doc under it) adds no
group key of its own — the ``path == folder`` arm only fires when
the path really is a prefix of the catalogue."""
rows = [("S", "top.md", "T", None), ("S", "a/one.md", "O", None)]
groups = group_by_folder(rows)
assert set(groups) == {("S", ""), ("S", "a")}
assert "top.md" not in [folder for _source, folder in groups]
# ---------- affected_folder_keys (phase 127, task 02) ----------
def test_affected_folder_keys_root_level_file_is_only_the_root() -> None:
"""A root-level file (``folder_of == ""``) affects ONLY the source
root — there is no folder prefix to walk."""
assert affected_folder_keys([("S", "a.md")]) == {("S", "")}
def test_affected_folder_keys_nested_file_walks_the_folder_chain() -> None:
"""A doc under ``a/b/`` affects ``a/b``, ``a``, and the source
root — the SAME prefix walk ``group_by_folder`` pass 1 uses (one
concept)."""
assert affected_folder_keys([("S", "a/b/c.md")]) == {
("S", ""),
("S", "a"),
("S", "a/b"),
}
def test_affected_folder_keys_multi_source_set_semantics() -> None:
"""Keys are per source (the PK is ``(source, folder_path)``), and
the result is a SET — a repeated chain adds no new keys, and an
unrelated source keeps its own root."""
keys = affected_folder_keys(
[
("S", "a/b/c.md"),
("T", "x.md"),
("S", "a/d.md"), # the same chain as the first entry — no new keys
]
)
assert keys == {("S", ""), ("S", "a"), ("S", "a/b"), ("T", "")}
def test_affected_folder_keys_fragment_paths_fold_into_their_parent_folder() -> None:
"""Embedded-image fragment paths (phase 126: ``x.pdf#page=…#img=…``)
get NO special case: the fragment never holds a ``/``, so
``folder_of`` folds them into the parent folder's chain (a
root-level fragment affects only the source root)."""
assert affected_folder_keys([("S", "dir/img.pdf#page=2#img=0")]) == {
("S", ""),
("S", "dir"),
}
assert affected_folder_keys([("S", "img.pdf#page=1#img=0")]) == {("S", "")}
def test_affected_folder_keys_empty_input_is_an_empty_set() -> None:
assert affected_folder_keys([]) == set()
# ---------- build_folder_summary_prompt: system ----------
def test_system_prompt_has_marker_and_locked_instruction() -> None:
assert SYSTEM_PROMPT.startswith(FOLDER_SUMMARY_MODE)
assert FOLDER_SUMMARY_INSTRUCTION in SYSTEM_PROMPT
for fragment in (
"1-3 sentence",
"plain-text summary",
"natural language",
"Do not use markdown",
"not in the list",
):
assert fragment in SYSTEM_PROMPT
system, _ = build_folder_summary_prompt("S", "a/b", [])
assert system == SYSTEM_PROMPT
assert FOLDER_SUMMARY_MODE in system # the marker the E2E mock keys on
def test_instruction_pins_the_variety_nudge_and_preserved_constraints() -> None:
"""Phase 155 task 01: the locked instruction keeps the four phase-94
constraints (1–3 sentences; plain text; no markdown; no invention)
and ADDS the variety direction — the two forbidden openers are the
pin (an explicit NOT, not a mere mention)."""
# The four preserved constraints (phase 94 — unchanged).
for fragment in (
"1-3 sentence",
"plain-text",
"natural language",
"Do not use markdown",
"Do not invent anything that is not in the list",
):
assert fragment in FOLDER_SUMMARY_INSTRUCTION, fragment
# The variety direction (phase 155): the "varied, natural way"
# direction must survive the grammar polish.
assert "Open in a varied, natural way" in FOLDER_SUMMARY_INSTRUCTION
assert "state what the folder is for" in FOLDER_SUMMARY_INSTRUCTION
# The two forbidden openers, banned by an explicit NOT.
assert "Do NOT begin with" in FOLDER_SUMMARY_INSTRUCTION
assert "'This folder contains'" in FOLDER_SUMMARY_INSTRUCTION
assert "'The <folder name> folder contains'" in FOLDER_SUMMARY_INSTRUCTION
def test_marker_and_header_prefix_stay_byte_identical() -> None:
"""Phase 155 task 01: the E2E mock keys on ``FOLDER_SUMMARY_MODE``
and the ``FOLDER_HEADER_PREFIX`` line — the instruction nudge must
not touch them (the mock-contract pin).``SYSTEM_PROMPT`` keeps the
marker-first shape: the nudge lands AFTER the marker."""
assert FOLDER_SUMMARY_MODE == "FOLDER_SUMMARY_MODE"
assert FOLDER_HEADER_PREFIX == "Folder: "
assert SYSTEM_PROMPT.startswith("FOLDER_SUMMARY_MODE: ")
# ---------- build_folder_summary_prompt: user ----------
def test_user_prompt_header_names_the_folder() -> None:
"""The first line is the ``FOLDER_HEADER_PREFIX`` header the E2E
mock parses: ``<source>`` for the root, ``<source>/<folder_path>``
for a folder."""
_, user = build_folder_summary_prompt("Homelab", "deployments/ansible", [])
assert user == FOLDER_HEADER_PREFIX + "Homelab/deployments/ansible"
_, user = build_folder_summary_prompt("Homelab", "", [])
assert user == FOLDER_HEADER_PREFIX + "Homelab"
def test_user_lines_carry_path_title_and_first_summary_line() -> None:
docs = [
("S", "a/b/one.md", "One", "First lead.\nSecond line.\nSource: S/a/b/one.md"),
("S", "a/b/two.md", "Two", None),
]
system, user = build_folder_summary_prompt("S", "a/b", docs)
assert system == SYSTEM_PROMPT
assert user == (
"Folder: S/a/b\n"
"a/b/one.md — One — First lead.\n"
"a/b/two.md — Two"
)
def test_user_line_omits_summary_field_when_absent_or_blank() -> None:
docs = [
("S", "a/x.md", "X", None),
("S", "a/y.md", "Y", " \n\t "),
]
_, user = build_folder_summary_prompt("S", "a", docs)
assert user == "Folder: S/a\na/x.md — X\na/y.md — Y"
assert " — " in user # the path — title join only
assert not any(line.endswith(" — ") for line in user.splitlines())
def test_user_line_uses_only_first_summary_line() -> None:
docs = [
("S", "a/x.md", "X", "First line.\nSecond line.\nSource: S/a/x.md"),
]
_, user = build_folder_summary_prompt("S", "a", docs)
assert user == "Folder: S/a\na/x.md — X — First line."
assert "Second line" not in user
assert "Source:" not in user
def test_user_prompt_truncated_with_marker_when_over_custom_cap() -> None:
docs = [("S", f"a/f{i}.md", f"T{i}", None) for i in range(10)]
_, full = build_folder_summary_prompt("S", "a", docs, max_chars=10_000)
cap = 30
_, user = build_folder_summary_prompt("S", "a", docs, 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:
docs = [("S", "a/x.md", "X", None)] # "Folder: S/a\na/x.md — X" = 22 chars
_, user = build_folder_summary_prompt("S", "a", docs, max_chars=22)
assert user == "Folder: S/a\na/x.md — X"
assert TRUNCATION_MARKER not in user
def test_user_prompt_truncated_at_default_cap() -> None:
"""No explicit cap → ``BOR_FOLDER_SUMMARY_INPUT_MAX_CHARS`` (read
from the live settings, so the test holds for any configured
value)."""
cap = get_settings().folder_summary_input_max_chars
docs = [("S", f"a/f{i}.md", "T", None) for i in range(3_000)]
_, user = build_folder_summary_prompt("S", "a", docs)
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 "f2999.md" not in body # the overflow never reaches the model
# ---------- summarize_folder ----------
# ---------- the E2E mock's FOLDER_SUMMARY_MODE branch ----------
def _mock_body(system: str, user: str) -> dict[str, Any]:
"""A minimal chat-completion body for the mock's ``compose_answer``."""
return {"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
]}
def test_mock_returns_canned_folder_summary_naming_the_folder() -> None:
"""The deterministic E2E mock keys on the ``FOLDER_SUMMARY_MODE``
marker in the system prompt and returns the canned one-liner naming
the folder from the ``Folder: …`` header — driven through the
GENERATOR's real prompt, so the two can never drift (the drill-down
E2E asserts on this exact template). Phase 155 task 01: the canned
voice is the natural-opener ``Documentation for <folder>.``."""
system, user = build_folder_summary_prompt(
"Homelab", "deployments/ansible",
[("Homelab", "deployments/ansible/lab-inventory.md", "Lab Inventory", None)],
)
assert compose_answer(_mock_body(system, user)) == (
"Documentation for Homelab/deployments/ansible."
)
# The source-root row names the source itself.
system, user = build_folder_summary_prompt("Homelab", "",
[("Homelab", "top.md", "Top", None)])
assert compose_answer(_mock_body(system, user)) == (
"Documentation for Homelab."
)
def test_mock_canned_folder_reply_shape_and_determinism() -> None:
"""Phase 155 task 01: the canned folder reply is the new voice —
it NAMES the folder (from the ``Folder: …`` header), AVOIDS the
two forbidden openers, and is a PURE function of the request (the
determinism contract: two identical requests → identical replies).
"""
def reply_for(source: str, folder_path: str) -> str:
system, user = build_folder_summary_prompt(
source, folder_path, [(source, "x.md", "X", None)]
)
return compose_answer(_mock_body(system, user))
first = reply_for("Homelab", "deployments/ansible")
second = reply_for("Homelab", "deployments/ansible") # same request → same reply
assert first == second, "pure function of the request (determinism contract)"
assert "Homelab/deployments/ansible" in first, "the reply names the folder"
# The two forbidden openers (phase 155's pin), case-insensitively.
assert not first.lower().startswith("this folder contains"), (
"the formulaic opener must not survive the canned reply"
)
assert not first.lower().startswith(
"the homelab/deployments/ansible folder contains"
), "the named-folder formulaic opener must not survive either"
# A different folder yields a different (but same-shape) reply —
# still names its own folder, still neither forbidden opener.
root = reply_for("Homelab", "")
assert root != first, "the reply is a function of the request, not a constant"
assert "Homelab" in root
assert not root.lower().startswith("this folder contains")
assert not root.lower().startswith("the homelab folder contains")
def test_mock_folder_marker_is_not_shadowed_by_the_summary_branch() -> None:
"""``FOLDER_SUMMARY_MODE`` contains ``SUMMARY_MODE`` as a substring —
the mock must check the folder branch FIRST, or every folder call
would land in the document-summary digest (regression pin)."""
system, user = build_folder_summary_prompt(
"S", "a", [("S", "a/x.md", "X", None)]
)
assert "SUMMARY_MODE" in system # the shadowing hazard is real
answer = compose_answer(_mock_body(system, user))
assert answer == "Documentation for S/a."
assert not answer.startswith("This document covers")
def test_summarize_folder_happy_path_returns_trimmed_text() -> None:
docs = [("S", "a/x.md", "X", None)]
llm = _FakeLLM(reply=f" {REPLY} \n")
out = asyncio.run(summarize_folder("S", "a", docs, llm))
assert out == REPLY # the model's text, trimmed
assert llm.calls == 1
def test_summarize_folder_calls_the_configured_summary_model_with_marker() -> None:
docs = [("S", "a/x.md", "X", "X lead.")]
llm = _FakeLLM()
asyncio.run(summarize_folder("S", "a", docs, llm))
assert llm.model == llm.settings.llm_summary_model # the ``lite`` default
assert llm.model == "lite"
system, user = llm.requests[0]
assert FOLDER_SUMMARY_MODE in system
assert user.startswith(FOLDER_HEADER_PREFIX + "S/a")
assert "a/x.md — X — X lead." in user
def test_summarize_folder_empty_reply_raises_llm_error() -> None:
docs = [("S", "a/x.md", "X", None)]
for reply in ("", " \n\t "):
llm = _FakeLLM(reply=reply)
with pytest.raises(LLMError, match="empty content for S/a"):
asyncio.run(summarize_folder("S", "a", docs, llm))
def test_summarize_folder_error_propagates() -> None:
docs = [("S", "a/x.md", "X", None)]
llm = _FakeLLM(fail=LLMError("simulated transport failure"))
with pytest.raises(LLMError, match="simulated transport failure"):
asyncio.run(summarize_folder("S", "a", docs, llm))
# ---------- generate_folder_summaries (real Postgres) ----------
def _add_doc(
db: Session, source: str, path: str, title: str, summary: str | 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,
)
db.add(doc)
db.commit()
return doc
def _truncate(db: Session) -> None:
db.execute(text("TRUNCATE chunks, documents"))
db.execute(text("DELETE FROM folder_summaries"))
db.commit()
def _rows(db: Session) -> dict[tuple[str, str], str]:
"""The stored folder summaries: ``{(source, folder_path): summary}``."""
result = db.execute(
text("SELECT source, folder_path, summary FROM folder_summaries")
).all()
return {(source, folder_path): summary for source, folder_path, summary in result}
def _seed_catalogue(db: Session) -> None:
"""The shared catalogue: FSU has four docs in three candidate
folders (root 4, a 3, a/b 2); FSU-solo has one doc (its root
folder is a candidate too — the ≥ 1 rule: a single-file source
root gets a row)."""
_add_doc(db, "FSU", "a/b/one.md", "One", "One lead.\nSource: FSU/a/b/one.md")
_add_doc(db, "FSU", "a/b/two.md", "Two")
_add_doc(db, "FSU", "a/three.md", "Three")
_add_doc(db, "FSU", "root.md", "Root")
_add_doc(db, "FSU-solo", "solo.md", "Solo")
@pytest.fixture()
def clean_tables(db: Session):
_truncate(db)
yield
_truncate(db)
def test_generate_happy_path_upserts_every_candidate_folder(
db: Session, clean_tables, caplog: pytest.LogCaptureFixture
) -> None:
"""Every folder with ≥ 1 recursive doc gets a row (the source root
row included — ``folder_path = ''`` — and the single-doc FSU-solo
root: the ≥ 1 rule); rows are stamped fresh; the stats dict and
the log line are right; folders are processed in deterministic
(source, folder_path) order."""
_seed_catalogue(db)
llm = _FakeLLM()
with caplog.at_level(logging.INFO, logger="app.rag.folder_summaries"):
stats = asyncio.run(generate_folder_summaries(db, llm))
assert stats == {"generated": 4, "failed": 0, "pruned": 0, "kept_manual": 0}
assert llm.calls == 4, "one lite call per candidate folder (the solo root: too)"
stored = _rows(db)
assert set(stored) == {("FSU", ""), ("FSU", "a"), ("FSU", "a/b"), ("FSU-solo", "")}
assert all(summary == REPLY for summary in stored.values())
assert stored[("FSU-solo", "")] == REPLY, (
"the single-doc source root is a candidate at the ≥ 1 rule"
)
row = db.get(FolderSummary, ("FSU", "a/b"))
assert row is not None
assert row.summary == REPLY
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"
# Deterministic (source, folder_path) order — root before the
# nested folders, one header per call.
assert [user.splitlines()[0] for _s, user in llm.requests] == [
"Folder: FSU",
"Folder: FSU/a",
"Folder: FSU/a/b",
"Folder: FSU-solo",
]
# The recursive-subtree input: the a/ prompt carries a/b's docs too.
a_prompt = llm.requests[1][1]
assert "a/b/one.md — One — One lead." in a_prompt
assert "a/three.md — Three" in a_prompt
assert "root.md — Root" not in a_prompt
assert (
"folder_summaries: generated=4 failed=0 pruned=0 kept_manual=0"
in caplog.text
), "the stats line must be greppable (PLAN §9 ample logging)"
def test_generate_per_folder_fail_soft_keeps_previous_and_lands_others(
db: Session, clean_tables, caplog: pytest.LogCaptureFixture
) -> None:
"""One folder's lite failure is logged and counted, its PREVIOUS
row is kept (an old summary is better than none), and the remaining
folders still land — a lite outage never fails the sync."""
_seed_catalogue(db)
db.add(FolderSummary(source="FSU", folder_path="a/b", summary="old summary"))
db.commit()
llm = _FakeLLM(fail_folders=("FSU/a/b",))
with caplog.at_level(logging.ERROR, logger="app.rag.folder_summaries"):
stats = asyncio.run(generate_folder_summaries(db, llm))
assert stats == {"generated": 3, "failed": 1, "pruned": 0, "kept_manual": 0}
assert llm.calls == 4 # the failing folder was attempted too
stored = _rows(db)
assert stored[("FSU", "a/b")] == "old summary", (
"the previous row survives the per-folder failure"
)
assert (
stored[("FSU", "")] == REPLY
and stored[("FSU", "a")] == REPLY
and stored[("FSU-solo", "")] == REPLY
), "the other folders still land"
assert "folder summary failed for FSU/a/b" in caplog.text
assert "simulated lite-model failure for FSU/a/b" in caplog.text
def test_generate_per_folder_fail_soft_without_previous_row_creates_nothing(
db: Session, clean_tables
) -> None:
_seed_catalogue(db)
llm = _FakeLLM(fail_folders=("FSU/a/b",))
stats = asyncio.run(generate_folder_summaries(db, llm))
assert stats["failed"] == 1
stored = _rows(db)
assert ("FSU", "a/b") not in stored, "no row must be invented for a failed folder"
assert ("FSU", "") in stored and ("FSU", "a") in stored
def test_generate_prunes_stale_rows_and_keeps_live_ones(db: Session, clean_tables) -> None:
"""Rows for folders that VANISHED (0 recursive docs) are deleted
(pruned/renamed — the summary would go stale); rows for folders
that still hold ≥ 1 doc persist (an unchanged folder's summary is
still true — regenerated in place), the 1-doc FSU-solo root
included (it is a candidate at the ≥ 1 rule)."""
_seed_catalogue(db)
# A stale row for a folder no longer in the catalogue (renamed
# away — 0 docs) + live rows with old content.
db.add(FolderSummary(source="FSU", folder_path="gone/old", summary="stale"))
db.add(FolderSummary(source="FSU", folder_path="a", summary="old a summary"))
db.add(FolderSummary(source="FSU-solo", folder_path="", summary="solo stale"))
db.commit()
stats = asyncio.run(generate_folder_summaries(db, _FakeLLM()))
assert stats["pruned"] == 1 # gone/old only (its folder vanished)
stored = _rows(db)
assert ("FSU", "gone/old") not in stored, "the stale folder row must be pruned"
assert stored[("FSU-solo", "")] == REPLY, (
"the 1-doc root qualifies — regenerated, not pruned"
)
assert ("FSU", "a") in stored, "the still-qualifying folder keeps its row"
assert stored[("FSU", "a")] == REPLY # regenerated, not stale
assert stored[("FSU", "")] == REPLY and stored[("FSU", "a/b")] == REPLY
def test_generate_skip_is_a_full_noop(db: Session, clean_tables) -> None:
"""``skip=True`` (the ``--limit`` debug run): the LLM is never
called, no rows are touched, zero stats."""
_seed_catalogue(db)
db.add(FolderSummary(source="FSU", folder_path="", summary="existing"))
db.commit()
llm = _FakeLLM()
stats = asyncio.run(generate_folder_summaries(db, llm, skip=True))
assert stats == {"generated": 0, "failed": 0, "pruned": 0, "kept_manual": 0}
assert llm.calls == 0
assert _rows(db) == {("FSU", ""): "existing"}
def test_generate_empty_kb_prunes_every_row(db: Session, clean_tables) -> None:
"""No documents → no candidate folders → every stored row is
pruned, with zero wasted lite calls."""
db.add(FolderSummary(source="FSU", folder_path="", summary="old"))
db.add(FolderSummary(source="FSU", folder_path="a/b", summary="old"))
db.commit()
llm = _FakeLLM()
stats = asyncio.run(generate_folder_summaries(db, llm))
assert stats == {"generated": 0, "failed": 0, "pruned": 2, "kept_manual": 0}
assert llm.calls == 0
assert _rows(db) == {}
def test_generate_summarizes_folder_whose_prefix_is_also_a_doc_path(
db: Session, clean_tables
) -> None:
"""The ``path == folder`` arm end to end: a file sharing its name
with a directory counts toward the folder's recursive count (2
docs → the folder is summarized, and BOTH docs are in its prompt).
"""
_add_doc(db, "FSU", "a/b", "B") # a file named "b" (its path is a prefix)
_add_doc(db, "FSU", "a/b/c.md", "C") # and a real folder "a/b/"
llm = _FakeLLM()
stats = asyncio.run(generate_folder_summaries(db, llm))
assert stats["generated"] == 3 # root (2), a (2), a/b (2) — all ≥ the minimum
stored = _rows(db)
assert set(stored) == {("FSU", ""), ("FSU", "a"), ("FSU", "a/b")}
a_b_prompt = [
user for _system, user in llm.requests if user.startswith("Folder: FSU/a/b\n")
][0]
assert "a/b — B" in a_b_prompt
assert "a/b/c.md — C" in a_b_prompt
def test_generate_only_flushes_caller_commits(db: Session, clean_tables) -> None:
"""The generator only flushes — the sync path owns the transaction
(the phase-53 ``bump_sources_version`` convention): the catalogue
is committed (the real sync path commits the import before the
summary hooks run), but a second session sees the generator's rows
as NOTHING until the CALLER commits — and sees them after."""
_add_doc(db, "FSU", "x/y/one.md", "One")
_add_doc(db, "FSU", "x/y/two.md", "Two")
stats = asyncio.run(generate_folder_summaries(db, _FakeLLM()))
assert stats["generated"] == 3 # root + x + x/y — all 2 recursive docs
with SessionLocal() as other:
n = other.scalar(
text("SELECT count(*) FROM folder_summaries WHERE source = 'FSU'")
)
assert n == 0, "unflushed-by-caller rows must not be visible yet"
db.commit()
with SessionLocal() as other:
n = other.scalar(
text("SELECT count(*) FROM folder_summaries WHERE source = 'FSU'")
)
assert n == 3, "the caller's commit makes the flushed rows durable"
assert MIN_DOCS_PER_FOLDER == 1 # the ≥ 1 scope rule, pinned by name
# ---------- manually_edited (phase 97, task 01) ----------
def test_manual_row_survives_regeneration(
db: Session, clean_tables, caplog: pytest.LogCaptureFixture
) -> None:
"""An owner-edited row is SKIPPED on regeneration (phase 97, task
01): the fake LLM is never called for it (no ``lite`` burn on owner
text — not even a prompt is built), its text AND ``updated_at``
stay byte-identical, ``kept_manual`` counts it, the flag is never
cleared, and the 4-field log line carries it (PLAN §9)."""
_seed_catalogue(db)
manual_text = "Owner's own words about a/."
db.add(
FolderSummary(
source="FSU", folder_path="a", summary=manual_text,
manually_edited=True,
)
)
db.commit()
stamp_before = _updated_at(db, "FSU", "a")
assert stamp_before is not None
llm = _FakeLLM()
with caplog.at_level(logging.INFO, logger="app.rag.folder_summaries"):
stats = asyncio.run(generate_folder_summaries(db, llm))
assert stats == {"generated": 3, "failed": 0, "pruned": 0, "kept_manual": 1}
assert llm.calls == 3, "the manual folder burns zero lite calls"
assert [user.splitlines()[0] for _s, user in llm.requests] == [
"Folder: FSU",
"Folder: FSU/a/b",
"Folder: FSU-solo",
], "no prompt is ever built for the owner's folder"
stored = _rows(db)
assert stored[("FSU", "a")] == manual_text, "the owner's text survives"
assert _updated_at(db, "FSU", "a") == stamp_before, ("never re-stamped")
assert (
stored[("FSU", "")] == REPLY
and stored[("FSU", "a/b")] == REPLY
and stored[("FSU-solo", "")] == REPLY
), (
"the non-manual candidates still regenerate (the flag is the difference)"
)
row = db.get(FolderSummary, ("FSU", "a"))
assert row is not None and row.manually_edited is True, (
"the generator never clears the flag"
)
assert (
"folder_summaries: generated=3 failed=0 pruned=0 kept_manual=1"
in caplog.text
), "the 4-field stats line must be greppable (PLAN §9 ample logging)"
def test_manual_row_survives_the_prune(db: Session, clean_tables) -> None:
"""A manual row is NEVER pruned and NEVER overwritten (phase 97,
task 01): a/ and b/ each drop to 1 recursive doc — still
candidates at the ≥ 1 rule — so the MANUAL a/ row is SKIPPED
(owner text kept, no re-stamp, no lite burn) while the NON-manual
b/ twin regenerates in place; the vanished folders' rows show the
prune rule — the manual gone/manual row persists (owner content
until cleared — the clear deletes it, so the next KB-changing sync
regenerates an AI description) and the non-manual gone/ai twin is
pruned; the flag is the only difference in each pair."""
_add_doc(db, "FSU", "a/one.md", "One")
_add_doc(db, "FSU", "a/two.md", "Two")
_add_doc(db, "FSU", "b/one.md", "B One")
_add_doc(db, "FSU", "b/two.md", "B Two")
manual_text = "Owner's words about a/."
db.add(
FolderSummary(
source="FSU", folder_path="a", summary=manual_text,
manually_edited=True,
)
)
db.add(FolderSummary(source="FSU", folder_path="b", summary="ai words"))
db.add(
FolderSummary(
source="FSU", folder_path="gone/manual", summary="owner kept",
manually_edited=True,
)
)
db.add(FolderSummary(source="FSU", folder_path="gone/ai", summary="stale ai"))
db.commit()
# a/ and b/ each drop to 1 recursive doc — still candidates at
# the ≥ 1 rule.
db.execute(
text(
"DELETE FROM documents WHERE source = 'FSU'"
" AND path IN ('a/two.md', 'b/two.md')"
)
)
db.commit()
llm = _FakeLLM()
stats = asyncio.run(generate_folder_summaries(db, llm))
assert stats == {"generated": 2, "failed": 0, "pruned": 1, "kept_manual": 1}
assert llm.calls == 2, "root + the non-manual b/ regenerate; manual a/ burns nothing"
stored = _rows(db)
assert stored[("FSU", "a")] == manual_text, (
"the manual row is skipped — never overwritten, even at 1 doc"
)
assert stored[("FSU", "b")] == REPLY, (
"the non-manual twin regenerates — 1 doc still qualifies (the ≥ 1 rule)"
)
assert stored[("FSU", "gone/manual")] == "owner kept", (
"a vanished folder's manual row is kept — owner content until cleared"
)
assert ("FSU", "gone/ai") not in stored, ("the non-manual twin is pruned")
assert stored[("FSU", "")] == REPLY # the root (2 docs) still regenerates
# ---------- missing_folder_summaries (phase 96, task 02) ----------
def test_missing_fresh_table_is_exactly_the_candidate_set(
db: Session, clean_tables
) -> None:
"""No stored rows → every candidate folder is a gap, sorted by
``(source, folder_path)`` — the single-doc FSU-solo root
included (a candidate at the ≥ 1 rule)."""
_seed_catalogue(db)
assert missing_folder_summaries(db) == [
("FSU", ""),
("FSU", "a"),
("FSU", "a/b"),
("FSU-solo", ""),
]
def test_missing_fully_populated_table_is_empty(db: Session, clean_tables) -> None:
"""Every candidate row present → no gap (the zero-burn gate case)."""
_seed_catalogue(db)
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
db.commit()
assert missing_folder_summaries(db) == []
def test_missing_one_deleted_row_is_that_folder(db: Session, clean_tables) -> None:
_seed_catalogue(db)
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
db.commit()
db.execute(
text(
"DELETE FROM folder_summaries "
"WHERE source = 'FSU' AND folder_path = 'a'"
)
)
db.commit()
assert missing_folder_summaries(db) == [("FSU", "a")]
def test_missing_empty_kb_empty_table_is_no_gap(db: Session, clean_tables) -> None:
"""No catalogue → no candidates → ``[]`` — an empty table over an
empty KB is not a gap (there is nothing to fill)."""
assert missing_folder_summaries(db) == []
def test_missing_single_doc_folder_is_listed(db: Session, clean_tables) -> None:
"""The ≥ 1 rule: a single-doc folder WITHOUT a row IS a gap (it is
a candidate — the one file line no longer exempts it), alongside
its source root (1 doc)."""
_add_doc(db, "FSU", "solo/one.md", "One")
assert missing_folder_summaries(db) == [("FSU", ""), ("FSU", "solo")]
def test_missing_stale_row_is_not_a_gap(db: Session, clean_tables) -> None:
"""A stored row for a folder that dropped below the minimum
(vanished — 0 docs) is stale, not missing — the prune pass owns
it, the gap detector ignores it."""
_seed_catalogue(db)
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
db.commit()
db.add(FolderSummary(source="FSU", folder_path="gone/old", summary="stale"))
db.commit()
assert missing_folder_summaries(db) == []
# ---------- generate_folder_summaries(only_missing=…) (phase 96, 02) ----------
def _updated_at(db: Session, source: str, folder_path: str) -> object:
"""The stored row's ``updated_at`` (raw SQL — bypasses the ORM
identity map, so the before/after byte-identity comparison is
honest)."""
return db.execute(
text(
"SELECT updated_at FROM folder_summaries "
"WHERE source = :s AND folder_path = :f"
),
{"s": source, "f": folder_path},
).scalar_one()
def test_only_missing_fills_exactly_the_missing_keys(
db: Session, clean_tables
) -> None:
"""Two missing + two present → exactly the missing keys are
generated (sorted order, one lite call each); the present rows are
byte-identical after (text AND ``updated_at``); stats right."""
_seed_catalogue(db)
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
db.commit()
full = _rows(db)
a_stamp = _updated_at(db, "FSU", "a")
db.execute(
text(
"DELETE FROM folder_summaries "
"WHERE (source, folder_path) IN (('FSU', ''), ('FSU', 'a/b'))"
)
)
db.commit()
assert missing_folder_summaries(db) == [("FSU", ""), ("FSU", "a/b")]
llm = _FakeLLM()
stats = asyncio.run(generate_folder_summaries(db, llm, only_missing=True))
assert stats == {"generated": 2, "failed": 0, "pruned": 0, "kept_manual": 0}
assert llm.calls == 2, "one call per MISSING key — zero for present rows"
assert [user.splitlines()[0] for _s, user in llm.requests] == [
"Folder: FSU",
"Folder: FSU/a/b",
], "the missing keys in sorted (source, folder_path) order"
assert _rows(db) == full, "the fill restores exactly the full candidate set"
assert _updated_at(db, "FSU", "a") == a_stamp, (
"the present row is byte-identical — never re-stamped by the fill"
)
def test_only_missing_no_gap_burns_zero_calls(db: Session, clean_tables) -> None:
"""No gap → zero lite calls, zero rows touched, zero stats (the
zero-burn invariant the unchanged-sync gate relies on)."""
_seed_catalogue(db)
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
db.commit()
before = _rows(db)
stamps = {f: _updated_at(db, "FSU", f) for f in ("", "a", "a/b")}
llm = _FakeLLM()
stats = asyncio.run(generate_folder_summaries(db, llm, only_missing=True))
assert stats == {"generated": 0, "failed": 0, "pruned": 0, "kept_manual": 0}
assert llm.calls == 0, "zero-burn: no gap, no lite call"
assert _rows(db) == before
for folder, stamp in stamps.items():
assert _updated_at(db, "FSU", folder) == stamp, "no row re-stamped"
def test_only_missing_still_prunes_stale_rows(db: Session, clean_tables) -> None:
"""The prune pass runs in BOTH modes: the manually seeded stale row
(folder gone from the catalogue) is pruned while the genuine
missing folders are filled, and the present row stays untouched."""
_seed_catalogue(db)
db.add(FolderSummary(source="FSU", folder_path="a", summary="keep me"))
db.add(FolderSummary(source="FSU", folder_path="gone/old", summary="stale"))
db.commit()
a_stamp = _updated_at(db, "FSU", "a")
assert missing_folder_summaries(db) == [
("FSU", ""),
("FSU", "a/b"),
("FSU-solo", ""),
]
llm = _FakeLLM()
stats = asyncio.run(generate_folder_summaries(db, llm, only_missing=True))
assert stats == {"generated": 3, "failed": 0, "pruned": 1, "kept_manual": 0}
assert llm.calls == 3
stored = _rows(db)
assert ("FSU", "gone/old") not in stored, (
"the stale row is pruned even under only_missing"
)
assert stored[("FSU", "a")] == "keep me"
assert _updated_at(db, "FSU", "a") == a_stamp
assert (
stored[("FSU", "")] == REPLY
and stored[("FSU", "a/b")] == REPLY
and stored[("FSU-solo", "")] == REPLY
)
def test_only_missing_fail_soft_keeps_prior_and_lands_others(
db: Session, clean_tables
) -> None:
"""Per-folder fail-soft applies under ``only_missing`` too: the
failing missing folder is counted and stays absent; the other
missing folders still land; the present row is untouched."""
_seed_catalogue(db)
db.add(FolderSummary(source="FSU", folder_path="a", summary="keep me"))
db.commit()
llm = _FakeLLM(fail_folders=("FSU/a/b",))
stats = asyncio.run(generate_folder_summaries(db, llm, only_missing=True))
assert stats == {"generated": 2, "failed": 1, "pruned": 0, "kept_manual": 0}
assert llm.calls == 3 # all three missing folders were attempted
stored = _rows(db)
assert stored[("FSU", "")] == REPLY, "the other missing folders still land"
assert stored[("FSU-solo", "")] == REPLY, "the single-doc root still lands"
assert ("FSU", "a/b") not in stored, "the failed folder stays absent"
assert stored[("FSU", "a")] == "keep me", "the present row is untouched"
def test_gap_probe_subsumes_the_table_empty_gate(db: Session, clean_tables) -> None:
"""The deleted phase-94 table-empty gate probe, re-expressed through
``missing_folder_summaries`` (phase 96, task 03 — the probe's unit
coverage moved here): an empty table over a populated catalogue
means EVERY candidate is missing (the targeted fill over all
candidates IS a full generation — the first full sync after
migration 0017 must still generate), a populated table means no
gap (a populated table waits for a KB change or a gap)."""
assert missing_folder_summaries(db) == [] # the truncated table, empty KB
_add_doc(db, "FSU", "a/one.md", "One")
_add_doc(db, "FSU", "a/two.md", "Two")
assert missing_folder_summaries(db) == [("FSU", ""), ("FSU", "a")]
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
db.commit()
assert missing_folder_summaries(db) == [] # rows landed → no gap
db.execute(text("DELETE FROM folder_summaries"))
db.commit()
assert missing_folder_summaries(db) == [("FSU", ""), ("FSU", "a")] # emptied again
# ---------- on_progress hook (phase 98, task 01) ----------
def _record_progress() -> tuple[list[tuple[int, int, str, str]], Any]:
"""A fresh event list + the ``on_progress`` recorder that appends
every ``(done, total, source, folder_path)`` event it receives."""
events: list[tuple[int, int, str, str]] = []
def record(done: int, total: int, source: str, folder_path: str) -> None:
events.append((done, total, source, folder_path))
return events, record
def test_on_progress_fires_once_per_candidate_in_sorted_key_order(
db: Session, clean_tables
) -> None:
"""Phase 98 (task 01): the hook fires once per candidate, in the
same sorted ``(source, folder_path)`` order the folders are
attempted — done climbs 1..total, total = the candidate count
(the single-doc FSU-solo root IS a candidate at the ≥ 1 rule)."""
_seed_catalogue(db)
llm = _FakeLLM()
events, record = _record_progress()
stats = asyncio.run(generate_folder_summaries(db, llm, on_progress=record))
assert events == [
(1, 4, "FSU", ""),
(2, 4, "FSU", "a"),
(3, 4, "FSU", "a/b"),
(4, 4, "FSU-solo", ""),
]
assert llm.calls == 4 # one event per attempt, in the same order
assert stats["generated"] == 4
def test_on_progress_manual_skip_still_advances(db: Session, clean_tables) -> None:
"""A manual-skip key is an INSTANT skip — no ``lite`` call burns,
but the counter still advances for it (D5: the UI's position moves
on either outcome)."""
_seed_catalogue(db)
db.add(
FolderSummary(
source="FSU", folder_path="a", summary="owner text",
manually_edited=True,
)
)
db.commit()
llm = _FakeLLM()
events, record = _record_progress()
stats = asyncio.run(generate_folder_summaries(db, llm, on_progress=record))
assert events == [
(1, 4, "FSU", ""),
(2, 4, "FSU", "a"), # the instant manual skip still advances
(3, 4, "FSU", "a/b"),
(4, 4, "FSU-solo", ""),
]
assert llm.calls == 3, "the skip itself burns no call"
assert stats["kept_manual"] == 1 and stats["generated"] == 3
def test_on_progress_failed_key_still_advances(db: Session, clean_tables) -> None:
"""A failed (``LLMError``) key still advances — the hook fires
BEFORE the attempt, so a fail-soft miss is visible to the UI as a
completed step (the run never flips to failed, neither does the
counter stall)."""
_seed_catalogue(db)
llm = _FakeLLM(fail_folders=("FSU/a",))
events, record = _record_progress()
stats = asyncio.run(generate_folder_summaries(db, llm, on_progress=record))
assert events == [
(1, 4, "FSU", ""),
(2, 4, "FSU", "a"), # the failed attempt still advances
(3, 4, "FSU", "a/b"),
(4, 4, "FSU-solo", ""),
]
assert stats["failed"] == 1 and stats["generated"] == 3
def test_on_progress_only_missing_reports_the_missing_count(
db: Session, clean_tables
) -> None:
"""Under ``only_missing=True`` ``total`` is the MISSING count (the
loop-start count after the missing filter, not the full candidate
count) and the events name exactly the missing keys, sorted."""
_seed_catalogue(db)
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
db.commit()
db.execute(
text(
"DELETE FROM folder_summaries"
" WHERE (source, folder_path) IN (('FSU', ''), ('FSU', 'a/b'))"
)
)
db.commit()
llm = _FakeLLM()
events, record = _record_progress()
stats = asyncio.run(
generate_folder_summaries(db, llm, only_missing=True, on_progress=record)
)
assert events == [
(1, 2, "FSU", ""),
(2, 2, "FSU", "a/b"),
]
assert llm.calls == 2
assert stats["generated"] == 2
def test_on_progress_skip_true_fires_zero_calls(db: Session, clean_tables) -> None:
"""``skip=True`` (the ``--limit`` debug run) returns before the
loop — the hook never fires."""
_seed_catalogue(db)
llm = _FakeLLM()
events, record = _record_progress()
stats = asyncio.run(generate_folder_summaries(db, llm, skip=True, on_progress=record))
assert events == []
assert llm.calls == 0
assert stats == {"generated": 0, "failed": 0, "pruned": 0, "kept_manual": 0}
def test_on_progress_none_is_a_zero_cost_noop(db: Session, clean_tables) -> None:
"""``on_progress=None`` (the ``scripts/import_docs.py`` CLI path)
behaves byte-identically to today: the fake LLM's call log, the
stats dict, and the stored rows are identical across a hooked and
a hook-less run."""
_seed_catalogue(db)
hooked = _FakeLLM()
events, record = _record_progress()
stats_hooked = asyncio.run(
generate_folder_summaries(db, hooked, on_progress=record)
)
requests_hooked = hooked.requests.copy()
assert len(events) == 4, "the hooked run fired (the contrast is real)"
db.rollback() # the generator only flushes — drop the uncommitted rows
plain = _FakeLLM()
stats_plain = asyncio.run(generate_folder_summaries(db, plain, on_progress=None))
assert plain.requests == requests_hooked, "the fake LLM's call log is unchanged"
assert stats_plain == stats_hooked
assert _rows(db) == {
("FSU", ""): REPLY,
("FSU", "a"): REPLY,
("FSU", "a/b"): REPLY,
("FSU-solo", ""): REPLY,
}
# ---------- generate_folder_summaries(changed_docs=…) (phase 127, task 02) ----------
def _seed_three_sources(db: Session) -> None:
"""The restricted-mode catalogue (three sources): A is nested
(``a/`` and ``a/b/``), B is a sibling single-folder source, and C
is a single-doc root — six candidate keys total: A ×
{``""``, ``a``, ``a/b``}, B × {``""``, ``b``}, C × {``""``}.
"""
_add_doc(db, "A", "a/b/one.md", "A One", "A one lead.")
_add_doc(db, "A", "a/b/two.md", "A Two")
_add_doc(db, "A", "a/three.md", "A Three")
_add_doc(db, "B", "b/one.md", "B One")
_add_doc(db, "C", "solo.md", "C Solo")
def _stamps(
db: Session, keys: set[tuple[str, str]]
) -> dict[tuple[str, str], object]:
"""The stored rows' ``updated_at`` for *keys* (raw SQL — the
byte-identity comparisons stay honest)."""
return {
(source, folder): _updated_at(db, source, folder) for source, folder in keys
}
def _freeze(
db: Session, values: dict[tuple[str, str], str]
) -> dict[tuple[str, str], object]:
"""Overwrite the stored summaries for *values*' keys with DISTINCT
frozen text (a "regenerated" row is then distinguishable from an
"untouched" one), and return the pre-run stamps."""
for (source, folder_path), value in values.items():
db.execute(
text(
"UPDATE folder_summaries SET summary = :v"
" WHERE source = :s AND folder_path = :f"
),
{"v": value, "s": source, "f": folder_path},
)
db.commit()
return _stamps(db, set(values))
def test_changed_docs_restricted_regeneration_only_affected_keys(
db: Session, clean_tables
) -> None:
"""One changed file in A/``a/b/`` → exactly that file's folder
chain (``a/b``, ``a``, and the A root) regenerates: one lite call
per affected non-manual key, in sorted order, fresh rows. Every
OTHER stored row — same-source siblings and other sources — keeps
its summary text AND ``updated_at`` byte-identical (zero lite
calls for them)."""
_seed_three_sources(db)
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
db.commit()
frozen = {
("A", ""): "A root frozen",
("A", "a"): "A/a frozen",
("A", "a/b"): "A/a/b frozen",
("B", ""): "B root frozen",
("B", "b"): "B/b frozen",
("C", ""): "C root frozen",
}
stamps = _freeze(db, frozen)
llm = _FakeLLM()
stats = asyncio.run(
generate_folder_summaries(db, llm, changed_docs=[("A", "a/b/one.md")])
)
assert stats == {"generated": 3, "failed": 0, "pruned": 0, "kept_manual": 0}
assert llm.calls == 3, "one lite call per AFFECTED non-manual key — nothing else"
assert [user.splitlines()[0] for _s, user in llm.requests] == [
"Folder: A",
"Folder: A/a",
"Folder: A/a/b",
], "the affected chain in sorted (source, folder_path) order"
stored = _rows(db)
assert stored[("A", "")] == REPLY
assert stored[("A", "a")] == REPLY
assert stored[("A", "a/b")] == REPLY, "the changed file's own folder regenerates"
assert stored[("B", "")] == "B root frozen"
assert stored[("B", "b")] == "B/b frozen"
assert stored[("C", "")] == "C root frozen", (
"the unaffected rows keep their stored text"
)
for key in (("B", ""), ("B", "b"), ("C", "")):
assert _updated_at(db, *key) == stamps[key], (
"the unaffected rows are never re-stamped"
)
def test_changed_docs_manual_affected_row_is_skipped(db: Session, clean_tables) -> None:
"""A manual row INSIDE the affected chain is skipped exactly as in
full mode (phase 97): ``kept_manual``, no lite call, text AND
``updated_at`` byte-identical — the restriction changes the key
set, not the manual-row rule."""
_seed_three_sources(db)
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
db.commit()
manual_text = "Owner's words about A/a."
row = db.get(FolderSummary, ("A", "a"))
assert row is not None
row.summary = manual_text
row.manually_edited = True
db.commit()
stamp_before = _updated_at(db, "A", "a")
llm = _FakeLLM()
stats = asyncio.run(
generate_folder_summaries(db, llm, changed_docs=[("A", "a/b/one.md")])
)
assert stats == {"generated": 2, "failed": 0, "pruned": 0, "kept_manual": 1}
assert llm.calls == 2, "the manual key burns zero lite calls"
assert [user.splitlines()[0] for _s, user in llm.requests] == [
"Folder: A",
"Folder: A/a/b",
]
stored = _rows(db)
assert stored[("A", "a")] == manual_text, "the owner's text is never rewritten"
assert _updated_at(db, "A", "a") == stamp_before, ("never re-stamped")
assert stored[("A", "")] == REPLY and stored[("A", "a/b")] == REPLY
assert stored[("B", "")] == REPLY and stored[("B", "b")] == REPLY
assert stored[("C", "")] == REPLY, "the unaffected rows are left as they were"
def test_changed_docs_failing_affected_folder_fails_soft(db: Session, clean_tables) -> None:
"""Per-folder fail-soft under the restriction: the failing
affected key is attempted and counted, its PREVIOUS row is kept
(an old summary is better than none), the other affected keys
still land, and the unaffected rows are untouched."""
_seed_three_sources(db)
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
db.commit()
# A DISTINCT previous text on the failing folder, so "kept" is
# visible (it would otherwise be the REPLY either way).
db.execute(
text(
"UPDATE folder_summaries SET summary = 'old A/a/b'"
" WHERE (source, folder_path) = ('A', 'a/b')"
)
)
db.commit()
stamps = _stamps(db, {("B", ""), ("B", "b"), ("C", "")})
llm = _FakeLLM(fail_folders=("A/a/b",))
stats = asyncio.run(
generate_folder_summaries(db, llm, changed_docs=[("A", "a/b/one.md")])
)
assert stats == {"generated": 2, "failed": 1, "pruned": 0, "kept_manual": 0}
assert llm.calls == 3, "the failing key is attempted (the hook's premise)"
stored = _rows(db)
assert stored[("A", "a/b")] == "old A/a/b", "the previous row survives"
assert stored[("A", "")] == REPLY and stored[("A", "a")] == REPLY
for key, stamp in stamps.items():
assert _updated_at(db, *key) == stamp, "the unaffected rows are untouched"
def test_changed_docs_multi_source_change_set(db: Session, clean_tables) -> None:
"""A change set spanning SOURCES: the affected keys are the UNION
of each document's chain (A's nested chain + B's root and ``b``),
sorted across sources; the unaffected C root row keeps its text
AND ``updated_at``."""
_seed_three_sources(db)
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
db.commit()
c_stamp = _updated_at(db, "C", "")
llm = _FakeLLM()
stats = asyncio.run(
generate_folder_summaries(
db, llm, changed_docs=[("A", "a/b/two.md"), ("B", "b/one.md")]
)
)
assert stats == {"generated": 5, "failed": 0, "pruned": 0, "kept_manual": 0}
assert llm.calls == 5
assert [user.splitlines()[0] for _s, user in llm.requests] == [
"Folder: A",
"Folder: A/a",
"Folder: A/a/b",
"Folder: B",
"Folder: B/b",
]
stored = _rows(db)
assert stored[("C", "")] == REPLY
assert _updated_at(db, "C", "") == c_stamp
def test_changed_docs_gap_in_affected_folder_is_generated(
db: Session, clean_tables
) -> None:
"""An affected folder with NO stored row (a gap in the chain) is a
candidate and gets generated like any other affected key — while a
gap in an UNAFFECTED folder stays the unchanged-walk gap probe's
job (phase 96): the restriction never widens beyond the affected
set."""
_seed_three_sources(db)
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
db.commit()
db.execute(
text(
"DELETE FROM folder_summaries"
" WHERE (source, folder_path) IN (('A', 'a/b'), ('B', ''))"
)
)
db.commit()
llm = _FakeLLM()
stats = asyncio.run(
generate_folder_summaries(db, llm, changed_docs=[("A", "a/b/one.md")])
)
assert stats == {"generated": 3, "failed": 0, "pruned": 0, "kept_manual": 0}
assert llm.calls == 3
stored = _rows(db)
assert stored[("A", "a/b")] == REPLY, "the gap in the affected chain is filled"
assert ("B", "") not in stored, (
"the gap in the UNAFFECTED source is left for the gap probe"
)
assert stored[("B", "b")] == REPLY
def test_changed_docs_vanished_affected_folder_is_pruned_not_generated(
db: Session, clean_tables
) -> None:
"""The ASSUMPTION pinned: an affected folder that no longer holds
any document is a PRUNE-pass job, not a generation's — the pruned
document's chain (root + its now-vanished folder) is in the change
set, only the surviving candidate (the root) generates, the
vanished folder's row is dropped, and the unaffected rows keep
their ``updated_at``."""
_add_doc(db, "A", "a/b/one.md", "A One")
_add_doc(db, "A", "a/b/two.md", "A Two")
_add_doc(db, "A", "gone/old.md", "Gone")
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
db.commit()
stamps = _stamps(db, {("A", "a"), ("A", "a/b")})
db.execute(text("DELETE FROM documents WHERE path = 'gone/old.md'"))
db.commit()
llm = _FakeLLM()
stats = asyncio.run(
generate_folder_summaries(db, llm, changed_docs=[("A", "gone/old.md")])
)
# affected = {("A", ""), ("A", "gone")} — the only surviving
# candidate in that set is the source root.
assert stats == {"generated": 1, "failed": 0, "pruned": 1, "kept_manual": 0}
assert llm.calls == 1
assert [user.splitlines()[0] for _s, user in llm.requests] == ["Folder: A"]
stored = _rows(db)
assert ("A", "gone") not in stored, "the vanished folder's row is pruned"
assert stored[("A", "")] == REPLY, "the root's subtree changed — regenerated"
assert stored[("A", "a")] == REPLY and stored[("A", "a/b")] == REPLY
for key, stamp in stamps.items():
assert _updated_at(db, *key) == stamp, "the unaffected rows are untouched"
def test_changed_docs_hook_total_is_the_affected_count(
db: Session, clean_tables
) -> None:
"""The progress hook (phase 98) under the restriction fires exactly
``len(affected)`` times with ``total == len(affected)`` — the sync
status's ``summaries_total`` then reports the affected count
through the existing hook — in sorted key order."""
_seed_three_sources(db)
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
db.commit()
llm = _FakeLLM()
events, record = _record_progress()
stats = asyncio.run(
generate_folder_summaries(
db, llm, changed_docs=[("A", "a/b/one.md")], on_progress=record
)
)
assert events == [
(1, 3, "A", ""),
(2, 3, "A", "a"),
(3, 3, "A", "a/b"),
]
assert llm.calls == 3
assert stats["generated"] == 3
def test_changed_docs_none_is_the_pre_127_full_regeneration(
db: Session, clean_tables
) -> None:
"""The default (no ``changed_docs`` kwarg) is the pre-127 FULL
regeneration over the same three-source catalogue: every
candidate key, one call each, the 4-field stats shape unchanged —
the ``scripts/import_docs.py`` CLI contract."""
_seed_three_sources(db)
llm = _FakeLLM()
stats = asyncio.run(generate_folder_summaries(db, llm))
assert stats == {"generated": 6, "failed": 0, "pruned": 0, "kept_manual": 0}
assert llm.calls == 6, "no kwarg → every candidate burns one call"
assert set(_rows(db)) == {
("A", ""), ("A", "a"), ("A", "a/b"),
("B", ""), ("B", "b"),
("C", ""),
}
def test_changed_docs_empty_set_generates_nothing_but_still_prunes(
db: Session, clean_tables
) -> None:
"""``changed_docs=[]`` (a change set that IS given but empty)
restricts the generation to zero keys — no lite calls, no
re-stamps, the hook never fires — while the prune pass still runs
in the given mode (the sweep is mode-independent, phase 97)."""
_seed_three_sources(db)
asyncio.run(generate_folder_summaries(db, _FakeLLM()))
db.commit()
db.add(FolderSummary(source="A", folder_path="gone/old", summary="stale"))
db.commit()
stamps = _stamps(db, {("A", ""), ("A", "a"), ("A", "a/b"), ("B", ""), ("C", "")})
llm = _FakeLLM()
events, record = _record_progress()
stats = asyncio.run(
generate_folder_summaries(db, llm, changed_docs=[], on_progress=record)
)
assert stats == {"generated": 0, "failed": 0, "pruned": 1, "kept_manual": 0}
assert llm.calls == 0
assert events == [], "zero keys → the hook never fires"
for key, stamp in stamps.items():
assert _updated_at(db, *key) == stamp, "no row is re-stamped"