phase: 155_folder_summary_voice
Build and Push Containers / build-and-push-app (push) Successful in 2m25s
Build and Push Containers / build-and-push-db (push) Successful in 13s

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`.
This commit is contained in:
2026-10-04 16:32:50 -04:00
parent c275735db4
commit 742eef955b
37 changed files with 2396 additions and 46 deletions
@@ -0,0 +1,27 @@
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`.
@@ -0,0 +1,121 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
..................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 443 0 100%
--------------------------------------------------
TOTAL 5719 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1 @@
(no final assistant message — see the .err log)
@@ -0,0 +1,121 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 99%]
....................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 443 0 100%
--------------------------------------------------
TOTAL 5719 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,21 @@
Everything is green. Final verification summary of the complete state:
- **Full suite** `uv run pytest --cov=app --cov-report=term-missing`: 3554 passed, 0 failed (exit 0), coverage 99% (>90% gate); `app/rag/folder_summaries.py` 100%
- **Lint** `uv run ruff check .` + `uv run pyright`: clean (0 errors, 0 warnings)
- **E2E spot-check** `uv run pytest tests/e2e/test_ls_tree_drilldown.py -v --no-cov` (isolated, DB up): 3 passed — confirms the mock's new canned text and the updated test constants agree end-to-end through a real sync + drill-down
## Report
**Implemented (task 01 — the instruction nudge):**
- `app/rag/folder_summaries.py`: `FOLDER_SUMMARY_INSTRUCTION` gains the A1 variety direction — kept all four constraints (1–3 sentence / plain-text / no-markdown / no-invention), added "Open in a varied, natural way — state what the folder is for… Do NOT begin with 'This folder contains' or 'The <folder name> folder contains'" (exact A1 wording); module docstring names the nudge citing phase 155; `FOLDER_SUMMARY_MODE` + `FOLDER_HEADER_PREFIX` untouched (byte-identical)
- `tests/e2e/mock_llm.py`: canned folder reply now `"Documentation for <folder>."` (natural opener naming the folder, never a forbidden opener; still a pure function of the request — same header parse); all three docstring references updated; other markers untouched
- Unit pins added in `tests/unit/test_folder_summaries.py`: the instruction pin (both forbidden openers + nudge + four preserved constraint words), the marker/header byte-identity pin, and the mock canned-reply shape test (names the folder, avoids both forbidden openers, identical request → identical reply)
- Updated the 6 E2E suites that pinned the old canned text (ls_tree_drilldown, kb_tree, kb_tree_nav, incremental_folder_summaries, sync_summary_visibility, oneshot_llm_retry) — constants + docstrings
**Results:** full suite 3554 passed / 0 failed, coverage 99% on `app/`; ruff + pyright clean; unit green; `test_ls_tree_drilldown.py` green in isolation.
**Docs:** no doc impact — README/AGENTS.md describe folder-summary behavior only (no instruction or mock text mentioned); the user-visible regen script is task 02.
**Notable:** the previous attempt left zero working-tree changes (child died before its final report) — this run redid the task cleanly. Pre-existing uncommitted `TODO.md` edit left untouched (not this task's work; harness owns staging).
**Next pending task:** `02_regen_script.md` (the `scripts/regen_folder_summaries.py` full-mode regen entrypoint).
@@ -0,0 +1,121 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 99%]
.......................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 443 0 100%
--------------------------------------------------
TOTAL 5719 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,20 @@
All criteria verified. Task 02 is complete.
**Report**
**Implemented**
- `scripts/regen_folder_summaries.py` — one-shot full regeneration (`generate_folder_summaries(session, llm)` with the FULL-mode defaults, no kwargs), mirroring `import_docs.py` exactly: `build_parser()` with `--limit N` (parity flag, named on the summary line — the pinned generator has no limit parameter, so it's the log token, honestly documented in help + docstring), `main(argv) -> int`, `get_settings()` + `configure_logging()` + `configure_debugging()` (phase-8 idiom) + `ensure_admin_configured()` (the app's fail-loud boot gate), short-lived `SessionLocal` that commits (phase-53 — failed commit rolls back), single cron-safe line on the logger `regen_folder_summaries: folders=… failed=… pruned=… kept_manual=… limit=…`, exit 0 / named stderr line on boot or DB failure
- Unit (8) + integration (3) tests: full-mode no-kwargs call contract, stats on the summary line, `--limit` naming, boot failure (admin gate + settings validator) named + non-zero with nothing running, commit-outage fail-loud; live-DB full regen upserts every candidate row (text + `updated_at` advance, no prune), manual row kept byte-identical + `kept_manual=1`, full `lite` failure fail-soft with exit 0
**Verification**
- `uv run pytest` → 3565 passed, 0 failed; `uv run pytest --cov=app` → app 99% (gate >90%); script coverage 98% (gate >90%); `uv run ruff check .` clean; `uv run pyright` clean
- Live run: `uv run python -m scripts.regen_folder_summaries` → exit 0, summary line logged; `DEBUGPY=1 …` → listener honored (warning line + exit 0); fixture DB restored to its pre-run state
- `app/api/sync.py` + `app/rag/importer.py` untouched (scoped `git status` check)
**Docs**
- README: `regen_folder_summaries` added to the "Updating the documents" workflow block + a bullet in the folder-summary note; no AGENTS.md impact
**Notable**
- The dev aipi key only has the `embed` model (403 on `lite`), so the live `lite` voice couldn't be burned; the full-regen semantics are pinned by the green integration suite against the same fixture DB. `--limit` is a parity/naming token (the task fixes the body to the bare full-mode call and forbids new API surface).
**Next pending task:** `03_unit_pins.md` (phase 155)
@@ -0,0 +1,121 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
..................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 443 0 100%
--------------------------------------------------
TOTAL 5719 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,23 @@
All verification is complete. The empty `.err` stub is the harness's own report placeholder. Final state of the audit:
**Task 03 audit — all required pins are in place and green:**
- **Instruction pin** — `test_instruction_pins_the_variety_nudge_and_preserved_constraints` (tests/unit/test_folder_summaries.py): the two forbidden openers present as the explicit NOT constraint, all four preserved constraint words (1-3 sentence, plain-text, natural language, no markdown, no invention) asserted.
- **Mock-contract pin** — `test_marker_and_header_prefix_stay_byte_identical`: asserts the verbatim pre-phase strings (`FOLDER_SUMMARY_MODE == "FOLDER_SUMMARY_MODE"`, `FOLDER_HEADER_PREFIX == "Folder: "`, marker-first `SYSTEM_PROMPT` shape).
- **Mock canned-reply pins** — the mock's folder-branch section of test_folder_summaries.py (no dedicated `test_mock_llm.py` exists in the project — this is the mock's suite per the phase-94 precedent): reply names the folder from the `Folder: …` header, avoids both forbidden openers, two identical requests → identical reply (determinism), different folder → different header-keyed reply, plus the `SUMMARY_MODE`-substring shadowing regression pin.
- **Script wiring pins** — tests/unit/test_regen_folder_summaries.py (beside the import_docs pins, mirroring that idiom): full-mode no-kwargs call contract (the `changed_docs=None`/`only_missing=False`/`skip=False` defaults), stats dict on the single cron-safe summary line, `--limit` passthrough, `configure_debugging` call, fail-loud boot (missing `BOR_ADMIN_PASSWORD` + settings validation error named, nothing runs), commit-outage fail-loud (session closes, no summary line).
- Integration pin (phase-127-untouched full mode): tests/integration/test_regen_folder_summaries.py — every candidate row upserted, `updated_at` advances, `pruned=0`, manual row byte-identical.
**No code changes were needed** — the task 01/02 executors delivered these exact pins (their Testing & Quality sections mandate them); I verified each against the task 03 spec and kept additions to none ("additions only").
**Gate results (all green):**
- `uv run pytest tests/unit -q` → 2802 tests, exit 0
- `uv run pytest --cov=app --cov-report=term-missing` → **3565 passed, 0 failed** (unit + integration, full suite)
- Coverage: `app/` **99%** (>90% gate), `app/rag/folder_summaries.py` **100%**, `scripts/regen_folder_summaries.py` **98%**
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings
**Docs:** no doc impact — README already documents `uv run python -m scripts.regen_folder_summaries` beside `import_docs` (task 02); no stale voice description in README.md/AGENTS.md; the working tree's `TODO.md` modification is a pre-existing owner note, untouched.
**Decisions:** verified the pins live in test_folder_summaries.py's mock section rather than a new `test_mock_llm.py` (none exists; the folder suite is the mock's suite per phase-94 precedent — no redundant duplication).
**Next pending task:** `.agents/phases/todo/155_folder_summary_voice/04_e2e_folder_voice.md`
@@ -0,0 +1,121 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
..................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 443 0 100%
--------------------------------------------------
TOTAL 5719 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,18 @@
Done — all gates green. Final report:
**Implemented**
- `tests/e2e/test_folder_summary_voice.py` — dedicated phase-155 suite (mock-only, per-module app on port 8158, phase-127/97 fixture idiom: local-dir source `voice/` with `alpha/` + `bravo/` folders + root doc, autouse truncate + sync-settled cleanup):
- `test_regen_summary_avoids_forbidden_openers`: change-gated sync (phase-127 path) → stored `alpha` row == exact mock canned string, no forbidden opener (shape pin, 3 prefix forms), source-root row likewise
- `test_rag_table_renders_the_stored_voice`: real form login → RAG view renders the stored voice in Description cells (`td:nth-child(4) span` text + `title` per the phase-99 clamp idiom), top-level + drilled folder rows + `#kb-level-summary`
- `test_unchanged_folders_keep_their_rows`: phase-127 regression guard — `bravo` row byte-identical (summary + `updated_at`), alpha+root re-stamped
**Test/lint/coverage**
- `uv run pytest tests/e2e/test_folder_summary_voice.py -v --no-cov` → **3 passed** (run twice, isolated, DB up)
- `uv run pytest --cov=app --cov-report=term-missing` → **3565 passed**, `app/` coverage **99%** (>90% gate)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
**Docs** — no doc impact from this task (task 02 already added `regen_folder_summaries` to the README workflow list; verified present).
**Decisions** — KB changes are content revisions of existing fixture files (no add/remove) so each test's syncs are deltas against its own truncated KB, making counts independent of the persisted temp tree's state; port 8158 (free, env-overridable `E2E_APP_PORT_FVOICE`).
**Next pending task** — none in `155_folder_summary_voice` (phase complete); next phase: `156_table_ux_polish`.
@@ -0,0 +1,121 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
..................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 443 0 100%
--------------------------------------------------
TOTAL 5719 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
+7 -1
View File
@@ -245,7 +245,13 @@ reflect the new files.
- A run that changed the KB also regenerates the **KB overview** and the
**folder summaries** (the drill-down catalog's stored per-folder
descriptions) with the `lite` model — best-effort: a summary failure
never fails the import.
never fails the import. Because the sync only rewrites the folders a
change touches, a one-shot
`uv run python -m scripts.regen_folder_summaries` refreshes EVERY
stored folder summary in one run (full mode: every candidate folder
is regenerated, owner-edited rows are kept, one `lite` call per
folder, one cron-safe summary line; exit 0, non-zero with a named
stderr line on a boot/DB failure).
### Git-based sources
+19 -7
View File
@@ -70,6 +70,12 @@ touches — each document's folder chain up to the source root
stored row keeps its text AND ``updated_at``. The
``scripts/import_docs.py`` CLI keeps the byte-identical full
regeneration (``changed_docs=None``).
Voice (phase 155, task 01): the locked instruction carries a variety
nudge — open in a varied, natural way, never the formulaic
"This folder contains…" catalogue (the two forbidden openers are the
pin; :data:`FOLDER_SUMMARY_MODE` / :data:`FOLDER_HEADER_PREFIX` stay
byte-identical — the mock's keys).
"""
from __future__ import annotations
@@ -93,17 +99,23 @@ logger = logging.getLogger("app.rag.folder_summaries")
#: ``KB_OVERVIEW_MODE``, PLAN §6).
FOLDER_SUMMARY_MODE = "FOLDER_SUMMARY_MODE"
#: Locked instruction for the ``lite`` model (phase 94): the folder
#: summary is the drill-down ``ls``'s per-level picture, so it must be
#: a short plain-text description of what the folder's documents
#: cover — 1–3 sentences (a folder is a skim, not a read: the document
#: summary's 3–6 sentences would blow up a 20-folder listing),
#: strictly grounded in the listed titles/paths/summary lines.
#: Locked instruction for the ``lite`` model (phase 94, voice nudge
#: phase 155 task 01): the folder summary is the drill-down ``ls``'s
#: per-level picture, so it must be a short plain-text description of
#: what the folder's documents cover — 1–3 sentences (a folder is a
#: skim, not a read: the document summary's 3–6 sentences would blow
#: up a 20-folder listing), strictly grounded in the listed
#: titles/paths/summary lines — and it must OPEN in a varied, natural
#: way: the formulaic "This folder contains…" catalogue is banned (the
#: two forbidden openers below are the pin).
FOLDER_SUMMARY_INSTRUCTION = (
"From the document list below, write a 1-3 sentence plain-text "
"summary of what this folder's documents cover, in natural "
"language. Do not use markdown. Do not invent anything that is not "
"in the list."
"in the list. Open in a varied, natural way — state what the "
"folder is for, the way a person would describe it. Do NOT begin "
"with 'This folder contains' or 'The <folder name> folder "
"contains'."
)
#: Full system prompt: marker first (the mock's key), then the
+165
View File
@@ -0,0 +1,165 @@
"""One-shot full regeneration of the stored folder summaries (phase 155).
The phase-155 nudge (task 01) re-voices the ``lite`` folder-summary
instruction — no more "This folder contains…" catalogue — but the sync
path is change-gated (phase 127): it rewrites only the folders a KB
change touches and NEVER re-touches unchanged folders. The live
catalogue's 15+ source-root rows that still carry the formulaic opener
can therefore only be refreshed by this one-shot, which runs the
generator's FULL mode (``changed_docs=None`` — the
``scripts/import_docs.py`` CLI's default mode) with
``only_missing=False``: EVERY stored row is regenerated against the
new instruction in one run, not just the gaps::
uv run python -m scripts.regen_folder_summaries
The entrypoint mirrors ``scripts/import_docs.py`` exactly (the
house entrypoint idiom — one event loop, one ``LLMClient``):
* ``get_settings()`` + ``configure_logging(settings.log_level)`` +
``configure_debugging()`` (the phase-8 debug idiom: ``DEBUGPY=1``
honored, default off — AGENTS.md rule 7) + ``ensure_admin_configured
(settings)`` — the app's fail-loud boot gate (phase 16): a settings
validation error or a missing ``BOR_ADMIN_PASSWORD`` /
``BOR_SESSION_SECRET`` exits non-zero NAMING the failure, never a
silent partial.
* ``llm = LLMClient()`` — the aipi ``lite`` model (no new model
management — the ``KB_OVERVIEW_MODE`` / ``FOLDER_SUMMARY_MODE``
contract, PLAN §6).
* A short-lived ``SessionLocal()``: the generator only flushes — THIS
run's own session commits (the phase-53 convention), so a failed
commit rolls the whole regeneration back with it (never a silent
partial).
* One cron-safe summary line (PLAN §9) on the logger, built from the
generator's stats dict: ``regen_folder_summaries: folders=<generated>
failed=<failed> pruned=<pruned> kept_manual=<kept_manual>
limit=<n|none>``.
* ``--limit N`` (parity with ``import_docs``'s parser — the same debug
passthrough, the summary line names it): the generator's pinned
signature (phase 127) has no limit parameter, so the flag does NOT
cap the regeneration — it is the log token that names the debug run
on the summary line (``limit=<n>``; ``limit=none`` otherwise).
Per-folder fail-soft is the generator's (phase 94): one folder's
``lite`` failure keeps its previous row and counts ``failed`` — a
partial failure never flips the exit code (an old summary is better
than none). The exit code is about the RUN: 0 on success; non-zero
with a named stderr line on a boot failure (settings validation, the
admin gate) or a DB/commit outage.
NO new API surface, NO schema change, NO change to the sync path's
change-gated behavior (phase 127's contract is untouched — this script
is a consumer of the ``changed_docs=None`` full mode only).
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import sys
from app.config import get_settings
from app.core.auth import ensure_admin_configured
from app.core.debugging import configure_debugging
from app.core.logging import configure_logging
from app.db import SessionLocal
from app.rag.folder_summaries import generate_folder_summaries
from app.rag.llm import LLMClient
logger = logging.getLogger("scripts.regen_folder_summaries")
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="python -m scripts.regen_folder_summaries",
description=(
"Regenerate EVERY stored folder summary in one run (full "
"mode — changed_docs=None, only_missing=False, skip=False: "
"the phase-155 one-shot that re-voices the whole catalogue "
"against the varied-opener instruction; the phase-127 "
"change-gated sync never re-touches unchanged folders, so "
"this is the only way to refresh them)."
),
)
p.add_argument(
"--limit",
type=int,
default=None,
metavar="N",
help=(
"debug passthrough (parity with scripts/import_docs.py's "
"--limit; the summary line names it: limit=N) — the "
"full-mode generator has no limit parameter, so the "
"regeneration itself is not capped"
),
)
return p
async def _run(llm: LLMClient) -> dict[str, int]:
"""The full-mode regeneration in one short-lived session.
``generate_folder_summaries(session, llm)`` with NO kwargs — the
FULL-mode defaults (``skip=False``, ``only_missing=False``,
``changed_docs=None``): every candidate folder is regenerated, the
``scripts/import_docs.py`` CLI's mode. The generator only flushes;
this run's own short-lived session commits (the phase-53
convention), so a failed commit rolls the whole regeneration back
with it.
"""
session = SessionLocal()
try:
stats = await generate_folder_summaries(session, llm)
session.commit()
finally:
session.close()
return stats
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
# The fail-loud boot (phase 16's app validator idiom, mirrored from
# app/main.py): a settings validation error, or a missing
# BOR_ADMIN_PASSWORD / BOR_SESSION_SECRET, names the failure and
# exits non-zero — a regen that cannot boot must never run.
try:
settings = get_settings()
configure_logging(settings.log_level)
configure_debugging()
ensure_admin_configured(settings)
except Exception as e:
print(f"regen_folder_summaries: boot failed: {e}", file=sys.stderr)
return 1
try:
llm = LLMClient()
stats = asyncio.run(_run(llm))
except Exception as e:
# Fail-loud (never a silent partial): a DB outage or a failed
# commit propagates here (the short-lived session rolls the
# uncommitted regeneration back on close) — the failure is
# NAMED on stderr and the exit code is non-zero.
print(
f"regen_folder_summaries: regeneration failed: {e}", file=sys.stderr
)
return 1
# The single cron-safe summary line (PLAN §9): the stats dict, one
# greppable line. limit= names the --limit passthrough (parity with
# import_docs' parser — the full-mode generator has no limit
# parameter, so the token is the log marker, not a cap).
limit_token = "none" if args.limit is None else str(args.limit)
logger.info(
"regen_folder_summaries: folders=%d failed=%d pruned=%d "
"kept_manual=%d limit=%s",
stats["generated"],
stats["failed"],
stats["pruned"],
stats["kept_manual"],
limit_token,
)
return 0
if __name__ == "__main__":
sys.exit(main())
+12 -8
View File
@@ -19,7 +19,9 @@ Implements just enough of the aipi surface:
of the user message (the generator puts the document list there) —
byte-stable for a given KB (KB overview, phase 31)
- ``FOLDER_SUMMARY_MODE`` -> the deterministic folder one-liner
``Fixture folder summary for <folder>.`` — <folder> is the user
``Documentation for <folder>.`` (phase 155 task 01: the new voice
— a natural opener naming the folder, never the forbidden
"This folder contains…" openers) — <folder> is the user
message's ``Folder: …`` header line (the generator names the
source/folder there, phase 94), so the stored row always names its
folder and an E2E can assert on it. Checked BEFORE the
@@ -836,7 +838,7 @@ test_llm_retry.py``). The mock is single-conversation per e2e server, so
""`` and ``choices[0].finish_reason = "length"`` (the exact wire
shape of the empty ``lite`` reply: the budget spent in
``reasoning_content``) — and every later POST returns the normal
``Fixture folder summary for <folder>.`` line (the one-shot retry,
``Documentation for <folder>.`` line (the one-shot retry,
phase 96 task 01, recovers the row).
- the label ends with ``/e2e_empty_always``: EVERY non-stream POST
for that label answers the empty envelope (the 1 +
@@ -3337,14 +3339,16 @@ def compose_answer(body: dict[str, Any]) -> str:
if LONG_ANSWER_TRIGGER in user.lower():
answer = long_answer()
elif "FOLDER_SUMMARY_MODE" in system:
# Folder summary (phase 94, TODO.md L4): the ``lite`` stand-in
# returns the deterministic one-liner
# ``Fixture folder summary for <folder>.`` — <folder> is the
# Folder summary (phase 94, voice nudge phase 155 task 01):
# the ``lite`` stand-in returns the deterministic one-liner
# ``Documentation for <folder>.`` — <folder> is the
# user message's ``Folder: …`` header line (the generator puts
# the canonical source/folder identity there, imported as
# ``FOLDER_HEADER_PREFIX`` so the mock can never drift from it).
# The stored row therefore always names its folder — the drill-
# down E2E (``test_ls_tree_drilldown.py``) asserts on it.
# The stored row therefore always names its folder with a
# natural opener (never the forbidden "This folder contains…"
# openers — phase 155's pin) — the drill-down E2E
# (``test_ls_tree_drilldown.py``) asserts on it.
# Checked BEFORE the ``SUMMARY_MODE`` branch: the folder marker
# CONTAINS the summary marker as a substring (``FOLDER_`` +
# ``SUMMARY_MODE``), so the summary branch would otherwise
@@ -3363,7 +3367,7 @@ def compose_answer(body: dict[str, Any]) -> str:
if header.startswith(FOLDER_HEADER_PREFIX)
else "(unnamed folder)"
)
answer = f"Fixture folder summary for {folder}."
answer = f"Documentation for {folder}."
elif "SUMMARY_MODE" in system:
# Document summaries (phase 30): the ``lite`` stand-in returns a
# deterministic digest — the first 24 tokens of the user message
+697
View File
@@ -0,0 +1,697 @@
"""Phase 155 task 04 E2E (Playwright, mock-only): the new voice, end to end.
The dedicated phase suite for ``155_folder_summary_voice`` (A16 — one
Playwright file per phase, run in isolation): the owner's audit found
that every ``lite`` folder summary opened with the formulaic "This
folder contains…" catalogue. The fix under test, proven here end to
end: a KB change triggers the sync's folder regeneration (the
change-gated path, phase 127) under the deterministic mock, the
STORED ``folder_summaries`` row for the regenerated folder carries the
mock's new-voice canned text — ``Documentation for <folder>.`` (a
natural opener naming the folder, NEVER the two forbidden "This folder
contains" / "The <folder> folder contains" openers — phase 155's pin)
— and the RAG catalogue (the phase-97 view) renders that stored voice
in its Description cells.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_folder_summary_voice.py -v --no-cov
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the gate is the
deterministic mock (the ``FOLDER_SUMMARY_MODE`` branch's canned
``Documentation for <folder>.`` line, ``tests/e2e/mock_llm.py``): the
stored rows are a pure function of the request (the label is the
``Folder: …`` header tail — ``<source>`` for the source root,
``<source>/<folder>`` for a folder), so the exact-text and the
forbidden-opener assertions are deterministic only against the mock.
No slow-LLM proxy: the assertions are on the POST-terminal DB state
(the rows' ``summary`` AND ``updated_at``) plus the terminal status
(the keep-final-counts convention — the terminal keeps
``summaries_done == summaries_total == <affected count>``) and the
rendered RAG view, never on in-flight sampling, so the fast mock is
dense enough by construction.
KB fixture — one host temp dir (``tmp_path_factory``; the app runs on
the same host) registered as a ``kind=local`` source (the
``test_local_directory_sources.py`` / phase-94 API-registration
pattern; no git anywhere): ``voice/`` with TWO 2-doc folders —
``alpha/`` (the folder every test changes) and ``bravo/`` (the
unchanged sibling — the phase-127 regression guard) — plus ONE
root-level doc (5 documents total). The generator's candidate set is
therefore exactly 3: the source root (``folder_path ""``) + the two
folders (the recursive-subtree ≥ 1 rule).
Autouse cleanup (the phase-96/127 pattern): before each test wait for
no running sync on the module app, then truncate the shared registry +
KB tables (documents/chunks/query_log/steering_notes/kb_overview/
git_sources/folder_summaries) — each test seeds its own source and
runs its own syncs; after each test the same truncate. (The temp tree
itself persists for the module; the tests' disk mutations are content
revisions of existing fixture files — no file is ever added or
removed — so every test's syncs are deltas against its own truncated
KB and the counts below hold independently of the tree state the
previous test left behind.)
Test → observable mapping (Playwright Mapping Rule):
1. ``test_regen_summary_avoids_forbidden_openers`` — a first (full)
sync settles ``success``; ONE doc inside ``alpha/`` is content-
revised and the second sync (the change-gated path, phase 127)
settles ``success`` with the single-file detail and
``summaries_done == summaries_total == 2`` (the AFFECTED count —
alpha + the source root): the stored ``folder_summaries`` row for
``alpha`` EQUALS the mock's canned reply for that folder
(``Documentation for voice/alpha.`` — the exact string, a pure
function of the request) AND does not start with "This folder
contains" or "The voice/alpha folder contains" (the phase-155
shape pin — the regenerated voice, not the formulaic catalogue),
while the source-root row — the other affected row — likewise
carries its exact canned line.
2. ``test_rag_table_renders_the_stored_voice`` — the same
sync-#1 + alpha-revision + sync-#2 sequence (the regenerated
folder's row is re-stamped under the new instruction), then the
REAL admin login (the ``auth_helpers`` form pattern) opens the RAG
view: the top-level source row's Description cell renders the
stored source-root voice (the cell text AND its ``title`` — the
phase-99 one-line clamp keeps the FULL text in the DOM and on the
hover escape hatch, so the assertion holds whether or not the
display ellipsizes); drilling into the source, the ``alpha`` row's
Description cell renders ``Documentation for voice/alpha.`` (text +
``title``) and the ``bravo`` row its own canned line, and the
level block shows the stored source-root voice — the stored voice
is what the catalogue renders, end to end.
3. ``test_unchanged_folders_keep_their_rows`` (the phase-127
regression guard) — sync #1 (full), capturing every row's
``(summary, manually_edited, updated_at)``; ONE doc inside
``alpha/`` is content-revised and sync #2 settles ``success`` with
``summaries_done == summaries_total == 2`` (the AFFECTED count):
the ``bravo`` row is BYTE-IDENTICAL — same ``summary`` AND same
``updated_at`` (a KB change in folder A does not touch folder B's
stored row), while the ``alpha`` AND the source-root rows are
re-stamped (``updated_at`` strictly after the captured stamp, the
canned text, still AI-written) — asserted through the DB the way
the phase-127 suite does.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
from collections.abc import Iterator
from datetime import datetime
from pathlib import Path
from typing import Any
import httpx
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import select, text
from app.config import Settings as _Settings
from app.db import SessionLocal
from app.models import FolderSummary
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
REPO = Path(__file__).resolve().parents[2]
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module's app binds its own port instead
# (a same-port second uvicorn dies on bind and would drive the wrong
# server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_FVOICE", "8158"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
# --------------------------------------------------------------------------
# Fixture constants (deterministic, token-controlled)
# --------------------------------------------------------------------------
#: The local source — the temp directory's basename (``kind=local`` →
#: the directory's basename is the source name, phase 38).
SOURCE = "voice"
#: The folder every test changes (A) and the unchanged sibling (B —
#: the phase-127 regression guard's keeper).
FOLDER_A = "alpha"
FOLDER_B = "bravo"
ROOT_NOTE = "root-note.md" # the one root-level doc
N_FILES = 5 # two folders × 2 docs + the root-level doc
N_CANDIDATES = 3 # the source root + the two folders (the ≥ 1-doc rule)
#: The mock's byte-stable ``FOLDER_SUMMARY_MODE`` lines for this fixture
#: (the phase-94 template — the label is the ``FOLDER_HEADER_PREFIX``
#: tail: ``<source>`` for the root, ``<source>/<folder>`` for a folder;
#: the phase-155 voice: a natural opener naming the folder, never the
#: forbidden "This folder contains…" openers).
SUMMARY_FOR = "Documentation for {}."
EXPECTED: dict[str, str] = {
"": SUMMARY_FOR.format(SOURCE),
FOLDER_A: SUMMARY_FOR.format(f"{SOURCE}/{FOLDER_A}"),
FOLDER_B: SUMMARY_FOR.format(f"{SOURCE}/{FOLDER_B}"),
}
#: The phase-155 shape pin (test 1): the forbidden openers the new
#: instruction bans — the generic "This folder contains" opener and the
#: "The <folder name> folder contains" form with <folder name> bound to
#: the mock's own label (``voice/alpha``) and to the bare folder name
#: (``alpha``) — the stored voice must open with none of them.
FORBIDDEN_PREFIXES: tuple[str, ...] = (
"This folder contains",
f"The {SOURCE}/{FOLDER_A} folder contains",
f"The {FOLDER_A} folder contains",
)
def _forbidden_hits(summary: str) -> list[str]:
"""The forbidden openers the text starts with (``[]`` = the shape
pin holds — the summary opens in the varied voice)."""
return [p for p in FORBIDDEN_PREFIXES if summary.startswith(p)]
#: Test 1's single-file revision — a revised body whose hash differs
#: from the fixture's (the importer's sha256 delta reports ``updated``)
#: and which no other fixture text contains.
ALPHA_A_REVISED_1 = (
"Voice alpha fixture note A, REVISED-1: the only file test 1 changed "
"— the change-gated regeneration must re-voice exactly this file's "
"chain (alpha + the source root) under the new instruction. "
"(RESE-FVOICE-01)"
)
#: Test 2's single-file revision (a fresh distinctive body — the temp
#: tree persists across the module, so the second revision is a delta
#: against the state test 1's revision left behind).
ALPHA_A_REVISED_2 = (
"Voice alpha fixture note A, REVISED-2: the one changed file of "
"test 2's sync — the regenerated folder whose stored voice the RAG "
"catalogue must render. (RESE-FVOICE-02)"
)
#: Test 3's single-file revision (``alpha-b.md`` — a different file, so
#: the revision is a delta against test 3's own truncated KB).
ALPHA_B_REVISED_3 = (
"Voice alpha fixture note B, REVISED-3: the one changed file of "
"test 3's sync — bravo's row must stay byte-identical through it. "
"(RESE-FVOICE-03)"
)
def _md(title: str, body: str) -> str:
return f"# {title}\n\n{body}\n"
# --------------------------------------------------------------------------
# Fixtures
# --------------------------------------------------------------------------
@pytest.fixture(scope="module")
def fvoice_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""The one-source temp tree (see the module docstring): the app
server runs on the same host, so the paths are visible to it. The
directory NAME is the source name (``kind=local``, phase 38)."""
root = tmp_path_factory.mktemp("bor_fvoice")
src = root / SOURCE
for folder in (FOLDER_A, FOLDER_B):
(src / folder).mkdir(parents=True)
for letter in ("a", "b"):
(src / folder / f"{folder}-{letter}.md").write_text(
_md(
f"Voice {folder.title()} {letter.upper()}",
f"Voice {folder} fixture note {letter.upper()}: covers "
f"topic {letter.upper()} of the {SOURCE} source tree.",
),
encoding="utf-8",
)
(src / ROOT_NOTE).write_text(
_md(
"Voice Root Note",
f"This file sits directly under the {SOURCE} source, not in "
"any folder.",
),
encoding="utf-8",
)
assert len(list(src.rglob("*.md"))) == N_FILES
return src
def _app_env(
base_url: str, tmp_path_factory: pytest.TempPathFactory
) -> dict[str, str]:
"""The per-module app env (the conftest pattern, cf.
``test_incremental_folder_summaries.py``): the ``base_url`` is the
mock LLM directly (the fast leg is the only leg — see the module
docstring), ``BOR_GIT_SOURCES`` forced empty (the sync source is
this suite's own DB-registered local row), and the leak-guarded
code defaults."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1" if USE_REAL_LLM else base_url
)
# Mock-calibrated threshold (conftest pattern): this suite never
# asks the chat model anything — the gate is never on a path.
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
# Phase 67: instant retry waits + the code-default budget (the
# conftest leak-guard pattern).
env["BOR_LLM_RETRY_DELAY"] = "0"
env["BOR_LLM_RETRIES"] = str(_Settings.model_fields["llm_retries"].default)
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
# Phase 16: admin auth must be set or create_app() refuses to boot.
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
# The repo's .env file carries the owner's BOR_GIT_SOURCES (the app
# reads it from cwd) — override it with an EMPTY value (the env var
# beats the .env file): the registry must hold EXACTLY the local
# directory this suite registers.
env["BOR_GIT_SOURCES"] = ""
# Leak guards (conftest pattern): an operator's local (gitignored)
# .env cannot leak corpus-specific settings into the app under test.
env["BOR_DOCS_REPO"] = ""
env["BOR_SUGGESTIONS"] = json.dumps(
_Settings.model_fields["suggestions"].default
)
env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
env["BOR_SOURCES_DIR"] = str(tmp_path_factory.mktemp("bor_checkouts"))
return env
@pytest.fixture(scope="module")
def app_server(
mock_llm: int,
fvoice_dir: Path,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[str]:
"""The app under test (all three tests): ``BOR_LLM_BASE_URL`` is
the mock directly — the canned ``FOLDER_SUMMARY_MODE`` lines are
byte-stable there, and the exact-text and shape assertions key on
that exact text (no slow proxy: the assertions are post-terminal,
see the module docstring). ``fvoice_dir`` is a dependency only for
the fixture ordering (the temp tree exists before the app boots —
the sync reads it)."""
env = _app_env(f"http://127.0.0.1:{mock_llm}/v1", tmp_path_factory)
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
def _truncate_all() -> None:
"""Fresh registry + KB (the E2E isolation pattern): the E2E suites
share one Postgres, so a leftover git_sources row or document would
pollute the candidate count and the counts the syncs and the
folder-rows assertions pin exactly."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, steering_notes, "
"kb_overview, git_sources, folder_summaries"
)
)
db.commit() # without the commit the TRUNCATE rolls back (the house pattern)
def _wait_sync_settled(base_url: str, timeout_s: float = 180.0) -> None:
"""No background sync may leak across tests (the run state lives in
the app's memory, and a still-running run would keep importing into
the NEXT test's truncated KB): wait for the app's sync status to
be non-running BEFORE the truncate. Own admin session (the
endpoint is admin-only) — usually a no-op: the tests settle only
after their run's terminal state."""
with httpx.Client(base_url=base_url, timeout=5.0) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, r.text
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
r = client.get("/api/sync/status")
assert r.status_code == 200, r.text
if r.json()["state"] != "running":
return
time.sleep(0.2)
raise AssertionError(f"a sync was still running at a test boundary: {base_url}")
@pytest.fixture(autouse=True)
def _clean(app_url: str, db_ready: None) -> Iterator[None]:
"""Per-test KB isolation (the phase-96/127 cleanup pattern): the
shared E2E Postgres is truncated before AND after every test —
each test seeds its own source and runs its own syncs, so the KB
rows + folder_summaries rows of the temp source are exactly this
test's own. The pre-wait keeps a leaked run from the previous test
(the module app is shared) from racing the truncate."""
_wait_sync_settled(app_url)
_truncate_all()
yield
_truncate_all()
# --------------------------------------------------------------------------
# Helpers
# --------------------------------------------------------------------------
def _register_source(base_url: str, path: str) -> None:
"""Register the temp directory through the authenticated API (the
``test_local_directory_sources.py`` / phase-94 pattern — a plain
directory is an API/DB-only operation)."""
with httpx.Client(base_url=base_url, timeout=30.0) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, r.text
r = client.post(
"/api/git-sources", json={"kind": "local", "path": path}
)
assert r.status_code == 201, r.text
def _run_sync(base_url: str, timeout_s: float = 120.0) -> dict[str, Any]:
"""Login + ``POST /api/sync`` + poll the status endpoint until THIS
run reaches a terminal state (the ``test_local_directory_sources``
pattern, over plain httpx — with the stale-terminal freshness gate:
the module app is shared, so the PREVIOUS run's terminal lives in
the app's memory until this run supersedes it; a terminal is
accepted only after a running tick was observed or its
``started_at`` post-dates this POST)."""
with httpx.Client(base_url=base_url, timeout=30.0) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, r.text
t0 = time.time()
r = client.post("/api/sync")
assert r.status_code == 202, r.text
saw_running = False
deadline = time.monotonic() + timeout_s
body: dict[str, Any] = {}
while time.monotonic() < deadline:
r = client.get("/api/sync/status")
assert r.status_code == 200, r.text
body = r.json()
state = body["state"]
if state == "running":
saw_running = True
elif state in ("success", "failed") and (
saw_running
or _started_at(body) >= t0 - 1.0 # same-host clock; 1 s tolerance
):
return body
time.sleep(0.1)
raise AssertionError(f"sync did not reach a terminal state: {body}")
def _started_at(body: dict[str, Any]) -> float:
"""The status body's ``started_at`` as a wall-clock epoch (the
server stamps it in UTC; this process runs on the same host —
same clock, the phase-64 recorder's freshness convention)."""
started = body.get("started_at")
if not started:
return 0.0
try:
return datetime.fromisoformat(str(started)).timestamp()
except ValueError:
return 0.0
def _folder_rows() -> dict[str, tuple[str, bool, datetime]]:
"""The source's stored folder summaries
``{folder_path: (summary, manually_edited, updated_at)}``
(``""`` = the source root) — the test process's direct DB access
(the E2E's other established lens)."""
with SessionLocal() as db:
rows = db.execute(
select(
FolderSummary.folder_path,
FolderSummary.summary,
FolderSummary.manually_edited,
FolderSummary.updated_at,
).where(FolderSummary.source == SOURCE)
).all()
return {
folder: (summary, manually, updated)
for folder, summary, manually, updated in rows
}
def _first_sync(app_url: str, fvoice_dir: Path) -> dict[str, Any]:
"""The suite's precondition (every test): register the temp source
and run the first (full) sync under the deterministic mock —
settled ``success`` with all 5 docs added and the 3 candidate rows
generated (the phase-127 suite's sequence)."""
_register_source(app_url, str(fvoice_dir))
body = _run_sync(app_url)
assert body["state"] == "success", body
detail = body["detail"]
assert detail["added"] == N_FILES, detail
assert detail["updated"] == 0 and detail["unchanged"] == 0, detail
assert detail["pruned"] == 0, detail
assert detail["overview"] is True, detail
return body
def _revise_alpha_a(fvoice_dir: Path, body: str) -> None:
"""Test 1's / test 2's KB change: ONE doc inside ``alpha/`` gets
new content (the importer's sha256 delta reports ``updated`` — no
add, no prune)."""
target = fvoice_dir / FOLDER_A / f"{FOLDER_A}-a.md"
target.write_text(_md(f"Voice {FOLDER_A.title()} A (revised)", body), encoding="utf-8")
def _assert_change_terminal(body: dict[str, Any]) -> None:
"""The second sync's terminal (the keep-final-counts convention):
the AFFECTED count — alpha + the source root — the phase-127
observable at the wire, plus the single-file detail."""
assert body["state"] == "success", body
detail = body["detail"]
assert detail["added"] == 0, detail
assert detail["updated"] == 1, detail
assert detail["unchanged"] == N_FILES - 1, detail
assert detail["pruned"] == 0, detail
assert detail["overview"] is True, detail
assert body["summaries_done"] == 2, body
assert body["summaries_total"] == 2, body
assert body["phase"] is None, body
assert body["current_summary"] is None, body
def _drill(page: Page, *names: str) -> None:
"""Drill one level at a time (client-side — no fetch, no URL
change): each name is the EXACT text of the source/folder link at
the current level (the row builders' link text: the source name,
or the folder's last path segment)."""
for name in names:
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
def _expect_desc_cell(
page: Page, row_index: int, full_text: str
) -> None:
"""The RAG view's Description cell (the phase-97 catalogue idiom —
``td:nth-child(4)``; the phase-99 one-line clamp): the cell's text
span carries the STORED text (``textContent`` only — the house
rule; the clamp is VISUAL only, so the full text stays in the DOM)
and its ``title`` carries the FULL unclamped string (the hover
escape hatch, ``makeDescCell`` — set whenever the description is
non-empty, whether or not the display ellipsizes)."""
span = page.locator(f"#folders-tbody tr:nth-child({row_index + 1}) "
"td:nth-child(4) span")
expect(span).to_have_text(full_text)
expect(span).to_have_attribute("title", full_text)
# --------------------------------------------------------------------------
# 1. The change-gated regeneration (phase 127's path) stores the mock's
# new-voice canned text — exact match, and the shape pin: none of
# the forbidden openers
# --------------------------------------------------------------------------
def test_regen_summary_avoids_forbidden_openers(
app_url: str, fvoice_dir: Path, db_ready: None
) -> None:
"""A first sync of the temp source settles ``success`` with all 5
docs added; ONE doc inside ``alpha/`` is content-revised and the
second sync (the CHANGE-GATED path — phase 127: the run's changed
document set reaches ``generate_folder_summaries``) settles
``success`` with the single-file detail and
``summaries_done == summaries_total == 2`` (the AFFECTED count —
alpha + the source root): the stored ``folder_summaries`` row for
``alpha`` EQUALS the mock's canned reply for that folder (the
EXACT string — the mock's line is a pure function of the request,
so the match is byte-exact) AND does not start with "This folder
contains" or "The voice/alpha folder contains" (the phase-155
shape pin); the source-root row — the other affected row —
likewise carries its exact canned line."""
_first_sync(app_url, fvoice_dir)
_revise_alpha_a(fvoice_dir, ALPHA_A_REVISED_1)
body = _run_sync(app_url)
_assert_change_terminal(body)
rows = _folder_rows()
assert set(rows) == {""} | {FOLDER_A, FOLDER_B}, rows
# The regenerated folder: the EXACT canned string (deterministic) +
# the shape pin (no forbidden opener).
summary, manually, _updated_at = rows[FOLDER_A]
assert summary == EXPECTED[FOLDER_A], (FOLDER_A, summary)
assert _forbidden_hits(summary) == [], (FOLDER_A, summary)
assert manually is False, (FOLDER_A, manually)
# The other affected row (the source root) — the new voice there too.
assert rows[""][0] == EXPECTED[""], rows[""]
assert _forbidden_hits(rows[""][0]) == [], rows[""]
# The unaffected sibling keeps its canned line (phase 127 — full
# guard in test 3; here only the text, the shape pin's context).
assert rows[FOLDER_B][0] == EXPECTED[FOLDER_B], rows[FOLDER_B]
# --------------------------------------------------------------------------
# 2. The RAG view (the phase-97 catalogue) renders the stored voice in
# the Description cells — the exact canned text, title attribute
# carrying the full string
# --------------------------------------------------------------------------
def test_rag_table_renders_the_stored_voice(
page: Page,
app_url: str,
fvoice_dir: Path,
db_ready: None,
) -> None:
"""The same sync-#1 + alpha-revision + sync-#2 sequence (the
regenerated folder's row is re-stamped under the new instruction —
the DB state test 1 pins), then the REAL admin login opens the
RAG view (the phase-97 catalogue): the top-level source row's
Description cell renders the stored source-root voice (the cell
text AND its ``title`` — the phase-99 clamp keeps the full text in
the DOM and on the hover escape hatch, so the assertion holds
whether or not the display ellipsizes); drilling into the source,
the ``alpha`` row's Description cell renders the regenerated
folder's exact canned line (text + ``title``) and the ``bravo``
row its own canned line, and the level block shows the stored
source-root voice — the stored voice is what the catalogue
renders, end to end."""
page.set_default_timeout(30_000)
_first_sync(app_url, fvoice_dir)
_revise_alpha_a(fvoice_dir, ALPHA_A_REVISED_2)
_assert_change_terminal(_run_sync(app_url))
# The regenerated folder's DB state (the precondition the view
# renders — test 1's exact-text pin, re-asserted here so the view
# assertion stands on a verified store).
rows = _folder_rows()
assert rows[FOLDER_A][0] == EXPECTED[FOLDER_A], rows[FOLDER_A]
login(page, app_url) # → /sources.html (the RAG view)
# The top level: the one source row (the ls() equivalence — the
# tree's single fetch settled) and its stored source-root voice in
# the Description cell (the exact canned line, text + title).
page.wait_for_selector("#folders-tbody tr", timeout=30_000)
expect(page.locator("#folders-tbody tr")).to_have_count(1)
expect(page.locator("#folders-tbody tr td:nth-child(1) a.folder-link")
).to_have_text(SOURCE)
_expect_desc_cell(page, 0, EXPECTED[""])
# Drill into the source: the two folder rows (path order) and the
# regenerated folder's stored voice in its Description cell — the
# exact canned line (the DB state above is what the cell renders),
# plus the sibling's own canned line; the level block carries the
# source root's stored voice (the phase-97 ``#kb-level`` idiom).
_drill(page, SOURCE)
rows_visible = page.locator("#folders-tbody tr")
expect(rows_visible).to_have_count(2)
expect(rows_visible.nth(0).locator("a.folder-link")).to_have_text(FOLDER_A)
_expect_desc_cell(page, 0, EXPECTED[FOLDER_A])
expect(rows_visible.nth(1).locator("a.folder-link")).to_have_text(FOLDER_B)
_expect_desc_cell(page, 1, EXPECTED[FOLDER_B])
expect(page.locator("#kb-level")).to_be_visible()
expect(page.locator("#kb-level-summary")).to_have_text(EXPECTED[""])
# The rendered voice is the new one: no Description cell in the
# level shows a forbidden opener (the shape pin, view-side).
for cell in page.locator("#folders-tbody td:nth-child(4) span").all():
rendered = cell.text_content() or ""
for prefix in FORBIDDEN_PREFIXES:
assert not rendered.startswith(prefix), rendered
# --------------------------------------------------------------------------
# 3. The phase-127 regression guard: a KB change in folder A does not
# touch folder B's stored row (text AND updated_at byte-identical)
# --------------------------------------------------------------------------
def test_unchanged_folders_keep_their_rows(
app_url: str, fvoice_dir: Path, db_ready: None
) -> None:
"""Re-seed (the autouse truncate) + sync #1 (full), capturing every
row's ``(summary, manually_edited, updated_at)``; then ONE doc
inside ``alpha/`` is content-revised (``alpha-b.md`` — a different
file, so the revision is a delta against this test's own truncated
KB) and sync #2 settles ``success`` with the single-file detail
and ``summaries_done == summaries_total == 2`` (the AFFECTED
count — alpha + the source root): the ``bravo`` row is
BYTE-IDENTICAL — same ``summary`` AND same ``updated_at`` (a KB
change in folder A does not touch folder B's stored row — the
phase-127 contract), while the ``alpha`` AND the source-root rows
are re-stamped (``updated_at`` strictly after the captured stamp,
the canned text, still AI-written)."""
_first_sync(app_url, fvoice_dir)
before = _folder_rows()
assert set(before) == {""} | {FOLDER_A, FOLDER_B}, before
target = fvoice_dir / FOLDER_A / f"{FOLDER_A}-b.md"
target.write_text(
_md(f"Voice {FOLDER_A.title()} B (revised)", ALPHA_B_REVISED_3),
encoding="utf-8",
)
body = _run_sync(app_url)
_assert_change_terminal(body)
after = _folder_rows()
assert set(after) == {""} | {FOLDER_A, FOLDER_B}, after
# The unchanged sibling: BYTE-IDENTICAL — same summary text AND
# same updated_at (never re-stamped, never re-generated).
assert after[FOLDER_B] == before[FOLDER_B], (
FOLDER_B,
before[FOLDER_B],
after[FOLDER_B],
)
# The changed chain (alpha + the source root): RE-STAMPED — a new
# stamp strictly after the captured one, the canned text, still
# AI-written (the mock labels from the folder header only, so the
# text is the same deterministic line).
for folder in ("", FOLDER_A):
summary, manually, updated_at = after[folder]
assert summary == EXPECTED[folder], (folder, summary)
assert manually is False, (folder, manually)
assert updated_at > before[folder][2], (
folder,
before[folder],
after[folder],
)
@@ -20,7 +20,7 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the gate is the
deterministic mock (the phase-94 ``FOLDER_SUMMARY_MODE`` canned
``Fixture folder summary for <folder>.`` lines): the stored rows are a
``Documentation for <folder>.`` lines): the stored rows are a
pure function of the request, so the byte-identical assertions are
exact only against the mock.
@@ -135,7 +135,7 @@ N_CANDIDATES = 5 # the source root + the four folders (the ≥ 1-doc rule)
#: The mock's byte-stable ``FOLDER_SUMMARY_MODE`` lines for this fixture
#: (the phase-94 template — the label is the ``FOLDER_HEADER_PREFIX``
#: tail: ``<source>`` for the root, ``<source>/<folder>`` for a folder).
SUMMARY_FOR = "Fixture folder summary for {}."
SUMMARY_FOR = "Documentation for {}."
EXPECTED: dict[str, str] = {
"": SUMMARY_FOR.format(SOURCE),
FOLDER_A: SUMMARY_FOR.format(f"{SOURCE}/{FOLDER_A}"),
+2 -2
View File
@@ -29,7 +29,7 @@ stored description is deterministic: the mock's EXISTING
stores, per existing folder (the ≥ 1 rule — this fixture holds no
single-doc folders, so the pinned rows are unchanged), the canned
one-liner naming the folder,
``Fixture folder summary for <source>[/<folder>].`` — the ``synced_kb``
``Documentation for <source>[/<folder>].`` — the ``synced_kb``
module fixture pins those exact rows (all with
``manually_edited = false``) after the sync, and the tests assert on
that exact text.
@@ -159,7 +159,7 @@ TOTAL_DOCS = ALPHA_COUNT + BETA_COUNT
#: folder_path)`` order: one row per existing folder (the ≥ 1-doc
#: recursive-subtree rule — this fixture holds no single-doc folders)
#: — the ``""`` rows are the source roots.
SUMMARY_FOR = "Fixture folder summary for {}."
SUMMARY_FOR = "Documentation for {}."
ALPHA_ROOT_SUM = SUMMARY_FOR.format(ALPHA)
ONE_SUM = SUMMARY_FOR.format(f"{ALPHA}/one")
TWO_SUM = SUMMARY_FOR.format(f"{ALPHA}/two")
+4 -4
View File
@@ -39,9 +39,9 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the clamp test's
level-block assertion keys on a MANUAL description (LLM-free), but the
seeded KB's stored descriptions at the other levels are the mock's
canned ``FOLDER_SUMMARY_MODE`` one-liner ``Fixture folder summary for
<folder>.`` (the phase-94 convention — byte-exact only against the
mock).
canned ``FOLDER_SUMMARY_MODE`` one-liner ``Documentation for
<folder>.`` (the phase-155 voice convention — byte-exact only against
the mock).
KB fixture — a host temp dir (``tmp_path_factory``; the app runs on the
same host) with ONE registered local source (the
@@ -161,7 +161,7 @@ TOTAL_DOCS = 6 # 2 one/ + 2 one/two/ + 2 three/ (no root-level files)
#: folder_path)`` order: one row per ≥ 2-doc folder (the
#: recursive-subtree rule — ``one/two`` is a distinct nested folder) —
#: the ``""`` row is the source root.
SUMMARY_FOR = "Fixture folder summary for {}."
SUMMARY_FOR = "Documentation for {}."
ALPHA_ROOT_SUM = SUMMARY_FOR.format(ALPHA)
ONE_SUM = SUMMARY_FOR.format(f"{ALPHA}/one")
TWO_SUM = SUMMARY_FOR.format(f"{ALPHA}/one/two")
+8 -7
View File
@@ -44,7 +44,8 @@ of the mock's cosine distribution, and the ``<tools>`` section is present
The mock's canned ``FOLDER_SUMMARY_MODE`` branch (phase 94 task 01)
stores, per existing folder (the ≥ 1-doc rule — this fixture holds
no single-doc folders), the deterministic one-liner
``Fixture folder summary for <source>[/<folder>].`` — the ``synced_kb``
``Documentation for <source>[/<folder>].`` (phase 155 task 01's
voice) — the ``synced_kb``
module fixture pins those exact rows in ``folder_summaries`` after the
sync, and the drill answers assert on them in the ``ls`` output.
@@ -168,12 +169,12 @@ assert "\n" not in TWO_A_CONTENT[:80] # the quote must stay one line
#: ≥ 1-doc recursive-subtree rule — this fixture holds no single-doc
#: folders) — the ``""`` rows are the source roots.
EXPECTED_SUMMARIES: list[tuple[str, str, str]] = [
(ALPHA, "", f"Fixture folder summary for {ALPHA}."),
(ALPHA, "one", f"Fixture folder summary for {ALPHA}/one."),
(ALPHA, "two", f"Fixture folder summary for {ALPHA}/two."),
(ALPHA, "wide", f"Fixture folder summary for {ALPHA}/wide."),
(BETA, "", f"Fixture folder summary for {BETA}."),
(BETA, "gamma", f"Fixture folder summary for {BETA}/gamma."),
(ALPHA, "", f"Documentation for {ALPHA}."),
(ALPHA, "one", f"Documentation for {ALPHA}/one."),
(ALPHA, "two", f"Documentation for {ALPHA}/two."),
(ALPHA, "wide", f"Documentation for {ALPHA}/wide."),
(BETA, "", f"Documentation for {BETA}."),
(BETA, "gamma", f"Documentation for {BETA}/gamma."),
]
assert [
(source, folder) for source, folder, _s in EXPECTED_SUMMARIES
+7 -7
View File
@@ -54,7 +54,7 @@ Test → phase mapping (Playwright Mapping Rule):
NOT — all 4 attempts empty → ``LLMError`` → per-folder fail-soft),
a scripted ``ls oneshot`` turn asserts the drill-down listing: the
``e2e_empty_once/`` line carries
``: Fixture folder summary for oneshot/e2e_empty_once.`` (the retry
``: Documentation for oneshot/e2e_empty_once.`` (the retry
recovered the row), the ``normal/`` line carries its summary, and
the ``e2e_empty_always/`` line is ``e2e_empty_always/ — 2
documents`` with NO ``: …`` suffix — while the sync reported
@@ -64,7 +64,7 @@ Test → phase mapping (Playwright Mapping Rule):
``e2e_empty_once``'s row ``updated_at`` is captured, and the
UNCHANGED sync #2 runs the gap gate (task 03): a second scripted
``ls oneshot`` turn shows ``normal/`` healed
(``: Fixture folder summary for oneshot/normal.`` again) and
(``: Documentation for oneshot/normal.`` again) and
``e2e_empty_always/`` still absent (the gap-fill attempted it,
exhausted, stayed absent — the sync still succeeded), and the DB
pins the targeted fill: the ``normal`` row is back with its
@@ -141,10 +141,10 @@ def _md(title: str, body: str) -> str:
#: fixture (the phase-94 template — the label is the
#: ``FOLDER_HEADER_PREFIX`` tail: ``<source>`` for the root,
#: ``<source>/<folder>`` for a folder).
SUM_ROOT = f"Fixture folder summary for {SOURCE}."
SUM_ONCE = f"Fixture folder summary for {SOURCE}/{FOLDER_ONCE}."
SUM_ALWAYS = f"Fixture folder summary for {SOURCE}/{FOLDER_ALWAYS}."
SUM_NORMAL = f"Fixture folder summary for {SOURCE}/{FOLDER_NORMAL}."
SUM_ROOT = f"Documentation for {SOURCE}."
SUM_ONCE = f"Documentation for {SOURCE}/{FOLDER_ONCE}."
SUM_ALWAYS = f"Documentation for {SOURCE}/{FOLDER_ALWAYS}."
SUM_NORMAL = f"Documentation for {SOURCE}/{FOLDER_NORMAL}."
# --- the pinned ``ls oneshot`` level (app.rag.agent's phase-94 template)
@@ -560,7 +560,7 @@ def test_deleted_row_self_heals_on_unchanged_sync(
other row stays byte-identical (text AND ``updated_at``).
* ``normal`` — healed: the second ``ls oneshot`` turn carries
``: Fixture folder summary for oneshot/normal.`` again, and the
``: Documentation for oneshot/normal.`` again, and the
DB row is back with its deterministic text;
* ``e2e_empty_once`` — the gap-fill NEVER calls it (the row
exists): its ``updated_at`` is UNCHANGED (a full regeneration
+4 -4
View File
@@ -16,7 +16,7 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the gate is the
deterministic mock (the phase-94 ``FOLDER_SUMMARY_MODE`` canned
``Fixture folder summary for <folder>.`` lines) plus the phase-64
``Documentation for <folder>.`` lines) plus the phase-64
``tests/e2e/slow_llm.py`` delay proxy that stretches the run past both
the ~100 ms recorder cadence and the UI's 2 s poll.
@@ -169,9 +169,9 @@ N_CANDIDATES = 3 # the source root + the two folders (the ≥ 1-doc rule)
#: The mock's byte-stable ``FOLDER_SUMMARY_MODE`` lines for this fixture
#: (the phase-94 template — the label is the ``FOLDER_HEADER_PREFIX``
#: tail: ``<source>`` for the root, ``<source>/<folder>`` for a folder).
SUM_ROOT = f"Fixture folder summary for {SOURCE}."
SUM_ALPHA = f"Fixture folder summary for {SOURCE}/{FOLDER_A}."
SUM_BRavo = f"Fixture folder summary for {SOURCE}/{FOLDER_B}."
SUM_ROOT = f"Documentation for {SOURCE}."
SUM_ALPHA = f"Documentation for {SOURCE}/{FOLDER_A}."
SUM_BRavo = f"Documentation for {SOURCE}/{FOLDER_B}."
#: The D4 "Summary pending" marker copy (phase 98 task 04 — verbatim
#: from the D4 decision): the row-cell text, the row-cell ``title``,
@@ -0,0 +1,267 @@
"""Integration test: ``regen_folder_summaries`` runs the FULL-mode one-shot
regeneration (phase 155, task 02).
Drives ``scripts.regen_folder_summaries.main()`` end to end against the
local compose Postgres with a deterministic fake LLM (no live aipi,
fresh settings — the phase 31 integration's mocking style), over a
fixture catalogue (2 sources, 3 candidate folders: the ``Alpha`` source
root, ``Alpha/sub``, the ``Beta`` source root) whose ``folder_summaries``
rows are pre-stored with the formulaic pre-nudge voice and a backdated
``updated_at``:
- a full-mode run upserts EVERY candidate row — the text is the
fake's deterministic reply for all of them and EVERY row's
``updated_at`` advances (a pure regen); the catalogue is
unchanged, so the count-driven prune rule fires for nothing
(``pruned=0``);
- the single cron-safe summary line logs the stats dict
(``regen_folder_summaries: folders=… failed=… pruned=…
kept_manual=… limit=…``);
- one ``lite`` call per candidate folder (a full regeneration burns a
call per folder; the ``FOLDER_SUMMARY_MODE`` marker + the phase-155
instruction are in the system prompt — the mock contract is intact
end to end);
- a ``manually_edited`` row is SKIPPED (no ``lite`` burn on owner
text, the row stays byte-identical — text AND ``updated_at``) and
is named in the stats (``kept_manual=1``);
- a full ``lite`` failure is fail-soft PER FOLDER (phase 94): every
previous row is kept (text AND ``updated_at``), the run's exit
code stays 0, and the failure counts on the summary line
(``folders=0 failed=3``) — the exit code is about the run (boot,
DB, commit), never about per-folder model failures.
"""
from __future__ import annotations
import logging
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.config import Settings
from app.db import db_available
from app.models import Document, FolderSummary
from app.rag.llm import LLMError
from scripts import regen_folder_summaries as regen
from tests.fakes import FakeEmbedder
#: The fixture catalogue: (source, path, title, summary) rows.
CATALOGUE: list[tuple[str, str, str, str]] = [
("Alpha", "a.md", "A One", "Summary of A"),
("Alpha", "b.md", "B Two", "Summary of B"),
("Alpha", "sub/c.md", "C Three", "Summary of C"),
("Beta", "x.md", "X One", "Summary of X"),
]
#: The candidate folders (recursive subtree ≥ 1 doc, per source):
#: the two source roots + the ``Alpha/sub`` folder.
CANDIDATES: list[tuple[str, str]] = [
("Alpha", ""),
("Alpha", "sub"),
("Beta", ""),
]
#: The formulaic pre-nudge voice the pre-stored rows carry (the
#: live catalogue's shape — what the one-shot exists to replace).
OLD_TEXT = "This folder contains the project's notes and setup guides."
class FailingChatEmbedder(FakeEmbedder):
"""A ``lite`` model that always fails (drives the per-folder
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 _seed(db: Session, old: datetime) -> None:
"""Seed the fixture catalogue + the pre-stored folder-summary
rows (the formulaic voice, backdated ``updated_at``)."""
db.add_all(
Document(
source=source,
path=path,
full_path=f"/fixture/{source}/{path}",
title=title,
content=f"# {title}\n\nBody.\n",
content_hash=f"{i:064d}"[:64],
summary=summary,
)
for i, (source, path, title, summary) in enumerate(CATALOGUE)
)
db.add_all(
FolderSummary(
source=source,
folder_path=folder_path,
summary=OLD_TEXT,
manually_edited=False,
updated_at=old,
)
for source, folder_path in CANDIDATES
)
db.commit()
def _run_main(
monkeypatch: pytest.MonkeyPatch,
llm: FakeEmbedder,
argv: list[str],
) -> tuple[int, list[str]]:
"""Run ``regen_folder_summaries.main`` with fresh settings and a
fake LLM; returns the exit code + the module logger's summary
lines (the single cron-safe line, PLAN §9)."""
monkeypatch.setattr(
regen, "get_settings", lambda: Settings(_env_file=None) # pyright: ignore[reportCallIssue]
)
monkeypatch.setattr(regen, "LLMClient", lambda: llm)
records: list[logging.LogRecord] = []
class _Sink(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
records.append(record)
sink = _Sink()
log = logging.getLogger("scripts.regen_folder_summaries")
log.addHandler(sink)
log.setLevel(logging.INFO)
try:
rc = regen.main(argv)
finally:
log.removeHandler(sink)
lines = [
r.getMessage()
for r in records
if r.getMessage().startswith("regen_folder_summaries:")
]
return rc, lines
@pytest.fixture(autouse=True)
def _clean_kb(db: Session) -> Iterator[None]:
if not db_available():
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
db.execute(text("TRUNCATE chunks, documents, folder_summaries"))
db.commit()
yield
db.execute(text("TRUNCATE chunks, documents, folder_summaries"))
db.commit()
def test_full_regen_upserts_every_candidate_row(
db: Session,
monkeypatch: pytest.MonkeyPatch,
) -> None:
old = datetime.now(UTC) - timedelta(days=1)
_seed(db, old)
llm = FakeEmbedder()
rc, lines = _run_main(monkeypatch, llm, [])
assert rc == 0
# The single cron-safe summary line: the stats dict, one line.
assert lines == [
"regen_folder_summaries: folders=3 failed=0 pruned=0 "
"kept_manual=0 limit=none"
]
# A full regeneration burns exactly ONE lite call per candidate
# folder (nothing else may touch chat).
assert len(llm.chat_calls) == 3
# The mock contract is intact end to end: the FOLDER_SUMMARY_MODE
# marker + the phase-155 instruction (the two forbidden openers as
# the constraint) are in every system prompt, and the user
# message's first line names the folder (FOLDER_HEADER_PREFIX).
expected_user_heads = ["Folder: Alpha", "Folder: Alpha/sub", "Folder: Beta"]
for call, head in zip(llm.chat_calls, expected_user_heads, strict=True):
by_role = {m["role"]: m["content"] for m in call}
assert "FOLDER_SUMMARY_MODE" in by_role["system"]
assert "Do NOT begin with 'This folder contains'" in by_role["system"]
assert by_role["user"].splitlines()[0] == head
# EVERY candidate row was upserted: the text is the fake's
# deterministic reply (a pure function of the request — the same
# "Summary of Folder:" for all three, the new voice for all of
# them), and EVERY row's updated_at advanced past the backdate.
db.expire_all()
for source, folder_path in CANDIDATES:
row = db.get(FolderSummary, (source, folder_path))
assert row is not None
assert row.summary == "Summary of Folder:"
assert row.updated_at > old
# No prune: the catalogue is unchanged, so the count-driven prune
# rule fires for nothing — the row count is unchanged.
assert db.execute(text("SELECT count(*) FROM folder_summaries")).scalar() == 3
def test_manual_row_is_kept_and_named_in_stats(
db: Session,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The owner's words are never regenerated (phase 97, task 01):
the manual row stays byte-identical (text AND ``updated_at`` —
no ``lite`` burn), the other candidate rows advance, and the skip
is named in the stats (``kept_manual=1``)."""
old = datetime.now(UTC) - timedelta(days=1)
_seed(db, old)
manual = db.get(FolderSummary, ("Alpha", ""))
assert manual is not None
manual.manually_edited = True
manual.summary = "Owner words — the drill-down picture I wrote."
db.commit()
# Capture the manual row's stamp AFTER the seed (the column's
# onupdate re-stamped it on the flag-setting UPDATE) — the
# script must leave it byte-identical.
manual = db.get(FolderSummary, ("Alpha", ""))
assert manual is not None
manual_stamp = manual.updated_at
llm = FakeEmbedder()
rc, lines = _run_main(monkeypatch, llm, [])
assert rc == 0
assert lines == [
"regen_folder_summaries: folders=2 failed=0 pruned=0 "
"kept_manual=1 limit=none"
]
# No lite burn on owner text: only the two non-manual candidates.
assert len(llm.chat_calls) == 2
db.expire_all()
row = db.get(FolderSummary, ("Alpha", ""))
assert row is not None
assert row.summary == "Owner words — the drill-down picture I wrote."
assert row.updated_at == manual_stamp # byte-identical — never re-stamped
for source, folder_path in [("Alpha", "sub"), ("Beta", "")]:
row = db.get(FolderSummary, (source, folder_path))
assert row is not None
assert row.summary == "Summary of Folder:"
assert row.updated_at > old
def test_lite_failure_keeps_previous_rows_and_stays_exit_zero(
db: Session,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A full ``lite`` outage is fail-soft PER FOLDER (phase 94): the
previous rows are kept (text AND ``updated_at`` — an old summary
is better than none), the exit code stays 0 (the run itself was
fine — boot, DB, commit all succeeded), and the failure counts on
the summary line (``folders=0 failed=3``)."""
old = datetime.now(UTC) - timedelta(days=1)
_seed(db, old)
llm = FailingChatEmbedder()
rc, lines = _run_main(monkeypatch, llm, [])
assert rc == 0 # a failed folder batch must not fail the run
assert lines == [
"regen_folder_summaries: folders=0 failed=3 pruned=0 "
"kept_manual=0 limit=none"
]
assert len(llm.chat_calls) == 3 # one attempt per candidate folder
db.expire_all()
for source, folder_path in CANDIDATES:
row = db.get(FolderSummary, (source, folder_path))
assert row is not None
assert row.summary == OLD_TEXT # previous row kept
assert row.updated_at == old # never re-stamped on failure
+71 -4
View File
@@ -272,6 +272,40 @@ def test_system_prompt_has_marker_and_locked_instruction() -> None:
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 ----------
@@ -369,22 +403,55 @@ def test_mock_returns_canned_folder_summary_naming_the_folder() -> None:
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)."""
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)) == (
"Fixture folder summary for Homelab/deployments/ansible."
"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)) == (
"Fixture folder summary for Homelab."
"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
@@ -394,7 +461,7 @@ def test_mock_folder_marker_is_not_shadowed_by_the_summary_branch() -> None:
)
assert "SUMMARY_MODE" in system # the shadowing hazard is real
answer = compose_answer(_mock_body(system, user))
assert answer == "Fixture folder summary for S/a."
assert answer == "Documentation for S/a."
assert not answer.startswith("This document covers")
+295
View File
@@ -0,0 +1,295 @@
"""Unit tests: ``scripts/regen_folder_summaries.py`` wiring (phase 155, task 02).
The one-shot full-regeneration entrypoint is pinned by wiring, not by a
live LLM or DB: a fake ``LLMClient``, a fake short-lived session, and a
stubbed ``generate_folder_summaries`` that records its call. The pins:
* the FULL-mode call contract — the stub is called with NO kwargs
(``changed_docs=None`` / ``only_missing=False`` / ``skip=False`` are
the generator's defaults; the phase-127 change-gated sync path is
untouched, the script is a consumer of the full mode);
* the stats dict surfaces on the single cron-safe summary line
(``regen_folder_summaries: folders=… failed=… pruned=…
kept_manual=… limit=…``) on the module logger;
* the ``--limit`` debug passthrough (parity with ``import_docs``'s
parser) is named on the summary line without disturbing the
generator's call contract;
* the settings/boot failure exits non-zero with the NAMED stderr line
— the app validator idiom (``ensure_admin_configured`` names the
missing ``BOR_`` variable; a settings validation error names the
field); the LLM/session/generator never run on a boot failure;
* a DB/commit outage exits non-zero with the named stderr line (never
a silent partial — the session still closes, no summary line);
* ``main()`` calls ``configure_debugging()`` (the phase-8 idiom — the
``DEBUGPY=1`` listener contract itself is the phase-8 precedent,
``tests/unit/test_debugging.py``).
"""
from __future__ import annotations
import logging
import pytest
from app.config import Settings
from scripts import regen_folder_summaries as regen
STATS = {"generated": 3, "failed": 1, "pruned": 2, "kept_manual": 1}
class _FakeLLM:
"""A duck-typed ``LLMClient`` stand-in (records construction)."""
class _FakeSession:
"""A short-lived session stub (phase-53 convention: the script's
own session commits; the generator only flushes)."""
def __init__(self, commit_error: Exception | None = None) -> None:
self.commit_error = commit_error
self.committed = False
self.closed = False
def commit(self) -> None:
if self.commit_error is not None:
raise self.commit_error
self.committed = True
def close(self) -> None:
self.closed = True
class _GenerateStub:
"""The stubbed ``generate_folder_summaries``: records the call's
positional args and kwargs and returns the fixed stats dict."""
def __init__(self) -> None:
self.calls: list[tuple[tuple[object, ...], dict[str, object]]] = []
self.raised: Exception | None = None
async def __call__(
self, session: object, llm: object, **kwargs: object
) -> dict[str, int]:
self.calls.append(((session, llm), dict(kwargs)))
if self.raised is not None:
raise self.raised
return dict(STATS)
def _wire(
monkeypatch: pytest.MonkeyPatch,
) -> tuple[_FakeSession, _GenerateStub, list[logging.LogRecord]]:
"""Boot the wiring with a fake LLM, fake session, and generator
stub; ``configure_logging``/``configure_debugging`` are spied (the
phase-8 idiom is pinned by the call, the listener contract by the
phase-8 precedent file)."""
session = _FakeSession()
stub = _GenerateStub()
llm = _FakeLLM()
monkeypatch.setattr(
regen,
"get_settings",
lambda: Settings(_env_file=None), # pyright: ignore[reportCallIssue]
)
monkeypatch.setattr(regen, "configure_logging", lambda level: None)
debugging_calls: list[int] = []
monkeypatch.setattr(
regen,
"configure_debugging",
lambda: debugging_calls.append(1) or False,
)
monkeypatch.setattr(regen, "LLMClient", lambda: llm)
monkeypatch.setattr(regen, "SessionLocal", lambda: session)
monkeypatch.setattr(regen, "generate_folder_summaries", stub)
records: list[logging.LogRecord] = []
class _Sink(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
records.append(record)
sink = _Sink()
regen_logger = logging.getLogger("scripts.regen_folder_summaries")
regen_logger.addHandler(sink)
regen_logger.setLevel(logging.INFO)
return session, stub, records
def _summary_lines(records: list[logging.LogRecord]) -> list[str]:
return [r.getMessage() for r in records if r.getMessage().startswith(
"regen_folder_summaries:"
)]
# ---------- the parser (the import_docs parity flag) ----------
def test_parser_defaults_to_no_limit() -> None:
args = regen.build_parser().parse_args([])
assert args.limit is None
def test_parser_limit_parity_flag() -> None:
args = regen.build_parser().parse_args(["--limit", "5"])
assert args.limit == 5
# ---------- the success path: the FULL-mode call contract ----------
def test_main_full_mode_defaults_and_stats_on_summary_line(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
session, stub, records = _wire(monkeypatch)
rc = regen.main([])
out = capsys.readouterr()
assert rc == 0
assert out.err == "" # success: no stderr noise
# The FULL-mode call contract: exactly one call, (session, llm)
# positional, NO kwargs — changed_docs=None / only_missing=False /
# skip=False are the generator's defaults (the phase-127 change-
# gated sync path is untouched — the script is a consumer of the
# default mode, not of changed_docs).
assert len(stub.calls) == 1
(pos, kwargs), = stub.calls
assert kwargs == {}
assert isinstance(pos[0], _FakeSession) # the short-lived session
assert isinstance(pos[1], _FakeLLM) # the LLMClient stand-in
# The phase-53 convention: the script's own session commits + closes.
assert session.committed is True
assert session.closed is True
# The stats dict surfaces on the single cron-safe summary line.
lines = _summary_lines(records)
assert len(lines) == 1
assert lines[0] == (
"regen_folder_summaries: folders=3 failed=1 pruned=2 "
"kept_manual=1 limit=none"
)
def test_main_debug_parsing_runs_full_mode_and_names_limit(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""The --limit debug passthrough (parity with import_docs): named
on the summary line WITHOUT disturbing the generator's call
contract — the full-mode defaults are still the no-kwargs call."""
_session, stub, records = _wire(monkeypatch)
rc = regen.main(["--limit", "7"])
assert rc == 0
assert len(stub.calls) == 1
assert stub.calls[0][1] == {} # the full-mode defaults, untouched
assert _summary_lines(records) == [
"regen_folder_summaries: folders=3 failed=1 pruned=2 "
"kept_manual=1 limit=7"
]
def test_main_calls_configure_debugging(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""The phase-8 wiring pin: main() calls configure_debugging()
(DEBUGPY=1 honored, default off — the listener contract itself is
the phase-8 precedent, tests/unit/test_debugging.py)."""
called: list[int] = []
session, _stub, _records = _wire(monkeypatch)
monkeypatch.setattr(
regen, "configure_debugging", lambda: called.append(1) or False
)
assert regen.main([]) == 0
assert called == [1]
assert session.committed is True
# ---------- the fail-loud boot (the app validator idiom) ----------
def test_main_boot_failure_missing_admin_secret_is_named(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""A missing BOR_ADMIN_PASSWORD: the app's fail-loud gate
(ensure_admin_configured) names the variable, main exits non-zero
with the named stderr line, and the LLM/session/generator NEVER
run (never a silent partial)."""
monkeypatch.setattr(
regen,
"get_settings",
lambda: Settings(_env_file=None, admin_password=""), # pyright: ignore[reportCallIssue]
)
monkeypatch.setattr(regen, "configure_logging", lambda level: None)
monkeypatch.setattr(regen, "configure_debugging", lambda: False)
llm_calls: list[int] = []
monkeypatch.setattr(
regen, "LLMClient", lambda: llm_calls.append(1) or _FakeLLM()
)
session = _FakeSession()
monkeypatch.setattr(regen, "SessionLocal", lambda: session)
stub = _GenerateStub()
monkeypatch.setattr(regen, "generate_folder_summaries", stub)
rc = regen.main([])
err = capsys.readouterr().err
assert rc == 1
assert "regen_folder_summaries: boot failed:" in err
assert "BOR_ADMIN_PASSWORD" in err # the gate names the variable
assert llm_calls == [] # the LLM never constructed
assert stub.calls == [] # the generator never ran
assert session.committed is False and session.closed is False
def test_main_settings_validation_error_is_named(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""A settings validation error (a bad env value — the settings
validator naming the offending field) names the failure the same
way: non-zero exit, the named line, nothing runs."""
def _bad_settings() -> Settings:
# The Settings validator rejects a negative recency_boost —
# the pydantic ValidationError names the field.
return Settings(_env_file=None, recency_boost=-1) # pyright: ignore[reportCallIssue]
monkeypatch.setattr(regen, "get_settings", _bad_settings)
monkeypatch.setattr(regen, "configure_logging", lambda level: None)
llm_calls: list[int] = []
monkeypatch.setattr(
regen, "LLMClient", lambda: llm_calls.append(1) or _FakeLLM()
)
stub = _GenerateStub()
monkeypatch.setattr(regen, "generate_folder_summaries", stub)
rc = regen.main([])
err = capsys.readouterr().err
assert rc == 1
assert "regen_folder_summaries: boot failed:" in err
assert "recency_boost" in err
assert llm_calls == []
assert stub.calls == []
# ---------- the fail-loud run (never a silent partial) ----------
def test_main_commit_outage_fails_loud(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""A DB/commit outage: the failure is NAMED on stderr, the exit
code is non-zero, the session still closes (the uncommitted
regeneration rolls back with it — never a silent partial), and NO
summary line is logged (a failed run reports nothing generated)."""
session, stub, records = _wire(monkeypatch)
session.commit_error = RuntimeError("db down (test sentinel)")
rc = regen.main([])
err = capsys.readouterr().err
assert rc == 1
assert "regen_folder_summaries: regeneration failed:" in err
assert "db down (test sentinel)" in err
assert len(stub.calls) == 1 # the generator ran…
assert session.committed is False # …but the commit failed
assert session.closed is True # …and the session closed (rollback)
assert _summary_lines(records) == [] # no success line
assert "regen_folder_summaries: folders=" not in err