From d4943b48220601cd62d2d398d934d8516d20fad0 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Fri, 11 Sep 2026 00:59:35 -0400 Subject: [PATCH] phase: 94_ls_tree_drilldown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All green. Verification complete. **Phase 94 — `ls` drill-down tree: final verification pass (all 5 tasks were already complete; verified, nothing to fix)** - Verified `ls` 3-level tree (`app/rag/agent.py`): `ls()` sources + summaries, `ls(source)`/`ls(source/folder)` drill-down, 50-line file cap + grep-pointer note, NOT-A-FOLDER teaching refusal - Verified `folder_summaries` (migration 0017, model, `app/rag/folder_summaries.py` generator: `FOLDER_SUMMARY_MODE` marker, fail-soft per folder, ≥2-doc scope + prune) wired change-gated in both sync paths - Verified 10-turn fixture battery verdict recorded in `TOOL_CALLING_TESTING.md` §9 (2026-09-11): turbo PASS 19/19 contract, 98.7 s (−12.5…−13.2 % vs baseline); lite PASS 18/18, 43.6 s (+7.7 %) — accuracy at/above baseline, gate met - `uv run pytest --cov=app --cov-report=term-missing` → 1939 passed, 0 failed; TOTAL coverage **99 %** (folder_summaries.py 100 %) - `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings - E2E in isolation: `test_ls_tree_drilldown.py` 3 passed; `test_agent_document_tools` 4, `test_agent_unlimited_tools` 4, `test_harness_aligned_tools` 3, `test_search_tool` 3, `test_grep_regex_teaching` 2, `test_response_to_docs` 4 — all passed (read/grep contracts untouched) - Dedicated folder-summary tests (fail-soft, prune, both sync paths, migration): 46 passed - Completion criteria: all 6 met; working tree holds only phase-94 changes (commit left to harness per protocol) **Next pending phase:** `95_read_truncation_cap` --- .../94_ls_tree_drilldown/00_phase.md | 0 .../01_folder_summary_model.md | 0 .../94_ls_tree_drilldown/02_sync_wiring.md | 0 .../94_ls_tree_drilldown/03_ls_tree.md | 0 .../94_ls_tree_drilldown/04_e2e_drilldown.md | 0 .../05_realmodel_battery.md | 0 .../94_ls_tree_drilldown__00_phase.a1.err | 0 .../94_ls_tree_drilldown__00_phase.a1.md | 14 + ...94_ls_tree_drilldown__00_phase.a1.validate | 94 +++ ..._drilldown__01_folder_summary_model.a1.err | 0 ...e_drilldown__01_folder_summary_model.a1.md | 17 + ...ldown__01_folder_summary_model.a1.validate | 94 +++ ...4_ls_tree_drilldown__02_sync_wiring.a1.err | 0 ...94_ls_tree_drilldown__02_sync_wiring.a1.md | 1 + ...tree_drilldown__02_sync_wiring.a1.validate | 94 +++ ...4_ls_tree_drilldown__02_sync_wiring.a2.err | 0 ...94_ls_tree_drilldown__02_sync_wiring.a2.md | 14 + ...tree_drilldown__02_sync_wiring.a2.validate | 94 +++ .../94_ls_tree_drilldown__03_ls_tree.a1.err | 0 .../94_ls_tree_drilldown__03_ls_tree.a1.md | 11 + ..._ls_tree_drilldown__03_ls_tree.a1.validate | 94 +++ ...ls_tree_drilldown__04_e2e_drilldown.a1.err | 0 ..._ls_tree_drilldown__04_e2e_drilldown.a1.md | 18 + ...ee_drilldown__04_e2e_drilldown.a1.validate | 94 +++ ...ree_drilldown__05_realmodel_battery.a1.err | 0 ...tree_drilldown__05_realmodel_battery.a1.md | 18 + ...rilldown__05_realmodel_battery.a1.validate | 94 +++ .env.example | 1 + TOOL_CALLING_TESTING.md | 194 ++++- alembic/versions/0017_folder_summaries.py | 63 ++ app/api/chat.py | 8 +- app/api/sync.py | 32 +- app/config.py | 8 + app/models.py | 52 ++ app/rag/agent.py | 516 ++++++++++-- app/rag/folder_summaries.py | 391 +++++++++ app/rag/prompts.py | 63 +- scripts/agent_realmodel_check.py | 71 +- scripts/import_docs.py | 108 ++- scripts/load_test_kb.py | 62 +- scripts/restore_test_kb.py | 18 +- tests/e2e/mock_llm.py | 529 ++++++++++-- tests/e2e/test_agent_document_tools.py | 74 +- tests/e2e/test_agent_unlimited_tools.py | 97 ++- tests/e2e/test_big_read_progress.py | 89 +- tests/e2e/test_harness_aligned_tools.py | 74 +- tests/e2e/test_ls_tree_drilldown.py | 772 ++++++++++++++++++ tests/e2e/test_tool_path_teaching.py | 92 ++- tests/fixtures/test_kb.dump.sql | 85 +- tests/integration/test_agent_tools.py | 256 +++++- tests/integration/test_chat_api.py | 5 +- tests/integration/test_import_docs_git.py | 18 + .../integration/test_import_docs_overview.py | 81 +- tests/integration/test_migration_0017.py | 248 ++++++ tests/integration/test_sync_api.py | 65 +- .../integration/test_sync_folder_summaries.py | 664 +++++++++++++++ tests/unit/test_agent.py | 608 +++++++++++--- tests/unit/test_folder_summaries.py | 628 ++++++++++++++ tests/unit/test_mock_tool_flow.py | 168 +++- tests/unit/test_prompts.py | 52 +- tests/unit/test_sync_button.py | 12 +- 61 files changed, 6289 insertions(+), 666 deletions(-) rename .agents/phases/{todo => complete}/94_ls_tree_drilldown/00_phase.md (100%) rename .agents/phases/{todo => complete}/94_ls_tree_drilldown/01_folder_summary_model.md (100%) rename .agents/phases/{todo => complete}/94_ls_tree_drilldown/02_sync_wiring.md (100%) rename .agents/phases/{todo => complete}/94_ls_tree_drilldown/03_ls_tree.md (100%) rename .agents/phases/{todo => complete}/94_ls_tree_drilldown/04_e2e_drilldown.md (100%) rename .agents/phases/{todo => complete}/94_ls_tree_drilldown/05_realmodel_battery.md (100%) create mode 100644 .agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__00_phase.a1.err create mode 100644 .agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__00_phase.a1.md create mode 100644 .agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__00_phase.a1.validate create mode 100644 .agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__01_folder_summary_model.a1.err create mode 100644 .agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__01_folder_summary_model.a1.md create mode 100644 .agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__01_folder_summary_model.a1.validate create mode 100644 .agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__02_sync_wiring.a1.err create mode 100644 .agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__02_sync_wiring.a1.md create mode 100644 .agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__02_sync_wiring.a1.validate create mode 100644 .agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__02_sync_wiring.a2.err create mode 100644 .agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__02_sync_wiring.a2.md create mode 100644 .agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__02_sync_wiring.a2.validate create mode 100644 .agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__03_ls_tree.a1.err create mode 100644 .agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__03_ls_tree.a1.md create mode 100644 .agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__03_ls_tree.a1.validate create mode 100644 .agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__04_e2e_drilldown.a1.err create mode 100644 .agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__04_e2e_drilldown.a1.md create mode 100644 .agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__04_e2e_drilldown.a1.validate create mode 100644 .agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__05_realmodel_battery.a1.err create mode 100644 .agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__05_realmodel_battery.a1.md create mode 100644 .agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__05_realmodel_battery.a1.validate create mode 100644 alembic/versions/0017_folder_summaries.py create mode 100644 app/rag/folder_summaries.py create mode 100644 tests/e2e/test_ls_tree_drilldown.py create mode 100644 tests/integration/test_migration_0017.py create mode 100644 tests/integration/test_sync_folder_summaries.py create mode 100644 tests/unit/test_folder_summaries.py diff --git a/.agents/phases/todo/94_ls_tree_drilldown/00_phase.md b/.agents/phases/complete/94_ls_tree_drilldown/00_phase.md similarity index 100% rename from .agents/phases/todo/94_ls_tree_drilldown/00_phase.md rename to .agents/phases/complete/94_ls_tree_drilldown/00_phase.md diff --git a/.agents/phases/todo/94_ls_tree_drilldown/01_folder_summary_model.md b/.agents/phases/complete/94_ls_tree_drilldown/01_folder_summary_model.md similarity index 100% rename from .agents/phases/todo/94_ls_tree_drilldown/01_folder_summary_model.md rename to .agents/phases/complete/94_ls_tree_drilldown/01_folder_summary_model.md diff --git a/.agents/phases/todo/94_ls_tree_drilldown/02_sync_wiring.md b/.agents/phases/complete/94_ls_tree_drilldown/02_sync_wiring.md similarity index 100% rename from .agents/phases/todo/94_ls_tree_drilldown/02_sync_wiring.md rename to .agents/phases/complete/94_ls_tree_drilldown/02_sync_wiring.md diff --git a/.agents/phases/todo/94_ls_tree_drilldown/03_ls_tree.md b/.agents/phases/complete/94_ls_tree_drilldown/03_ls_tree.md similarity index 100% rename from .agents/phases/todo/94_ls_tree_drilldown/03_ls_tree.md rename to .agents/phases/complete/94_ls_tree_drilldown/03_ls_tree.md diff --git a/.agents/phases/todo/94_ls_tree_drilldown/04_e2e_drilldown.md b/.agents/phases/complete/94_ls_tree_drilldown/04_e2e_drilldown.md similarity index 100% rename from .agents/phases/todo/94_ls_tree_drilldown/04_e2e_drilldown.md rename to .agents/phases/complete/94_ls_tree_drilldown/04_e2e_drilldown.md diff --git a/.agents/phases/todo/94_ls_tree_drilldown/05_realmodel_battery.md b/.agents/phases/complete/94_ls_tree_drilldown/05_realmodel_battery.md similarity index 100% rename from .agents/phases/todo/94_ls_tree_drilldown/05_realmodel_battery.md rename to .agents/phases/complete/94_ls_tree_drilldown/05_realmodel_battery.md diff --git a/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__00_phase.a1.err b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__00_phase.a1.md b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__00_phase.a1.md new file mode 100644 index 0000000..a49f144 --- /dev/null +++ b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__00_phase.a1.md @@ -0,0 +1,14 @@ +All green. Verification complete. + +**Phase 94 — `ls` drill-down tree: final verification pass (all 5 tasks were already complete; verified, nothing to fix)** + +- Verified `ls` 3-level tree (`app/rag/agent.py`): `ls()` sources + summaries, `ls(source)`/`ls(source/folder)` drill-down, 50-line file cap + grep-pointer note, NOT-A-FOLDER teaching refusal +- Verified `folder_summaries` (migration 0017, model, `app/rag/folder_summaries.py` generator: `FOLDER_SUMMARY_MODE` marker, fail-soft per folder, ≥2-doc scope + prune) wired change-gated in both sync paths +- Verified 10-turn fixture battery verdict recorded in `TOOL_CALLING_TESTING.md` §9 (2026-09-11): turbo PASS 19/19 contract, 98.7 s (−12.5…−13.2 % vs baseline); lite PASS 18/18, 43.6 s (+7.7 %) — accuracy at/above baseline, gate met +- `uv run pytest --cov=app --cov-report=term-missing` → 1939 passed, 0 failed; TOTAL coverage **99 %** (folder_summaries.py 100 %) +- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings +- E2E in isolation: `test_ls_tree_drilldown.py` 3 passed; `test_agent_document_tools` 4, `test_agent_unlimited_tools` 4, `test_harness_aligned_tools` 3, `test_search_tool` 3, `test_grep_regex_teaching` 2, `test_response_to_docs` 4 — all passed (read/grep contracts untouched) +- Dedicated folder-summary tests (fail-soft, prune, both sync paths, migration): 46 passed +- Completion criteria: all 6 met; working tree holds only phase-94 changes (commit left to harness per protocol) + +**Next pending phase:** `95_read_truncation_cap` diff --git a/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__00_phase.a1.validate b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__00_phase.a1.validate new file mode 100644 index 0000000..7f9cabd --- /dev/null +++ b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__00_phase.a1.validate @@ -0,0 +1,94 @@ +........................................................................ [ 3%] +........................................................................ [ 7%] +........................................................................ [ 11%] +........................................................................ [ 14%] +........................................................................ [ 18%] +........................................................................ [ 22%] +........................................................................ [ 25%] +........................................................................ [ 29%] +........................................................................ [ 33%] +........................................................................ [ 37%] +........................................................................ [ 40%] +........................................................................ [ 44%] +........................................................................ [ 48%] +........................................................................ [ 51%] +........................................................................ [ 55%] +........................................................................ [ 59%] +........................................................................ [ 63%] +........................................................................ [ 66%] +........................................................................ [ 70%] +........................................................................ [ 74%] +........................................................................ [ 77%] +........................................................................ [ 81%] +........................................................................ [ 85%] +........................................................................ [ 89%] +........................................................................ [ 92%] +........................................................................ [ 96%] +................................................................... [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 178 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 94 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 229 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 29 0 100% +app/api/sync.py 110 0 100% +app/api/tokens.py 28 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 133 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 33 0 100% +app/db.py 21 0 100% +app/main.py 66 0 100% +app/models.py 124 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 306 1 99% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/folder_summaries.py 110 0 100% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 190 3 98% +app/rag/llm.py 217 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +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 24 0 100% +app/schemas.py 269 0 100% +-------------------------------------------------- +TOTAL 3647 13 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 diff --git a/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__01_folder_summary_model.a1.err b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__01_folder_summary_model.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__01_folder_summary_model.a1.md b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__01_folder_summary_model.a1.md new file mode 100644 index 0000000..436d6fd --- /dev/null +++ b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__01_folder_summary_model.a1.md @@ -0,0 +1,17 @@ +Task 01 is complete and fully verified. + +**Implemented** (phase 94, task 01 — folder summary storage + generator) +- `alembic/versions/0017_folder_summaries.py`: `folder_summaries` table, PK `(source, folder_path)` mirroring `documents` String(120)/String(1000), TEXT `summary` NOT NULL, TIMESTAMPTZ `updated_at` (now() default); tested downgrade +- `app/models.py`: `FolderSummary` model (`folder_path = ""` = source root; ≥2-doc policy documented) +- `app/rag/folder_summaries.py`: `FOLDER_SUMMARY_MODE` marker + 1–3-sentence locked instruction, `folder_of`, `group_by_folder` (recursive subtree = exactly the `ls` count rule — I fixed a gap: the `path == folder` arm now lands a file-sharing-a-directory-name in its folder's group), capped prompt builder (8 000 chars, `BOR_FOLDER_SUMMARY_INPUT_MAX_CHARS` + `TRUNCATION_MARKER`), `summarize_folder` (empty→`LLMError`), `generate_folder_summaries` (skip no-op, per-folder fail-soft, ≥2 rule, stale prune, flush-only caller-commits, stats dict + log line) +- `tests/e2e/mock_llm.py`: canned `Fixture folder summary for .` branch, checked **before** the `SUMMARY_MODE` branch (marker-substring shadowing — real bug, pinned) +- Tests: 31 unit + 4 migration integration; also repaired dev DB (found at 0006 → upgraded to 0017 head) + +**Results** +- `uv run pytest --cov=app --cov-report=term-missing` → **1903 passed, TOTAL 99%** (`folder_summaries.py` 100%, `models.py` 100%) +- `uv run ruff check . && uv run pyright` → clean (0 errors) +- E2E in isolation, all PASS: `test_harness_aligned_tools`, `test_agent_document_tools`, `test_agent_unlimited_tools`, `test_search_tool`, `test_grep_regex_teaching`, `test_response_to_docs`, `test_kb_overview`, `test_document_summaries` + +**Notable** — a previous (a1) attempt's work was in the tree; I verified rather than trusted it, fixed the `group_by_folder` concept gap, and added the mock-branch + edge-case pins. + +**Next pending task** — `.agents/phases/todo/94_ls_tree_drilldown/02_sync_wiring.md` diff --git a/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__01_folder_summary_model.a1.validate b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__01_folder_summary_model.a1.validate new file mode 100644 index 0000000..620dfb0 --- /dev/null +++ b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__01_folder_summary_model.a1.validate @@ -0,0 +1,94 @@ +........................................................................ [ 3%] +........................................................................ [ 7%] +........................................................................ [ 11%] +........................................................................ [ 15%] +........................................................................ [ 18%] +........................................................................ [ 22%] +........................................................................ [ 26%] +........................................................................ [ 30%] +........................................................................ [ 34%] +........................................................................ [ 37%] +........................................................................ [ 41%] +........................................................................ [ 45%] +........................................................................ [ 49%] +........................................................................ [ 52%] +........................................................................ [ 56%] +........................................................................ [ 60%] +........................................................................ [ 64%] +........................................................................ [ 68%] +........................................................................ [ 71%] +........................................................................ [ 75%] +........................................................................ [ 79%] +........................................................................ [ 83%] +........................................................................ [ 87%] +........................................................................ [ 90%] +........................................................................ [ 94%] +........................................................................ [ 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 178 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 94 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 229 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 29 0 100% +app/api/sync.py 101 0 100% +app/api/tokens.py 28 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 133 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 33 0 100% +app/db.py 21 0 100% +app/main.py 66 0 100% +app/models.py 124 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/folder_summaries.py 108 0 100% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 190 3 98% +app/rag/llm.py 217 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +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 24 0 100% +app/schemas.py 269 0 100% +-------------------------------------------------- +TOTAL 3552 12 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 diff --git a/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__02_sync_wiring.a1.err b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__02_sync_wiring.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__02_sync_wiring.a1.md b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__02_sync_wiring.a1.md new file mode 100644 index 0000000..428828c --- /dev/null +++ b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__02_sync_wiring.a1.md @@ -0,0 +1 @@ +(no final assistant message — see the .err log) diff --git a/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__02_sync_wiring.a1.validate b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__02_sync_wiring.a1.validate new file mode 100644 index 0000000..620dfb0 --- /dev/null +++ b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__02_sync_wiring.a1.validate @@ -0,0 +1,94 @@ +........................................................................ [ 3%] +........................................................................ [ 7%] +........................................................................ [ 11%] +........................................................................ [ 15%] +........................................................................ [ 18%] +........................................................................ [ 22%] +........................................................................ [ 26%] +........................................................................ [ 30%] +........................................................................ [ 34%] +........................................................................ [ 37%] +........................................................................ [ 41%] +........................................................................ [ 45%] +........................................................................ [ 49%] +........................................................................ [ 52%] +........................................................................ [ 56%] +........................................................................ [ 60%] +........................................................................ [ 64%] +........................................................................ [ 68%] +........................................................................ [ 71%] +........................................................................ [ 75%] +........................................................................ [ 79%] +........................................................................ [ 83%] +........................................................................ [ 87%] +........................................................................ [ 90%] +........................................................................ [ 94%] +........................................................................ [ 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 178 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 94 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 229 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 29 0 100% +app/api/sync.py 101 0 100% +app/api/tokens.py 28 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 133 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 33 0 100% +app/db.py 21 0 100% +app/main.py 66 0 100% +app/models.py 124 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/folder_summaries.py 108 0 100% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 190 3 98% +app/rag/llm.py 217 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +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 24 0 100% +app/schemas.py 269 0 100% +-------------------------------------------------- +TOTAL 3552 12 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 diff --git a/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__02_sync_wiring.a2.err b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__02_sync_wiring.a2.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__02_sync_wiring.a2.md b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__02_sync_wiring.a2.md new file mode 100644 index 0000000..eb5c988 --- /dev/null +++ b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__02_sync_wiring.a2.md @@ -0,0 +1,14 @@ +All green. Everything for task 02 is implemented, tested, and verified. + +**Report — task 02 (sync wiring) completed** + +- `scripts/import_docs.py`: `_run()` regenerates `folder_summaries` under the overview's change gate (added+updated>0) or the table-empty first-run trigger; same event loop/LLMClient; generator flushes, the run's own short-lived session commits (phase-53 convention); stats on the summary line as `folder_summaries=//` or `skipped`; docstrings updated +- `app/api/sync.py`: step 5 calls the same generator + gate (change or empty table); per-folder fail-soft (a `lite` outage never flips the run and never blocks the bump); stats log-only — `/api/sync/status` detail shape untouched +- `app/rag/folder_summaries.py`: added `folder_summary_table_empty()` gate probe (LIMIT 1) +- New `tests/integration/test_sync_folder_summaries.py` (10 tests: changed/unchanged/prune/fail-soft/`--limit`/empty-table on both paths); updated overview/git/sync-API/sync-button suites (pinned line endings + layer stubs per the line-extension rule); +1 unit test for the gate probe + +- `uv run pytest --cov=app --cov-report=term-missing` → **1914 passed**, app/ coverage **99%** (>90%) +- `uv run pytest tests/integration/test_sync_folder_summaries.py -v --no-cov` → 10 passed +- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors + +Decisions: line token format `G/F/P|skipped`; `--limit` skip enforced at the caller (early return) with the generator's `skip` flag as tested backstop; API folder gate = change ∨ empty table, overview gate unchanged. Next pending: `03_ls_tree.md`. diff --git a/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__02_sync_wiring.a2.validate b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__02_sync_wiring.a2.validate new file mode 100644 index 0000000..6a2f100 --- /dev/null +++ b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__02_sync_wiring.a2.validate @@ -0,0 +1,94 @@ +........................................................................ [ 3%] +........................................................................ [ 7%] +........................................................................ [ 11%] +........................................................................ [ 15%] +........................................................................ [ 18%] +........................................................................ [ 22%] +........................................................................ [ 26%] +........................................................................ [ 30%] +........................................................................ [ 33%] +........................................................................ [ 37%] +........................................................................ [ 41%] +........................................................................ [ 45%] +........................................................................ [ 48%] +........................................................................ [ 52%] +........................................................................ [ 56%] +........................................................................ [ 60%] +........................................................................ [ 63%] +........................................................................ [ 67%] +........................................................................ [ 71%] +........................................................................ [ 75%] +........................................................................ [ 78%] +........................................................................ [ 82%] +........................................................................ [ 86%] +........................................................................ [ 90%] +........................................................................ [ 94%] +........................................................................ [ 97%] +.......................................... [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 178 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 94 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 229 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 29 0 100% +app/api/sync.py 110 0 100% +app/api/tokens.py 28 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 133 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 33 0 100% +app/db.py 21 0 100% +app/main.py 66 0 100% +app/models.py 124 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 222 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/folder_summaries.py 110 0 100% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 190 3 98% +app/rag/llm.py 217 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +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 24 0 100% +app/schemas.py 269 0 100% +-------------------------------------------------- +TOTAL 3563 12 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 diff --git a/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__03_ls_tree.a1.err b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__03_ls_tree.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__03_ls_tree.a1.md b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__03_ls_tree.a1.md new file mode 100644 index 0000000..3c78f5b --- /dev/null +++ b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__03_ls_tree.a1.md @@ -0,0 +1,11 @@ +All gates green. Task complete — final report: + +**Phase 94 · task 03 (drill-down `ls`) — DONE, no commit** + +- `app/rag/agent.py`: `ls` branch rewritten to the 3-level tree — `ls_top`/`ls_folder` (+ pure `group_folder_listing`, `render_ls_top`/`render_folder_listing`); new `NOT_A_FOLDER` (parent's subfolders), `LS_PATH_NOT_A_SOURCE` deleted; 50-file cap + grep-pointer note; dead `list_catalog` removed; `AGENT_TOOLS` description pinned. +- `app/rag/prompts.py`: TOOLS_SECTION drill-down teaching (read/grep clauses byte-identical). +- `app/api/chat.py` + `scripts/agent_realmodel_check.py`: comment/script repointed off the deleted accessor. +- Tests: unit (agent/prompts/mock-flow) + integration (real-DB tree, cap, refusals, ordering) rewritten; 5 E2E suites updated for the drill frame (git_sources TRUNCATE + commit-between-inserts seed ordering — UUID tie-break is random, `added_at` is per-transaction). +- Gates: `pytest` **1939 passed**; `app/` coverage **99%** (gate >90%); ruff clean; pyright 0 errors; E2E in isolation — document-tools 4✓, unlimited 4✓, harness-aligned 3✓, path-teaching 2✓, big-read 4✓, search 3✓, grep-teaching 2✓. + +Next pending: `04_e2e_drilldown.md` (not started). diff --git a/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__03_ls_tree.a1.validate b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__03_ls_tree.a1.validate new file mode 100644 index 0000000..7f9cabd --- /dev/null +++ b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__03_ls_tree.a1.validate @@ -0,0 +1,94 @@ +........................................................................ [ 3%] +........................................................................ [ 7%] +........................................................................ [ 11%] +........................................................................ [ 14%] +........................................................................ [ 18%] +........................................................................ [ 22%] +........................................................................ [ 25%] +........................................................................ [ 29%] +........................................................................ [ 33%] +........................................................................ [ 37%] +........................................................................ [ 40%] +........................................................................ [ 44%] +........................................................................ [ 48%] +........................................................................ [ 51%] +........................................................................ [ 55%] +........................................................................ [ 59%] +........................................................................ [ 63%] +........................................................................ [ 66%] +........................................................................ [ 70%] +........................................................................ [ 74%] +........................................................................ [ 77%] +........................................................................ [ 81%] +........................................................................ [ 85%] +........................................................................ [ 89%] +........................................................................ [ 92%] +........................................................................ [ 96%] +................................................................... [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 178 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 94 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 229 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 29 0 100% +app/api/sync.py 110 0 100% +app/api/tokens.py 28 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 133 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 33 0 100% +app/db.py 21 0 100% +app/main.py 66 0 100% +app/models.py 124 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 306 1 99% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/folder_summaries.py 110 0 100% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 190 3 98% +app/rag/llm.py 217 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +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 24 0 100% +app/schemas.py 269 0 100% +-------------------------------------------------- +TOTAL 3647 13 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 diff --git a/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__04_e2e_drilldown.a1.err b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__04_e2e_drilldown.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__04_e2e_drilldown.a1.md b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__04_e2e_drilldown.a1.md new file mode 100644 index 0000000..04e37fe --- /dev/null +++ b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__04_e2e_drilldown.a1.md @@ -0,0 +1,18 @@ +All green. Task complete. + +## Report — Phase 94, Task 04 (E2E drill-down story suite) + +**Implemented** +- `tests/e2e/test_ls_tree_drilldown.py` (new, isolated, own module app port 8136): two local temp sources (`alpha` 56 docs incl. a 51-file `wide/` folder, `beta` 2 docs) registered via the authenticated API + real in-process `POST /api/sync`; pins the 6 stored `folder_summaries` rows byte-exactly (mock's canned `FOLDER_SUMMARY_MODE` text) +- 3 tests: 4-turn scripted drill `ls()` → `ls(alpha)` → `ls(alpha/two)` → `read(alpha/two/two-a.md)` (tool lines, wire `tool` frames, exact tree levels incl. summaries + file-line format, grounded citations); 51-file cap (exactly 50 lines + `…and 1 more…` note, 51st absent); NOT-A-FOLDER teaching + scripted recovery +- `tests/e2e/mock_llm.py`: new `DRILL_TRIGGER` scripted flow (question carries its own call: `drill down the tree: ls [target]` / `read source/path`) — tool call → echo the listing verbatim into the answer (house lens); recovery branch keys on receiving the NOT-A-FOLDER line; ALREADY_IN_CONTEXT branch answers from the `` block + +**Results** +- `uv run pytest tests/e2e/test_ls_tree_drilldown.py -v --no-cov` → 3 passed (in isolation, re-run twice) +- Regression gate in isolation: `test_agent_document_tools`, `test_agent_unlimited_tools`, `test_harness_aligned_tools`, `test_search_tool`, `test_grep_regex_teaching`, `test_sync_button`, `test_local_directory_sources`, `test_response_to_docs` → all exit 0, 0 failures +- `uv run pytest --cov=app --cov-report=term-missing` → 1939 passed, app coverage 99% (>90%) +- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors + +**Notable decision**: the read target (its path is named in the question) deterministically ranks top-2, so the agent's phase-72 dedupe returns `ALREADY_IN_CONTEXT`; the mock then answers from the `` prompt with the same citation shape (prefixed `Already in context:`), which the suite pins — the document citation contract holds and the dedupe notice's visibility is asserted. + +**Next pending task**: `.agents/phases/todo/94_ls_tree_drilldown/05_realmodel_battery.md` diff --git a/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__04_e2e_drilldown.a1.validate b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__04_e2e_drilldown.a1.validate new file mode 100644 index 0000000..7f9cabd --- /dev/null +++ b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__04_e2e_drilldown.a1.validate @@ -0,0 +1,94 @@ +........................................................................ [ 3%] +........................................................................ [ 7%] +........................................................................ [ 11%] +........................................................................ [ 14%] +........................................................................ [ 18%] +........................................................................ [ 22%] +........................................................................ [ 25%] +........................................................................ [ 29%] +........................................................................ [ 33%] +........................................................................ [ 37%] +........................................................................ [ 40%] +........................................................................ [ 44%] +........................................................................ [ 48%] +........................................................................ [ 51%] +........................................................................ [ 55%] +........................................................................ [ 59%] +........................................................................ [ 63%] +........................................................................ [ 66%] +........................................................................ [ 70%] +........................................................................ [ 74%] +........................................................................ [ 77%] +........................................................................ [ 81%] +........................................................................ [ 85%] +........................................................................ [ 89%] +........................................................................ [ 92%] +........................................................................ [ 96%] +................................................................... [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 178 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 94 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 229 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 29 0 100% +app/api/sync.py 110 0 100% +app/api/tokens.py 28 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 133 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 33 0 100% +app/db.py 21 0 100% +app/main.py 66 0 100% +app/models.py 124 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 306 1 99% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/folder_summaries.py 110 0 100% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 190 3 98% +app/rag/llm.py 217 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +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 24 0 100% +app/schemas.py 269 0 100% +-------------------------------------------------- +TOTAL 3647 13 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 diff --git a/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__05_realmodel_battery.a1.err b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__05_realmodel_battery.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__05_realmodel_battery.a1.md b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__05_realmodel_battery.a1.md new file mode 100644 index 0000000..f5908e0 --- /dev/null +++ b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__05_realmodel_battery.a1.md @@ -0,0 +1,18 @@ +Task 05 complete — all green. + +**Implemented** +- Fixture dump refreshed: `load_test_kb` now generates sync-time folder summaries against live `lite` (4 rows: 2 source roots + `homelab/containers` + `homelab/networking`) and carries `folder_summaries` in TRUNCATE/INSERT/round-trip verify; `restore_test_kb` reports them — restore 0.03 s hot +- Fixed stale gate-side mirror: `classify_call` now scores `ls(source/folder)` drill-downs as contract-correct (mirrors task-03's resolution rules; previously the correct new behavior would have been flagged) +- Ran the 10-turn battery against the live endpoint, both models: configured `turbo` (verdict) and `lite` (same-model check vs the phase-70/72 standing, `.env` switched then restored byte-identical) +- Recorded the dated phase-94 section in `TOOL_CALLING_TESTING.md` (per-turn lines, verdicts, baseline table, conclusion) + moved Q1/2/3/10 expected-behavior notes with the owner-permitted surface (questions untouched) + §2/§5/§8 notes + +**Results** +- turbo: `PASS … calls 18/19 executed (95%) contract 19/19 (100%) (wall 98.7s)` — vs 112.8–113.7 s baseline: −12.5…−13.2 %, within band +- lite: `PASS … contract 18/18 (100%) (wall 43.6s)` — vs 40.4–40.5 s standing: +7.7 %, within band; no copy-lever iteration needed +- `uv run pytest --cov=app` → rc=0 (1939 passed, 2 skipped), TOTAL 99 %; `uv run ruff check .` clean; `uv run pyright` 0 errors + +**Decisions** +- Verdict run uses the configured model (`turbo`); `lite` run added for the same-model comparison the task's "phase 70/72 numbers" reference implies — both recorded +- Flagged for the owner (not a gate miss): `lite` answered the two "list everything" turns from the top level alone; lever noted in §9 if file-level depth is wanted + +**Next pending task:** `95_read_truncation_cap/01_read_cap.md` diff --git a/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__05_realmodel_battery.a1.validate b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__05_realmodel_battery.a1.validate new file mode 100644 index 0000000..7f9cabd --- /dev/null +++ b/.agents/reports/94_ls_tree_drilldown/94_ls_tree_drilldown__05_realmodel_battery.a1.validate @@ -0,0 +1,94 @@ +........................................................................ [ 3%] +........................................................................ [ 7%] +........................................................................ [ 11%] +........................................................................ [ 14%] +........................................................................ [ 18%] +........................................................................ [ 22%] +........................................................................ [ 25%] +........................................................................ [ 29%] +........................................................................ [ 33%] +........................................................................ [ 37%] +........................................................................ [ 40%] +........................................................................ [ 44%] +........................................................................ [ 48%] +........................................................................ [ 51%] +........................................................................ [ 55%] +........................................................................ [ 59%] +........................................................................ [ 63%] +........................................................................ [ 66%] +........................................................................ [ 70%] +........................................................................ [ 74%] +........................................................................ [ 77%] +........................................................................ [ 81%] +........................................................................ [ 85%] +........................................................................ [ 89%] +........................................................................ [ 92%] +........................................................................ [ 96%] +................................................................... [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 178 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 94 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 229 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 29 0 100% +app/api/sync.py 110 0 100% +app/api/tokens.py 28 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 133 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 33 0 100% +app/db.py 21 0 100% +app/main.py 66 0 100% +app/models.py 124 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 306 1 99% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/folder_summaries.py 110 0 100% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 190 3 98% +app/rag/llm.py 217 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +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 24 0 100% +app/schemas.py 269 0 100% +-------------------------------------------------- +TOTAL 3647 13 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 diff --git a/.env.example b/.env.example index 21d7bd9..ce2bf33 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,7 @@ BOR_STEERING_MAX_CHARS=8000 # char budget for the (steering notes BOR_SUMMARY_MAX_CHARS=12000 # cap on document content sent to the lite summary model (phase 30) BOR_KB_OVERVIEW_MAX_CHARS=4000 # char budget for the prompt section (phase 31) BOR_OVERVIEW_INPUT_MAX_CHARS=40000 # cap on the document list sent to the lite model for the KB outline (phase 31) +BOR_FOLDER_SUMMARY_INPUT_MAX_CHARS=8000 # cap on a folder's document list sent to the lite model (phase 94) BOR_CHUNK_TARGET_CHARS=2000 BOR_CHUNK_OVERLAP_CHARS=200 BOR_EMBED_BATCH_SIZE=16 diff --git a/TOOL_CALLING_TESTING.md b/TOOL_CALLING_TESTING.md index f5434d7..07a821d 100644 --- a/TOOL_CALLING_TESTING.md +++ b/TOOL_CALLING_TESTING.md @@ -127,14 +127,21 @@ state is snapshotted to **`tests/fixtures/test_kb.dump.sql`** — a data-only SQL script (TRUNCATE + one multi-row INSERT per app table: documents, chunks + embeddings, the `git_sources` local rows that make the source registry self-contained, the static KB overview, the -sources version). Restoring it puts the whole known state back in one -transaction; the generated `chunks.tsv` column is recomputed by -Postgres. The dump is verified by round-trip at build time (restore + -per-table checksum compare — a serialization bug fails the build). +sources version, and — since the 2026-09-11 phase-94 refresh — the +sync-time `folder_summaries` rows the drill-down `ls` shows). The +build generates those rows against the live `lite` endpoint through +the real sync-time generator (4 rows for this KB: the two source +roots + `homelab/containers` + `homelab/networking` — the ≥ 2-document +folders; single-document folders carry no row by design). Restoring it +puts the whole known state back in one transaction; the generated +`chunks.tsv` column is recomputed by Postgres. The dump is verified by +round-trip at build time (restore + per-table checksum compare — a +serialization bug fails the build). ```bash # Rebuild the KB + dump — only when the fixture documents, the -# chunker, or the embedding model change. NOT part of the loop. +# chunker, the embedding model, or the folder-summary prompt change +# (its `lite` output is baked in — phase 94). NOT part of the loop. uv run python -m scripts.load_test_kb # restores the fixture KB standalone (what --restore runs inline) uv run python -m scripts.restore_test_kb @@ -170,22 +177,31 @@ of it. | # | question | tests | expected ideal | |---|---|---|---| -| 1 | List the files in this directory. | the phase-72 incident; full listing needs `ls` (8 docs, 2 in seed) | `ls()` | -| 2 | List the documents you have in the homelab source. | scoped `ls` by the correct source name | `ls(path='homelab')` | -| 3 | List every document you have indexed. | no-arg listing | `ls()` | +| 1 | List the files in this directory. | the phase-72 incident; the top level lists the sources — a full file listing drills them (phase 94) | `ls()` → drill `ls('deployments')` + `ls('homelab')` (+ leaf folders) | +| 2 | List the documents you have in the homelab source. | scoped `ls` by the correct source name; the result is the source's folders (+ counts/summaries) — file lines come from the leaf drills (phase 94) | `ls(path='homelab')` (+ leaf drills) | +| 3 | List every document you have indexed. | the "list everything" expectation is now the whole tree — no single call lists every file (phase 94) | `ls()` → drill both sources and every folder | | 4 | Open the document homelab/networking/vela-bridges.md … | `read`, combined form | `read('homelab/networking/vela-bridges.md')` | | 5 | Read deployments/quadlet/mimir-service.md and summarize it. | `read`, unseeded target | `read(…)` (or `ls` first, then `read`) | | 6 | Open the document homelab/networking/meridian-notes.md … | `read`, unseeded target | `read(…)` | | 7 | Find the exact string "rbm-8842" in your documents … | `grep`, pattern only | `grep(pattern='rbm-8842')` — the match line alone answers it | | 8 | Which document has the title "Lab Ansible Inventory"? Summarize it. | title lookup; target IS seeded | answer from context (or `ls`) | | 9 | What do you know about the qwen 3.8 llama.cpp setup? … | topic lookup; target IS seeded | answer from context | -| 10 | List the files in the deployments directory. | source name phrased as a directory | `ls(path='deployments')` | +| 10 | List the files in the deployments directory. | source name phrased as a directory; the result is the source's folders — file lines come from the leaf drills (phase 94) | `ls(path='deployments')` (+ leaf drills) | Questions 4–6 name the **full combined identity** (no bare-path trap — that is the job of the locked derived battery, §6). Questions 7–9 name content, so their target document is seeded; the correct behavior there is to **not** re-read what is already in the prompt. +Phase-94 note: the listing questions' (1, 2, 3, 10) correct behavior +**moved with the owner-permitted `ls` surface change** (2026-09-10, +`TODO.md` L4 — `00_phase.md` of `94_ls_tree_drilldown`): a full file +listing is now a drill sequence (`ls()` → source → folder; one level +per call, the old whole-KB flood is structurally gone), and a folder +line's count + sync-time summary is the tree's picture of that +subtree. The questions themselves are unchanged (the methodology's +locked battery) — only the expected-behavior definition moved. + ### The four pass conditions 1. all 10 turns answer (no `LLMError`/`MalformedReplyError`); @@ -205,10 +221,11 @@ gate: lite PASS turns=10 answered=10 caps=0 tool-turns=10 calls 8/13 executed (6 gate: lite PASS turns=10 answered=10 caps=0 tool-turns=10 calls 7/11 executed (64%) contract 11/11 (100%) (wall 46.8s) gate: lite PASS turns=10 answered=10 caps=0 tool-turns=9 calls 9/12 executed (75%) contract 11/12 (92%) 2026-09-06 (wall 40.4s) gate: lite PASS turns=10 answered=10 caps=0 tool-turns=10 calls 9/14 executed (64%) contract 13/14 (93%) 2026-09-06 (wall 40.5s) +gate: lite PASS turns=10 answered=10 caps=0 tool-turns=9 calls 13/18 executed (72%) contract 18/18 (100%) 2026-09-11 (wall 43.6s) [phase-94 ls tree] ``` -Contract accuracy ≥ 90 %: **met** (100 / 92 / 100 / 93 / 92 / 93). The executed -ratio sits at 58–73 % for the reason documented in §5 — an app +Contract accuracy ≥ 90 %: **met** (100 / 92 / 100 / 93 / 92 / 93 / 100). The +executed ratio sits at 58–73 % for the reason documented in §5 — an app semantics choice, not a model defect, and the open design question in §7. @@ -222,6 +239,7 @@ gate: turbo FAIL turns=10 answered=10 caps=0 tool-turns=5 calls 5/5 executed (10 gate: turbo PASS turns=10 answered=10 caps=0 tool-turns=7 calls 9/9 executed (100%) contract 9/9 (100%) 2026-09-06 (wall 113.7s) gate: turbo PASS turns=10 answered=10 caps=0 tool-turns=7 calls 7/7 executed (100%) contract 7/7 (100%) 2026-09-06 (wall 112.8s) gate: turbo FAIL turns=10 answered=10 caps=0 tool-turns=5 calls 5/5 executed (100%) contract 5/5 (100%) 2026-09-06 (wall 97.2s) [derived battery — MISS: 5/10 tool-turn floor] +gate: turbo PASS turns=10 answered=10 caps=0 tool-turns=7 calls 18/19 executed (95%) contract 19/19 (100%) 2026-09-11 (wall 98.7s) [phase-94 ls tree] ``` Reads: the re-read habit is model-specific. `lite` re-reads a seeded @@ -304,7 +322,12 @@ The verdict carries both: combined `source/path` belongs, a nonexistent document identity, a source name where a document belongs (`ls(path='.')`, `ls(path='/')`, `grep(path='homelab')` — the entire phase-72 - incident class). + incident class). Phase 94 moved the `ls` contract (owner-permitted + surface revision): `ls(path)` is ALSO contract-correct for an + EXISTING `source/folder` — the drill-down; the violation is now an + unknown first segment, a bare folder name (no source prefix), or a + folder matching no indexed prefix. The gate-side mirror + (`classify_call` in `scripts/agent_realmodel_check.py`) follows. - **executed/emitted** (the phase-72 locked metric) = calls the app actually executed ÷ emitted. Every refusal class counts against it — **including `ALREADY_IN_CONTEXT`**, the app's dedupe refusal when @@ -394,7 +417,8 @@ podman compose up -d db cp .env.example .env # once; LLM endpoint + DB URL uv run alembic upgrade head -# 1. Build the controlled KB + dump (one-off, ~2 s — real embeddings) +# 1. Build the controlled KB + dump (one-off, ~7 s — real embeddings + +# 4 live-lite folder-summary calls since phase 94) uv run python -m scripts.load_test_kb # → prints the retrieval report (all 10 must be grounded) and # verifies the dump by round-trip. @@ -421,3 +445,147 @@ Diagnosing a bad run: every call is logged by `run_agent` refusal templates in `app/rag/agent.py` to see which teaching line the model hit, and which refusal class (contract violation vs. in-context dedupe) the rejection was. + +--- + +## 9. Phase 94 — the `ls` drill-down tree (2026-09-11) + +Phase 94 rebuilt `ls` as a drill-down tree (owner-permitted +tool-surface revision, `TODO.md` L4 — the `ls` result format and +`path` semantics changed; the tool name and the `read`/`grep` +contract are untouched): `ls()` lists the synced sources with a +per-source sync-time summary, `ls(source)` lists its folders (each +with its summary) + its own files, `ls(source/folder)` drills one +level deeper; file lines are capped at 50 with a grep pointer. This +section is the controlled battery's verdict on that architecture — +the TODO's gate: "ensure accuracy and performance aren't too badly +impacted", read per the phase-94 task file as **accuracy at or above +the recorded baseline** (no contract/verdict regression on the +10-question battery) and **wall time within ~20% of the baseline +total**. + +**Fixture dump refresh (a precondition of this run).** The 2026-09-04 +dump predates migration 0017 — it had no `folder_summaries` rows, so +the drill-down's summaries would have been absent from the controlled +KB. It was rebuilt on 2026-09-11 with +`uv run python -m scripts.load_test_kb`: the same 8 hand-written +documents through the real pipeline (real embeddings), the static KB +overview + sources version re-stored, and the sync-time folder +summaries generated against the live `lite` endpoint (4 rows: the two +source roots + `homelab/containers` + `homelab/networking` — the +≥ 2-document folders). The dump now carries the `folder_summaries` +table (TRUNCATE + INSERT + round-trip checksum verification — the +house rule); `--restore` measured **0.03 s** hot. + +**The classifier mirror followed the surface.** `classify_call` +(`scripts/agent_realmodel_check.py`) is documented as the gate-side +mirror of `app.rag.agent._execute_tool`'s resolution rules — task 03 +changed the `ls` resolution (a `source/folder` path is now valid) but +the mirror still flagged every folder drill-down as a contract +violation. Fixed in this phase: `ls(path)` is contract-correct for +`""`, a registered source, or an EXISTING `source/folder`; without +the fix the battery would have scored the *correct* new behavior as +inaccurate. (No battery question changed; the §3 expected-behavior +notes moved with the owner-permitted surface.) + +**The runs.** Against the live aipi endpoint, the refreshed fixture +KB, the real grounded path — two models: the **configured** chat +model (`turbo` per `.env` — the verdict) and `lite` (the `.env` model +temporarily switched, then restored byte-identical — the house +procedure of the 2026-09-05 turbo comparison) to check the +phase-70/72 standing block, which was recorded under `lite`. The +micro-loop (`--turns 3` — the three listing turns) ran twice on +`turbo` first: 12/12 and 13/13 contract (100 %), zero caps — so the +full battery went straight to the verdict, with no copy-lever +iteration needed (the first full run on each model was green). + +``` +$ uv run python -m scripts.agent_realmodel_check --restore --mode fixture +restore: ok in 0.03s (8 docs, 2 sources) +turn 01 | emitted=1 executed=1 cap=no defl=no | 6.80s | List the files in this directory. +turn 02 | emitted=3 executed=3 cap=no defl=no | 9.77s | List the documents you have in the … +turn 03 | emitted=7 executed=7 cap=no defl=no | 14.83s | List every document you have indexed. +turn 04 | emitted=0 executed=0 cap=no defl=no | 8.89s | Open the document … +turn 05 | emitted=1 executed=1 cap=no defl=no | 12.48s | Read … +turn 06 | emitted=1 executed=1 cap=no defl=no | 10.03s | Open the document … +turn 07 | emitted=1 executed=1 cap=no defl=no | 6.85s | Find the exact string "rbm-8842" in … +turn 08 | emitted=0 executed=0 cap=no defl=no | 7.92s | Which document has the title "Lab … +turn 09 | emitted=0 executed=0 cap=no defl=no | 10.90s | What do you know about the qwen 3.8 … +turn 10 | emitted=5 executed=4 cap=no defl=no | 10.08s | List the files in the deployments … +gate: turbo PASS turns=10 answered=10 caps=0 tool-turns=7 calls 18/19 executed (95%) contract 19/19 (100%) 2026-09-11 (wall 98.7s) +``` + +``` +(same command; `.env` chat model temporarily `lite`, restored after) +turn 01 | emitted=1 executed=1 cap=no defl=no | 3.82s | List the files in this directory. +turn 02 | emitted=8 executed=5 cap=no defl=no | 10.93s | List the documents you have in the … +turn 03 | emitted=1 executed=1 cap=no defl=no | 2.25s | List every document you have indexed. +turn 04 | emitted=1 executed=0 cap=no defl=no | 3.84s | Open the document … +turn 05 | emitted=3 executed=3 cap=no defl=no | 4.99s | Read … +turn 06 | emitted=1 executed=1 cap=no defl=no | 5.64s | Open the document … +turn 07 | emitted=1 executed=1 cap=no defl=no | 2.25s | Find the exact string "rbm-8842" in … +turn 08 | emitted=1 executed=0 cap=no defl=no | 5.09s | Which document has the title "Lab … +turn 09 | emitted=0 executed=0 cap=no defl=no | 2.93s | What do you know about the qwen 3.8 … +turn 10 | emitted=1 executed=1 cap=no defl=no | 1.75s | List the files in the deployments … +gate: lite PASS turns=10 answered=10 caps=0 tool-turns=9 calls 13/18 executed (72%) contract 18/18 (100%) 2026-09-11 (wall 43.6s) +``` + +**Per-turn reading.** Both runs show the designed drill-down: the +listing turns hop `ls()` → source → folder (turbo turn 03: top level ++ both sources + five leaf folders in 7 calls; lite turn 02: +`ls('homelab')` + all three homelab folders), the read turns keep the +combined `source/path` form (turbo turn 05 drills +`ls('deployments')` → `ls('deployments/quadlet')` before the read — +the "or `ls` first, then `read`" branch), the grep turn is +pattern-only, and the seeded discipline turns (04/08/09) answer from +the `` context. Zero cap hits, zero contract violations, +10/10 answered on both models. The non-executed emitted calls are all +contract-correct (the contract is 100 %): turbo's one (turn 10) is a +second call in a single reply that the one-tool-per-reply rule +discards; lite's five are the same discard class (three on turn 02) +plus the two documented copy-invariant `ALREADY_IN_CONTEXT` re-reads +of seeded documents (turns 04/08 — §4: 15/15 across every copy +variant tried). lite turn 02 also carried one exploratory +`grep(pattern='homelab')` (pattern-only — contract-correct, executed, +then the proper folder drills in the following rounds — the same +self-correcting exploration class §5 documents pre-94). + +**Baseline comparison.** Against the last recorded baseline per model +(the 2026-09-06 runs for `turbo`; the phase-70/72 standing block for +`lite`): + +| model | baseline wall | phase-94 wall | Δ wall | baseline contract | phase-94 contract | +|---|---|---|---|---|---| +| `turbo` (configured — the verdict) | 112.8–113.7 s | 98.7 s | −12.5 % to −13.2 % | 100 % (7/7, 9/9) | 100 % (19/19) | +| `lite` (phase-70/72 standing) | 40.4–40.5 s | 43.6 s | +7.7–7.9 % | 92–100 % (last: 92 / 93) | 100 % (18/18) | + +Both inside the ~20 % band — `turbo` *under* its same-model baseline +despite executing MORE calls (18 vs 7–9: the drill-down adds rounds, +but per-round latency was lower on this day than on the baseline runs +— wall is endpoint-load-sensitive, which is why a band, not a point, +is the bar), `lite` +7.7 % — the modest increase the task file +expects from a few extra `ls` hops. Accuracy: contract at or above +the same-model baseline on both models; verdict PASS on both; the +executed ratios (95 % / 72 %) sit in the documented bands (§4/§5 — +discard class + app-semantics, not tool-calling errors). + +**Conclusion.** The phase-94 gate is **met**: accuracy at or above +baseline (100 % contract, PASS, zero cap hits, zero contract +violations — on both models) and wall time within the 20 % band on +both models (−12.5/−13.2 % same-model `turbo`, +7.7 % `lite` +standing). The phase-72 incident class is gone under the tree: the +flood is structurally impossible (one level per call, 50-line cap) +and the listing turns drill cleanly with the folder summaries doing +the steering. No copy-lever change was made (nothing regressed to +iterate on). + +**Observation for the owner (not a gate miss).** The top level now +carries the two source summaries, and on the `lite` run the two +"list everything" turns (01, 03) answered after a single `ls()` — +from the source-level lines — without drilling to the files; `turbo` +drilled (3 / 7 calls on the same turns). The gate measures +contract/verdict, which is met either way; if the owner wants +listing answers to reach the file level by default, the lever is the +`ls` description / `TOOLS_SECTION` copy (teach that a folder's file +lines appear only after drilling into it) — flagged here rather than +acted on unilaterally. diff --git a/alembic/versions/0017_folder_summaries.py b/alembic/versions/0017_folder_summaries.py new file mode 100644 index 0000000..3085a72 --- /dev/null +++ b/alembic/versions/0017_folder_summaries.py @@ -0,0 +1,63 @@ +"""folder_summaries: sync-time lite folder summaries (phase 94, task 01) + +Revision ID: 0017 +Revises: 0016 +Create Date: 2026-09-10 + +Phase 94 (``ls`` becomes a filesystem-style drill-down tree — TODO.md +L4): the LLM lists the synced projects, then drills into folders; each +level's listing shows the folder's **subtree summary** — a 1–3 sentence +plain-text description of what the folder's documents cover, generated +at sync time by the aipi ``lite`` model (the ``KB_OVERVIEW_MODE`` +contract, ``app.rag.overview``: change-gated by the caller, fail-soft, +``FOLDER_SUMMARY_MODE`` marker for the E2E mock). This migration adds +the storage — ONE additive, fully reversible table (A13), no other +schema change: + +* ``folder_summaries`` — one row per folder with ≥ 2 documents + (recursive count — the same set the ``ls`` count rule counts, + ``00_phase.md``; the ≥ 2 rule and the prune rule are generator + policy, ``app.rag.folder_summaries``, not schema constraints): + + * PK ``(source, folder_path)`` — ``source`` is VARCHAR(120) NOT NULL + (mirrors ``documents.source``); ``folder_path`` is VARCHAR(1000) + NOT NULL (mirrors ``documents.path`` — the source-relative folder + prefix). ``folder_path = ""`` is the SOURCE ROOT: the top-level + source summary (the whole source's recursive subtree). + * ``summary`` — TEXT NOT NULL: the ``lite``-written description + (never empty — the generator validates before storing). + * ``updated_at`` — TIMESTAMPTZ NOT NULL, server default now() (house + style — the ``kb_overview.updated_at`` precedent); the generator + always stamps it explicitly on upsert. +""" +from __future__ import annotations + +import sqlalchemy as sa + +from alembic import op + +revision = "0017" +down_revision = "0016" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "folder_summaries", + sa.Column("source", sa.String(length=120), primary_key=True), + sa.Column("folder_path", sa.String(length=1000), primary_key=True), + sa.Column("summary", sa.Text(), nullable=False), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + + +def downgrade() -> None: + # The table is the only 0017 artefact — dropping it leaves the 0016 + # schema byte-identical (A13, fully reversible). + op.drop_table("folder_summaries") diff --git a/app/api/chat.py b/app/api/chat.py index 24ba57e..3ad80eb 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -618,10 +618,10 @@ async def chat( ) return except Exception: # noqa: BLE001 — a tool call hit the DB mid-stream - # Phase 37: tool execution (list_catalog / find_document) - # runs inside the stream now; a mid-turn DB failure gets - # the same structured ``error`` event as the pre-stream - # retrieval path. + # Phase 37: tool execution (the drill-down ``ls`` / + # ``read`` / ``grep`` lookups) runs inside the stream + # now; a mid-turn DB failure gets the same structured + # ``error`` event as the pre-stream retrieval path. logger.exception( "chat: tool execution failed question=%r total_ms=%d", request.message, diff --git a/app/api/sync.py b/app/api/sync.py index c342d76..ec29788 100644 --- a/app/api/sync.py +++ b/app/api/sync.py @@ -41,7 +41,16 @@ decisions): (pruning covers the union; the CLI's no-prune default is unchanged); 5. when the import changed the KB (added + updated > 0), ``regenerate_overview`` refreshes the single ``kb_overview`` row - (phase 31 trigger, best-effort inside); + (phase 31 trigger, best-effort inside) — and ``generate_ + folder_summaries`` (phase 94, task 02) regenerates the stored folder + summaries (the drill-down ``ls``'s per-level descriptions): its gate + is the same change trigger **plus** an empty ``folder_summaries`` + table (the first sync after migration 0017 — the KB may predate the + table). It is per-folder fail-soft (a ``lite`` outage keeps the + failed folders' previous rows and never flips the run to + ``failed``) and only flushes — this run's own short-lived session + commits (the phase-53 convention), so a folder failure never blocks + step 6's bump; 6. when the import changed the KB (added + updated + pruned > 0 — the saved-chat invalidation gate, phase 53 task 02: a pruned document can invalidate a saved answer that cited it, deliberately broader @@ -79,6 +88,7 @@ from app.config import get_settings from app.core.auth import require_admin from app.core.errors import sanitize_error as _sanitize_error from app.db import SessionLocal +from app.rag.folder_summaries import folder_summary_table_empty, generate_folder_summaries from app.rag.git_sources import effective_sources from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient, check_models @@ -251,6 +261,26 @@ async def _run_sync() -> None: overview = False if summary.added + summary.updated > 0: overview = await regenerate_overview(llm) + # Phase 94 (task 02): the folder summaries — the drill-down + # ls's per-level descriptions. Same change gate as the overview + # (added + updated > 0), plus the table-empty first-run trigger + # (the first sync after migration 0017 — the KB may have been + # imported by the CLI before the table landed). The generator is + # per-folder fail-soft (a lite outage never flips the run to + # failed) and only flushes: this run's own short-lived session + # commits (the phase-53 convention), so the step-6 bump stays + # change-gated on the KB, not on the summaries. No status-surface + # change: the stats are log-only (the detail shape is untouched). + fs_db = SessionLocal() + try: + if summary.added + summary.updated > 0 or folder_summary_table_empty(fs_db): + folder_stats = await generate_folder_summaries(fs_db, llm) + fs_db.commit() + logger.info("sync: folder_summaries stats=%s", folder_stats) + else: + logger.info("sync: folder_summaries skipped (KB unchanged)") + finally: + fs_db.close() # Phase 53 (task 02): a sync that changed the KB advances the # sources version exactly once — the saved-chat invalidation # marker (task 03 stamps rows against it). The gate is diff --git a/app/config.py b/app/config.py index 5bf8540..ff0cf82 100644 --- a/app/config.py +++ b/app/config.py @@ -135,6 +135,14 @@ class Settings(BaseSettings): #: ``app.rag.overview``). Overflow is cut at the cap and the shared #: ``[…truncated…]`` marker is appended (summarizer convention). overview_input_max_chars: int = 40_000 + #: Cap on the folder document list (path/title/first summary line per + #: row) sent to the ``lite`` folder-summary model in ONE call + #: (phase 94, ``app.rag.folder_summaries``). Overflow is cut at the + #: cap and the shared ``[…truncated…]`` marker is appended + #: (summarizer convention). Smaller than the KB-overview cap on + #: purpose: a folder sees its own subtree only — there can be + #: hundreds of folders, each summarized separately at sync time. + folder_summary_input_max_chars: int = 8_000 #: Hard cap on the agent tool rounds per grounded turn (phase 45, #: revising phase 37's per-tool budgets — owner permission #: 2026-08-27, TODO L8: "allow the LLM to make as many tool calls diff --git a/app/models.py b/app/models.py index 28ddc5e..94c4534 100644 --- a/app/models.py +++ b/app/models.py @@ -63,6 +63,10 @@ Data model — see ``.agents/PLAN.md`` §Data Model: (``id = 1``); every column NULL = "use the default" (env value for the strings, the built-in palette for the colors — task 01). +* ``folder_summaries`` — one row per folder with ≥ 2 documents: + the sync-time ``lite`` summary the drill-down + ``ls`` shows next to each folder (phase 94; + ``folder_path = ""`` = the source root). """ from __future__ import annotations @@ -446,3 +450,51 @@ class UiSettings(Base): accent_bg: Mapped[str | None] = mapped_column(String(7), nullable=True) accent_ink: Mapped[str | None] = mapped_column(String(7), nullable=True) accent_line: Mapped[str | None] = mapped_column(String(7), nullable=True) + + +class FolderSummary(Base): + """One sync-time folder summary (phase 94, task 01). + + ``ls`` is a drill-down tree (phase 94, ``00_phase.md``): the top + level lists the synced projects, each with its stored source-root + summary; drilling into a source lists its folders, each with its + stored folder summary. This table holds those summaries: + + * PK ``(source, folder_path)`` — ``source`` mirrors + ``documents.source`` (String(120)); ``folder_path`` mirrors + ``documents.path`` (String(1000)) and is the source-relative + folder prefix. ``folder_path = ""`` is the SOURCE ROOT — the + top-level source summary (the whole source's recursive subtree). + * Rows exist only for folders with ≥ 2 documents (recursive count + — the same set the ``ls`` count rule counts): a + single-document folder is fully described by its one file line, + so no ``lite`` burn. After a changed sync, rows whose folder + dropped below 2 documents are pruned (a pruned/renamed folder's + summary would otherwise go stale); rows for folders that still + have ≥ 2 documents persist (an unchanged folder's summary is + still true). Both rules are generator policy (``app.rag. + folder_summaries``), not schema constraints. + * ``summary`` — the 1–3 sentence plain-text description the aipi + ``lite`` model wrote at sync time (``FOLDER_SUMMARY_MODE``, + ``app.rag.folder_summaries`` — change-gated and fail-soft like + the KB overview: an old summary is better than none). + + Chat turns only READ these rows (the agent's ``ls`` output, phase + 94 task 03) — generation happens at sync time only (task 02). + """ + + __tablename__ = "folder_summaries" + + #: Mirrors ``documents.source`` (String(120)) — PK part 1. + source: Mapped[str] = mapped_column(String(120), primary_key=True) + #: Mirrors ``documents.path`` (String(1000)) — the source-relative + #: folder prefix; ``""`` = the source root. PK part 2. + folder_path: Mapped[str] = mapped_column(String(1000), primary_key=True) + #: The lite-written 1–3 sentence description — never empty (the + #: generator validates before storing, ``app.rag.folder_summaries``). + summary: Mapped[str] = mapped_column(Text) + #: Fresh UTC stamp on every upsert (the ``kb_overview.updated_at`` + #: precedent; the generator always sets it explicitly). + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) diff --git a/app/rag/agent.py b/app/rag/agent.py index df3bd00..bf87bcf 100644 --- a/app/rag/agent.py +++ b/app/rag/agent.py @@ -1,5 +1,6 @@ """Agent loop: the grounded-turn document tools (phase 37, task 03; the -harness-aligned ``ls``/``read``/``grep`` surface, phase 70). +harness-aligned ``ls``/``read``/``grep`` surface, phase 70; the +drill-down tree ``ls`` + sync-time folder summaries, phase 94). Probe verdict (task 01 — ``uv run python -m scripts.llm_probe --tools`` run live against aipi): **``probe: turbo tool_calls=supported 2026-08-26``** @@ -48,14 +49,41 @@ task 04): split (with its self-correction and "teach the split" refusals) is gone — the model's combined form is now simply correct. The phase-68 A5 match/output contract rides along under the new name. + Phase 94 revision (owner permission 2026-09-10, ``TODO.md`` L4 — the + tool-surface revision recorded in the phase 94 overview ``00_phase.md``; + PLAN.md is being redone by the owner): the ``ls`` RESULT format and + ``path`` semantics changed — ``ls`` is now a filesystem-style + drill-down tree (no path: the synced sources with counts + stored + source-root summaries; a source name: its top-level folders + files; + a ``source/folder`` path: that folder's subfolders + files — the + model drills one level per call instead of flooding the whole + catalog into one result), while the tool NAME and the + ``read``/``grep`` contract (combined ``source/path``) are untouched. 2. Each tool call the model emits is executed server-side against - Postgres only (no LLM, no network): ``ls`` returns the indexed - catalog — one ``source: X | path: Y | title: Z`` line per document - (phase 63: labeled fields — unambiguous for LLM parsing), - ``GET /api/docs`` order (uncapped in v1; the UI never shows it, only - the model does) — optionally scoped to one source name (a ``path`` - argument matching no source name is a refusal; a registered source - with no indexed documents lists as ``0 documents:`` and counts) — + Postgres only (no LLM, no network): ``ls`` lists ONE level of the + drill-down tree (phase 94, task 03) — no ``path``: every registered + source (registry order; a 0-document source still lists) as + ``{source} — {n} documents`` plus the indented stored source-root + summary line when one is in ``folder_summaries``; a ``path`` that is + a registered source name (no ``/``): that source's root folder — + each direct subfolder `` {sub}/ — {m} documents[: {summary}]`` + (the count is the subfolder's recursive subtree — every document + whose path equals the folder or starts with ``folder + "/"``, the + same set the sync-time folder summary describes — and the file + lines ``source: X | path: Y | title: Z`` (the canonical + ``read``/``grep`` identity — the phase-63 labeled format, + unchanged) in path order (``GET /api/docs`` order), capped at + :data:`LS_MAX_FILE_LINES` lines + one deterministic grep-pointer + note for the rest (a 500-file folder costs 50 lines, never 500); + a ``source/folder`` ``path``: that folder's subfolders + own file + lines (the same template, ``identity = source + "/" + folder``); + a registered source with no documents lists its header line alone + (``… — 0 documents, 0 folders:`` — the old ``0 documents:`` + behavior preserved in spirit) — every successful listing (top/root/ + folder) counts; a ``source/…`` argument whose first segment names no + registered source is the no-source refusal (below, the segment + echoed), and a folder segment matching no indexed prefix is the + :data:`NOT_A_FOLDER` teaching (below) — ``read`` takes the combined ``source/path`` string, splits it at the FIRST ``'/'`` (source names are directory basenames — they can never contain ``'/'``), and returns the document's **full** content @@ -82,15 +110,19 @@ task 04): blank or non-string) → ``"read requires a string argument 'path'."``; a ``grep`` without a usable ``pattern`` (missing, blank or non-string) → ``"grep requires a string argument - 'pattern'."``; a scoped ``ls`` whose (stripped) ``path`` contains a - ``/`` — a document path where a source name belongs (source names - are directory basenames and can never contain one; the 2026-09-03 - incident's ``ls(path='app/rag/importer.py')``) → - :data:`LS_PATH_NOT_A_SOURCE`, the document-path teaching line with - the argument echoed; a scoped ``ls`` whose ``path`` names no - registered source (no ``/`` — the incident's ``ls(path='.')``) - → :data:`NO_SOURCE_NOT_A_DIRECTORY`, the no-source refusal with the - teaching parenthetical appended; a document + 'pattern'."``; a scoped ``ls`` whose ``path``'s FIRST segment (split + at the first ``/``) names no registered source — a bare unknown + name (the 2026-09-03 incident's ``ls(path='.')``) or the source + segment of a ``source/…`` argument (phase 94: a ``/`` now names a + folder, so the phase-72 document-path teaching refusal is deleted) + → :data:`NO_SOURCE_NOT_A_DIRECTORY`, the + no-source refusal with the teaching parenthetical appended, the + segment echoed; a ``source/…`` argument whose folder segment + matches no indexed prefix (the phase-94 existence rule — some + indexed path of the source starts with ``folder + "/"``) → + :data:`NOT_A_FOLDER`, the drill-down teaching with the argument + echoed and the deepest existing ancestor's direct subfolders + listed, so the model self-corrects in the next round; a document already in context (seed or previously read) → :data:`ALREADY_IN_CONTEXT` (phase 72, task 05 gate iteration: the line names the correct action — answer from the text already @@ -164,25 +196,30 @@ needs none either (the tool ran) — the policy keys on the no-calls exit only. No model participates in detection or repair: the registry + the fixed retry policy are the whole guardrail. -The DB accessors (:func:`list_catalog`, :func:`list_source_names`, -:func:`find_document`, :func:`all_documents`) and the -:func:`grep_document` line matcher are module-level functions so unit -tests can monkeypatch them without a database. +The DB accessors — the drill-down ``ls`` (:func:`ls_top`, +:func:`ls_folder`; the pure grouping :func:`group_folder_listing` and +the pure renderers :func:`render_ls_top` / +:func:`render_folder_listing` sit next to them), +:func:`list_source_names`, :func:`find_document`, +:func:`all_documents` — and the :func:`grep_document` line matcher are +module-level functions so unit tests can monkeypatch them without a +database. """ from __future__ import annotations import json import logging import re -from collections.abc import AsyncIterator, Sequence +from collections.abc import AsyncIterator, Mapping, Sequence from dataclasses import dataclass, field from typing import Any -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session from app.config import Settings -from app.models import Document +from app.models import Document, FolderSummary +from app.rag.folder_summaries import folder_of from app.rag.git_sources import effective_sources from app.rag.llm import ( LLMClient, @@ -212,9 +249,17 @@ AGENT_TOOLS: list[dict[str, Any]] = [ "function": { "name": "ls", "description": ( - "List the indexed documents as `source: X | path: Y | " - "title: Z` lines. Call one tool at a time — wait for " - "this result before your next call." + "List the knowledge base as a tree, one level at a " + "time. With no path: the synced sources — each with " + "its document count and a summary of its contents. " + "With a source name (no '/'): that source's top-level " + "folders and files. With a `source/folder` path: that " + "folder's subfolders and files. Folder lines carry a " + "summary of what the folder contains. File lines are " + "`source: X | path: Y | title: Z` — use the combined " + "`source/path` with `read` and `grep`. Call one tool " + "at a time — wait for this result before your next " + "call." ), "parameters": { "type": "object", @@ -222,13 +267,11 @@ AGENT_TOOLS: list[dict[str, Any]] = [ "path": { "type": "string", "description": ( - "Source name to list one source's documents " - "(e.g. 'homelab') — a source name, not a " - "file or directory path; omit to list " - "every document. This is the only tool " - "whose `path` is a source name — for " - "`read` and `grep` it must be a document's " - "combined `source/path`." + "Optional — a source name (e.g. " + "'homelab') to list its top level, or a " + "`source/folder` path to drill down " + "(e.g. 'homelab/active'). Omit it to list " + "every source." ), } }, @@ -338,21 +381,6 @@ UNKNOWN_TOOL = "Unknown tool." MISSING_READ_ARGS = "read requires a string argument 'path'." MISSING_SEARCH_ARGS = "grep requires a string argument 'pattern'." -#: Teaching refusal for a scoped ``ls`` whose stripped ``path`` -#: contains a ``/`` (phase 72): a source name is a directory basename -#: and can never contain one, so the argument is a document path passed -#: where a source name belongs (the 2026-09-03 incident's -#: ``ls(path='app/rag/importer.py')``). One ``{path}`` field — the -#: argument echoed; a fixed template states the correct contract -#: instead of the terse pre-phase-72 line, so the harness-prior misuse -#: self-corrects in one round. -LS_PATH_NOT_A_SOURCE = ( - "'{path}' looks like a document path, not a source name. The " - "'path' argument of ls filters by source name (e.g. 'homelab') — " - "omit it to list every document, or read a document by its " - "combined 'source/path' string." -) - #: The no-source ``ls`` refusal with the teaching parenthetical #: appended (phase 72): used when a stripped scope has no ``/`` and #: matches no registered source (the incident's ``ls(path='.')``). The @@ -394,6 +422,29 @@ NO_DOCUMENT_DID_YOU_MEAN_MANY = ( #: dropped). SUGGESTION_LIMIT = 3 +#: The drill-down ``ls`` file-line cap (phase 94, task 03): a folder's +#: own files list at most this many ``source: X | path: Y | title: Z`` +#: lines (path order), then one deterministic grep-pointer note — a +#: 500-file folder costs the model 50 lines + the note, never 500. +#: Pinned module constant (no env var — the phase-94 TODO asks for a +#: shape change, not a knob; the constant lives next to +#: :data:`SEARCH_MAX_MATCHES`). +LS_MAX_FILE_LINES = 50 + +#: The NOT-A-FOLDER ``ls`` teaching refusal (phase 94, task 03): +#: a ``source/…`` argument whose folder segment matches no indexed +#: prefix (the ``00_phase.md`` existence rule — a folder exists iff +#: some indexed path starts with ``folder + "/"``; a document's own +#: path is never a folder). Phase-72 teaching style: one line, the +#: argument echoed (``{arg}``), the DEEPEST existing ancestor's name +#: (``{parent}`` — the source for a top-level miss, ``source/folder`` +#: for a nested one) and its direct subfolders (``{subfolders}`` — +#: space-joined ``name/`` entries in path order, so the model +#: self-corrects in the next round; ``none`` when the ancestor has no +#: subfolders). Still a refusal: it counts in nothing and consumes a +#: round (no silent argument normalization). +NOT_A_FOLDER = "'{arg}' is not a folder — {parent} has: {subfolders}" + #: The harness-owned recovery line (phase 71, task 03) — folded into the #: ORIGINAL single system message of the one bounded recovery request #: (``system_prompt + "\n" + CORRECTION_INSTRUCTION``; provider-safe, @@ -507,20 +558,6 @@ def plain_form(pattern: str) -> str: return p.strip() -def list_catalog(db: Session) -> list[tuple[str, str, str]]: - """Every indexed document as ``(source, path, title)``. - - Ordered by ``(source, path)`` — the same order as ``GET /api/docs``. - Module-level (not a method) so unit tests can monkeypatch it. - """ - rows = db.execute( - select(Document.source, Document.path, Document.title).order_by( - Document.source, Document.path - ) - ).all() - return [(source, path, title) for source, path, title in rows] - - def list_source_names(db: Session) -> list[str]: """Every registered source name, deduped, in registry order. @@ -546,6 +583,286 @@ def list_source_names(db: Session) -> list[str]: return names +#: The drill-down ``ls`` fetchers (phase 94, task 03) — module-level so +#: unit tests can monkeypatch them without a database (the house style: +#: :func:`ls_top` / :func:`ls_folder` compose them; the pure grouping +#: :func:`group_folder_listing` and renderers +#: :func:`render_ls_top` / :func:`render_folder_listing` sit below). + + +def _source_document_counts(db: Session) -> list[tuple[str, int]]: + """``(source, document count)`` per indexed source (one grouped + query — the top-level ``ls`` counts, phase 94 task 03).""" + return [ + (source, count) + for source, count in db.execute( + select(Document.source, func.count()).group_by(Document.source) + ) + ] + + +def _source_root_summaries(db: Session) -> list[tuple[str, str]]: + """The stored source-root summaries ``(source, summary)`` + (``folder_path = ""`` — the top-level ``ls`` indented lines, phase + 94 task 03; absent when never generated).""" + return [ + (source, summary) + for source, summary in db.execute( + select(FolderSummary.source, FolderSummary.summary).where( + FolderSummary.folder_path == "" + ) + ) + ] + + +def _source_document_rows(db: Session, source: str) -> list[tuple[str, str]]: + """``(path, title)`` of every document under *source*, ordered by + ``path`` — the one bounded fetch a folder drill level lists (phase + 94 task 03; one source's paths, not the whole KB).""" + return [ + (path, title) + for path, title in db.execute( + select(Document.path, Document.title) + .where(Document.source == source) + .order_by(Document.path) + ) + ] + + +def _source_folder_summaries(db: Session, source: str) -> dict[str, str]: + """The stored folder summaries ``{folder_path: summary}`` of one + source (phase 94 task 03; includes the ``""`` source-root row when + stored).""" + return { + folder_path: summary + for folder_path, summary in db.execute( + select(FolderSummary.folder_path, FolderSummary.summary).where( + FolderSummary.source == source + ) + ) + } + + +def ls_top(db: Session) -> list[tuple[str, int, str | None]]: + """The top level of the drill-down ``ls`` (phase 94, task 03). + + Every registered source in :func:`list_source_names` order (the + registry is the source of truth — a registered source with 0 + indexed documents still lists, the phase-70/72 invariant) as + ``(name, recursive_document_count, source_root_summary)``: the + count is the source's whole subtree (all of its documents — the + same set its stored summary describes) and the summary is the + stored ``folder_summaries`` row for ``(source, "")`` (the source + root, phase 94 task 01) or ``None`` when absent. Module-level so + unit tests can monkeypatch the fetchers. + """ + names = list_source_names(db) + if not names: + return [] + counts = dict(_source_document_counts(db)) + summaries = dict(_source_root_summaries(db)) + return [(name, counts.get(name, 0), summaries.get(name)) for name in names] + + +def group_folder_listing( + source: str, + folder: str, + rows: Sequence[tuple[str, str]], + summaries: Mapping[str, str], +) -> tuple[list[tuple[str, int, str | None]], list[tuple[str, str, str]], int]: + """One level of the drill-down tree (phase 94, task 03) — pure. + + Given *rows* — the source's ``(path, title)`` pairs in catalog + (path) order — and *summaries* (the source's stored + ``folder_summaries`` rows: ``folder_path → summary``), the folder + level *folder* (source-relative; ``""`` = the source root): + + * **(a) direct subfolders** — the folders whose parent is exactly + *folder*, in path order, each + ``(sub_path_relative_to_source, recursive_count, + summary_or_None)``. A folder is a slash-boundary prefix of at + least one indexed path (the ``00_phase.md`` existence rule: a + folder ``F`` exists ⟺ some path starts with ``F + "/"`` — a + document's OWN path is never a folder); the count is the + folder's recursive subtree — every path equal to the folder or + starting with ``folder + "/"`` (the same set the sync-time + folder summary describes, phase 94 task 01 — one concept end to + end). + * **(b) direct file lines** — the documents whose folder (the + prefix before the last ``/`` — + :func:`app.rag.folder_summaries.folder_of`, the shared notion) IS + *folder*, in path order (catalog order — the same order + ``GET /api/docs`` serves), as ``(source, path, title)`` triples + — the canonical ``read``/``grep`` identity, capped at + :data:`LS_MAX_FILE_LINES` (the rest fold into the renderer's + note; a 500-file folder never costs 500 lines). + * **(c) the TOTAL direct-file count** — pre-cap, for the note. + + Pure (no I/O) — unit tests drive the grouping without a database; + :func:`ls_folder` is the DB-composing wrapper. + """ + # The source's existing folders: every slash-boundary prefix of an + # indexed path (the existence rule's candidate set — a folder is + # present iff at least one path starts with ``folder + "/"``). + folders: set[str] = set() + for path, _title in rows: + f = folder_of(path) + while f: + folders.add(f) + f = folder_of(f) + # The recursive count per folder — the ``path == folder`` arm (a + # document sharing a folder's name) plus the ``startswith + # folder + "/"`` arm (the folder's true descendants), one pass per + # document. + counts: dict[str, int] = {f: 0 for f in folders} + for path, _title in rows: + if path in folders: + counts[path] += 1 + f = folder_of(path) + while f: + counts[f] += 1 + f = folder_of(f) + subfolders = [ + (g, counts[g], summaries.get(g)) + for g in sorted(g for g in folders if folder_of(g) == folder) + ] + files = [ + (source, path, title) + for path, title in rows + if folder_of(path) == folder + ] + return subfolders, files[:LS_MAX_FILE_LINES], len(files) + + +def ls_folder( + db: Session, source: str, folder: str +) -> tuple[list[tuple[str, int, str | None]], list[tuple[str, str, str]], int]: + """One folder level of the drill-down ``ls`` (phase 94, task 03). + + The source's document rows (:func:`_source_document_rows`) and + stored folder summaries (:func:`_source_folder_summaries`) through + :func:`group_folder_listing` — the pure grouping the unit tests + drive directly. ``folder = ""`` is the source root. Module-level + so unit tests can monkeypatch the fetchers. + """ + return group_folder_listing( + source, + folder, + _source_document_rows(db, source), + _source_folder_summaries(db, source), + ) + + +def _folder_exists_in(rows: Sequence[tuple[str, str]], folder: str) -> bool: + """The phase-94 folder-existence rule (``00_phase.md``), pure. + + Folder *folder* (source-relative) under a registered source + exists ⟺ ``folder == ""`` OR some indexed path starts with + ``folder + "/"`` — a document's OWN path is never a folder + (nothing starts with ``path + "/"``), so ``ls`` of a file path + refuses with :data:`NOT_A_FOLDER` rather than listing. + """ + if not folder: + return True + prefix = folder + "/" + return any(path.startswith(prefix) for path, _title in rows) + + +def _deepest_existing_ancestor( + rows: Sequence[tuple[str, str]], folder: str +) -> str: + """The deepest EXISTING folder prefix of a missing *folder* (pure). + + The :data:`NOT_A_FOLDER` refusal's teaching context: the argument's + segments are walked from the top; the walk stops at the first + segment that is no folder, so the returned prefix is the deepest + existing ancestor (``""`` = the source root when the first segment + is already the miss) and its direct subfolders are the bounded + self-correction list the refusal prints. + """ + parent = "" + for part in folder.split("/"): + candidate = f"{parent}/{part}" if parent else part + if not _folder_exists_in(rows, candidate): + break + parent = candidate + return parent + + +def render_ls_top(entries: Sequence[tuple[str, int, str | None]]) -> str: + """The top-level ``ls`` result (phase 94, task 03) — the pinned + template. + + ``{N} sources:`` — the line alone when the registry is empty (the + old ``0 documents:`` behavior preserved in spirit) — then, when at + least one source is registered, a blank line and one block per + source in registry order: ``{source} — {n} documents`` plus the + indented `` {summary}`` line ONLY when the source-root summary is + stored (absent → the count line alone, no placeholder) — with NO + blank line between blocks. + """ + lines = [f"{len(entries)} sources:"] + if entries: + lines.append("") + for source, count, summary in entries: + lines.append(f"{source} — {count} documents") + if summary: + lines.append(f" {summary}") + return "\n".join(lines) + + +def render_folder_listing( + identity: str, + subfolders: Sequence[tuple[str, int, str | None]], + files: Sequence[tuple[str, str, str]], + total_files: int, +) -> str: + """One folder level of the drill-down ``ls`` (phase 94, task 03) — + the pinned template. + + The header ``{identity} — {n_files} documents, {n_folders} + folders:`` (``n_files`` = *total_files*, the PRE-cap direct-file + count; ``identity`` is the source name at the root level and + ``source/folder`` below it), then — when the level carries anything + below the header — a blank line, the 2-space-indented subfolder + lines `` {sub}/ — {m} documents`` in path order (``: {summary}`` + appended ONLY when the subfolder's summary is stored), a blank + line, the file lines in EXACTLY the existing + ``source: X | path: Y | title: Z`` format (the canonical + ``read``/``grep`` identity — unchanged), and the cap note + ``…and {hidden} more documents in this folder — use grep + (pattern) to find a specific one.`` ONLY when the folder's own + files outnumber :data:`LS_MAX_FILE_LINES` (*files* arrives capped; + *total_files* carries the pre-cap count). A header with no + subfolders and no files — a registered source with no documents — + is the header line alone (``… — 0 documents, 0 folders:``). + """ + header = f"{identity} — {total_files} documents, {len(subfolders)} folders:" + if not subfolders and not files: + return header + body: list[str] = [] + if subfolders: + body.append("") + for sub, count, summary in subfolders: + line = f" {sub}/ — {count} documents" + if summary: + line += f": {summary}" + body.append(line) + if files or total_files > len(files): + body.append("") + body.extend( + f"source: {source} | path: {path} | title: {title}" + for source, path, title in files + ) + hidden = total_files - len(files) + if hidden > 0: + body.append( + f"…and {hidden} more documents in this folder — use grep " + "(pattern) to find a specific one." + ) + return "\n".join([header, *body]) + + def find_document(db: Session, source: str, path: str) -> Document | None: """The indexed document at ``(source, path)``, or ``None``. @@ -704,30 +1021,61 @@ def _execute_tool( phase 70). """ if call.name == "ls": + # Phase 94 (task 03): the drill-down tree — one level per call + # (the owner-permitted tool-surface revision; the old + # whole-catalog listing is gone). A successful listing at ANY + # level (top/root/folder) counts; a refusal counts in nothing + # and consumes a round like every refusal. raw_path = call.arguments.get("path") scope = raw_path.strip() if isinstance(raw_path, str) else "" - rows = list_catalog(db) - if scope: - if "/" in scope: - # A source name (a directory basename) can never - # contain '/' — this is a document path where a source - # name belongs (phase 72): teach the contract; no - # registry lookup needed, counts in nothing, consumes - # a round like every refusal. - return LS_PATH_NOT_A_SOURCE.format(path=scope) - if scope not in list_source_names(db): - # The no-source refusal with the teaching parenthetical - # (phase 72) — the prefix byte-identical to the - # pre-phase-72 line; counts in nothing, consumes a - # round like every refusal. - return NO_SOURCE_NOT_A_DIRECTORY.format(scope=scope) - rows = [row for row in rows if row[0] == scope] - listing = f"{len(rows)} documents:\n" + "\n".join( - f"source: {source} | path: {path} | title: {title}" - for source, path, title in rows - ) + if not scope: + # The top level: the synced sources, registry order, each + # with its recursive document count and its stored + # source-root summary (``None`` → no indented line). + holder.tool_calls += 1 + return render_ls_top(ls_top(db)) + source, _, rest = scope.partition("/") + if source not in list_source_names(db): + # The no-source refusal with the teaching parenthetical + # (phase 72, the prefix byte-identical to the pre-phase-72 + # line): the FIRST segment is the source candidate — a bare + # unknown name (the incident's ``ls(path='.')``) or the + # source segment of a ``source/…`` argument (phase 94: a + # ``/`` now names a folder, so the phase-72 document-path + # teaching is deleted) — the segment echoed; counts in + # nothing, consumes a round like every refusal. + return NO_SOURCE_NOT_A_DIRECTORY.format(scope=source) + if not rest: + # The source's ROOT folder: subfolders + own file lines + # (capped + note) — the pinned template; a registered + # source with no documents lists its header line alone + # (``… — 0 documents, 0 folders:`` — the old + # ``0 documents:`` behavior preserved in spirit). + subfolders, files, total = ls_folder(db, source, "") + holder.tool_calls += 1 + return render_folder_listing(source, subfolders, files, total) + rows = _source_document_rows(db, source) + summaries = _source_folder_summaries(db, source) + if not _folder_exists_in(rows, rest): + # The NOT-A-FOLDER teaching (phase 94, task 03): the + # argument echoed, the DEEPEST existing ancestor's name and + # its direct subfolders listed (bounded — the parent's own + # listing, so no new flood path), so the model + # self-corrects in the next round; counts in nothing, + # consumes a round like every refusal. + parent = _deepest_existing_ancestor(rows, rest) + parent_subs = group_folder_listing(source, parent, rows, summaries)[0] + return NOT_A_FOLDER.format( + arg=scope, + parent=source if not parent else f"{source}/{parent}", + subfolders=" ".join(f"{sub}/" for sub, _c, _s in parent_subs) + or "none", + ) + # The folder level: the same template as the root, identity = + # source + "/" + folder. + subfolders, files, total = group_folder_listing(source, rest, rows, summaries) holder.tool_calls += 1 - return listing + return render_folder_listing(f"{source}/{rest}", subfolders, files, total) if call.name == "read": raw_path = call.arguments.get("path") arg = raw_path.strip() if isinstance(raw_path, str) else "" diff --git a/app/rag/folder_summaries.py b/app/rag/folder_summaries.py new file mode 100644 index 0000000..27d932f --- /dev/null +++ b/app/rag/folder_summaries.py @@ -0,0 +1,391 @@ +"""Folder summary generator (phase 94, task 01). + +``ls`` becomes a drill-down tree (phase 94, ``00_phase.md``): the LLM +lists the synced projects, then drills into folders. Each level's +listing shows the folder's **subtree summary** — a 1–3 sentence +plain-text description of what the folder's documents cover, generated +at SYNC time by the aipi ``lite`` model (the ``KB_OVERVIEW_MODE`` +contract of ``app.rag.overview``: change-gated by the caller, +fail-soft — an old summary is better than none — and E2E-mockable via +the ``FOLDER_SUMMARY_MODE`` system-prompt marker). + +ONE concept end to end (:func:`group_by_folder`): a folder row's +documents are its **recursive subtree** — every document whose path +equals the folder or starts with ``folder + "/"`` — exactly the set the +``ls`` count rule (``00_phase.md``) counts. A document under ``a/b/`` +therefore contributes to the ``a``, ``a/b``, and ``""`` (source root) +groups alike: each level's listing shows its own accurate subtree +summary, and the number next to a folder is the number of documents its +summary describes. + +Storage: ``folder_summaries`` (migration 0017) — PK +``(source, folder_path)``; ``folder_path = ""`` is the SOURCE ROOT +(the top-level source summary). Rows exist only for folders with +≥ 2 documents (the :data:`MIN_DOCS_PER_FOLDER` rule — a +single-document folder is fully described by its one file line, so no +``lite`` burn); after a changed sync, rows whose folder dropped below +2 documents are pruned (a pruned/renamed folder's summary would +otherwise go stale), while rows for folders that still have +≥ 2 documents persist (an unchanged folder's summary is still true). + +Chat turns never generate folder summaries — the agent's ``ls`` output +(phase 94, task 03) only reads the stored rows. Generation is the +caller's job at sync time (phase 94, task 02), and :func:`generate_ +folder_summaries` only flushes — the sync path owns the transaction +(the phase-53 ``bump_sources_version`` convention). +""" +from __future__ import annotations + +import logging +from collections.abc import Sequence +from datetime import UTC, datetime +from typing import Protocol + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import Settings, get_settings +from app.models import Document, FolderSummary +from app.rag.llm import LLMError +from app.rag.retriever import TRUNCATION_MARKER + +logger = logging.getLogger("app.rag.folder_summaries") + +#: System-prompt marker for folder-summary generation — the E2E mock +#: LLM keys on it (same convention as ``SUMMARY_MODE`` / +#: ``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. +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." +) + +#: Full system prompt: marker first (the mock's key), then the +#: instruction (the ``app.rag.overview.SYSTEM_PROMPT`` shape). +SYSTEM_PROMPT = f"{FOLDER_SUMMARY_MODE}: {FOLDER_SUMMARY_INSTRUCTION}" + +#: First line of the user message — the folder the summary describes: +#: ```` for the source root, ``/`` for a +#: folder. The deterministic E2E mock keys on it to name the folder in +#: its canned reply (``tests/e2e/mock_llm.py``), so the stored row is +#: a pure function of the request. +FOLDER_HEADER_PREFIX = "Folder: " + +#: A folder is summarized only while its recursive subtree holds at +#: least this many documents — a single-document folder is fully +#: described by its one file line, so no ``lite`` burn (phase 94 +#: ``00_phase.md`` scope rule; the prune rule applies the same count). +MIN_DOCS_PER_FOLDER = 2 + +#: One document row for the grouping/prompting: +#: ``(source, path, title, summary)`` — the ``app.rag.overview`` +#: ``build_overview_prompt`` row shape (``summary`` is the stored +#: lite-written text or ``None`` for markdown docs / the fail-soft +#: path). +DocRow = tuple[str, str, str, str | None] + + +class FolderSummaryLLM(Protocol): + """The one-shot chat surface the folder-summary generator needs. + + :class:`app.rag.llm.LLMClient` satisfies it; unit tests pass a + duck-typed fake (``chat`` + ``settings``) instead — same pattern as + the overview's ``OverviewLLM`` protocol. + """ + + settings: Settings + + async def chat( + self, messages: list[dict[str, str]], model: str | None = None + ) -> str: ... + + +def folder_of(path: str) -> str: + """The directory prefix before the last ``/`` (``""`` for root-level files). + + The single notion of "which folder owns this document" used by the + whole module: ``folder_of("a.md") == ""``, + ``folder_of("a/b.md") == "a"``, ``folder_of("a/b/c.md") == "a/b"``. + Iterating :func:`folder_of` over its own result walks a path's + folder prefixes from nearest to farthest, ending at ``""``. + """ + idx = path.rfind("/") + return path[:idx] if idx >= 0 else "" + + +def group_by_folder(rows: Sequence[DocRow]) -> dict[tuple[str, str], list[DocRow]]: + """Group document rows by the folders their recursive subtree fills. + + The ONE concept of this module, documented in the module docstring: + a folder row's documents are its **recursive subtree** — every + document whose path equals the folder or starts with + ``folder + "/"`` — exactly the set the ``ls`` count rule counts. + Each row therefore lands in the ``""`` (source root) group AND in + the group of every folder prefix of its path: a doc under + ``a/b/`` contributes to the ``a``, ``a/b``, and ``""`` groups. + Per source the candidate rows are thus ``""`` (all of the source's + documents — the top-level source summary) plus every distinct + folder prefix of an indexed path. + + *rows* are ``(source, path, title, summary)`` tuples (the + :data:`DocRow` shape, e.g. straight from the ``documents`` + catalogue query). Returns ``{(source, folder_path): [rows]}`` — + group lists keep the input (catalogue) order, so downstream prompt + building is deterministic. Groups of ANY size (≥ 1) are returned; + the ≥ 2 :data:`MIN_DOCS_PER_FOLDER` rule is applied by + :func:`generate_folder_summaries`, not here. + + """ + # Pass 1: the distinct TRUE folder prefixes of the catalogue — a + # folder is a slash-boundary prefix of at least one indexed path + # (the ``00_phase.md`` candidate definition: ``""`` + every distinct + # folder prefix, per source). + folders: set[tuple[str, str]] = set() + for row in rows: + source, path = row[0], row[1] + folder = folder_of(path) + while folder: + folders.add((source, folder)) + folder = folder_of(folder) + + # Pass 2: every row lands in the source-root group, in the group of + # every ancestor folder (the ``startswith folder + "/"`` arm of the + # count rule), and — when its path IS one of the source's folder + # prefixes (a file sharing its name with a directory) — in that + # folder's group too (the ``path == folder`` arm). A row therefore + # belongs to its folder's group iff its path equals the folder or + # starts with ``folder + "/"`` — exactly the set the ``ls`` count + # rule counts, for EVERY group. + groups: dict[tuple[str, str], list[DocRow]] = {} + for row in rows: + source, path = row[0], row[1] + groups.setdefault((source, ""), []).append(row) + if (source, path) in folders: + groups.setdefault((source, path), []).append(row) + folder = folder_of(path) + while folder: + groups.setdefault((source, folder), []).append(row) + folder = folder_of(folder) + return groups + + +def _first_summary_line(summary: str | None) -> str: + """First line of a stored summary, stripped; ``''`` when absent. + + The stored summary (phase 30) ends in the code-appended + ``Source: …`` pointer line, so its first line is the model-written + lead sentence — the best one-line picture of the document for the + folder summary. Blank/whitespace-only summaries yield ``''`` as + well (the ``app.rag.overview`` helper, mirrored locally so each + lite-mode module stays self-contained). + """ + if not summary: + return "" + for line in summary.splitlines(): + stripped = line.strip() + if stripped: + return stripped + return "" + + +def _folder_label(source: str, folder_path: str) -> str: + """The user-message folder identifier (the ``FOLDER_HEADER_PREFIX`` tail). + + ```` for the source root (``folder_path = ""``), + ``/`` for a folder — the canonical folder + identity the E2E mock echoes into its canned reply. + """ + return source if not folder_path else f"{source}/{folder_path}" + + +def build_folder_summary_prompt( + source: str, + folder_path: str, + docs: Sequence[DocRow], + max_chars: int | None = None, +) -> tuple[str, str]: + """The ``(system, user)`` message pair for one folder-summary call. + + * ``system`` — :data:`SYSTEM_PROMPT`: the ``FOLDER_SUMMARY_MODE`` + marker + the locked instruction. + * ``user`` — the folder header line + (``FOLDER_HEADER_PREFIX + [/]`` — the line + the E2E mock parses to name the folder), then one line per + document, ``path — title — {first line of summary}``, joined + with newlines, in the given (catalogue) order. The summary field + is omitted when the document has none (markdown docs and the + fail-soft path — no dangling dash, the overview convention). The + whole message is capped at *max_chars* (default + ``BOR_FOLDER_SUMMARY_INPUT_MAX_CHARS``): overflow is cut exactly + at the cap and the shared ``[…truncated…]`` marker is appended on + its own line, so the model never sees more than the cap and the + cut is visible (summarizer convention, phase 30). + """ + lines = [FOLDER_HEADER_PREFIX + _folder_label(source, folder_path)] + for _source, path, title, summary in docs: + line = f"{path} — {title}" + first = _first_summary_line(summary) + if first: + line += f" — {first}" + lines.append(line) + user = "\n".join(lines) + limit = ( + max_chars + if max_chars is not None + else get_settings().folder_summary_input_max_chars + ) + if len(user) > limit: + user = user[:limit] + "\n" + TRUNCATION_MARKER + return SYSTEM_PROMPT, user + + +async def summarize_folder( + source: str, folder_path: str, docs: Sequence[DocRow], llm: FolderSummaryLLM +) -> str: + """One-shot ``lite`` summary of one folder's recursive subtree. + + Builds the prompt from the folder's documents (each ``path``, + ``title``, first summary line), makes ONE :meth:`LLMClient.chat` + call against ``llm.settings.llm_summary_model`` (the ``lite`` + model — no new model management), and returns the model's text + trimmed. Raises :class:`LLMError` when the reply is empty after + trimming (the client already rejects empty content; re-asserted + defensively, the summarizer's rule — a silent summary must never be + stored), and propagates any :class:`LLMError` the client raises + (the generator's fail-soft path catches it per folder). + """ + system, user = build_folder_summary_prompt(source, folder_path, docs) + raw = await llm.chat( + [{"role": "system", "content": system}, {"role": "user", "content": user}], + model=llm.settings.llm_summary_model, + ) + summary = raw.strip() + if not summary: + label = _folder_label(source, folder_path) + raise LLMError( + f"folder summary model returned empty content for {label} — " + "refusing to store a silent summary" + ) + return summary + + +def _upsert(db: Session, source: str, folder_path: str, summary: str) -> None: + """Insert or update the row for one folder (fresh UTC stamp).""" + now = datetime.now(UTC) + row = db.get(FolderSummary, (source, folder_path)) + if row is None: + db.add( + FolderSummary( + source=source, folder_path=folder_path, summary=summary, updated_at=now + ) + ) + else: + row.summary = summary + row.updated_at = now + + +def folder_summary_table_empty(db: Session) -> bool: + """Whether ``folder_summaries`` holds no rows (the sync-path gate). + + Phase 94 (task 02): the ``_overview_row_exists`` pattern + (``scripts.import_docs``) extended to a table-emptiness check — + after an unchanged re-sync, an EMPTY table (the first full sync + after migration 0017, or after a ``--limit`` debug walk that + skipped generation) still gets a fresh batch, while a populated + table is left untouched until the KB actually changes. One + bounded ``LIMIT 1`` probe, never a count scan. + """ + return db.execute(select(FolderSummary.source).limit(1)).first() is None + + +async def generate_folder_summaries( + db: Session, llm: FolderSummaryLLM, *, skip: bool = False +) -> dict[str, int]: + """Regenerate the stored folder summaries for the current catalogue. + + The sync-path orchestrator (phase 94, task 02 calls it + change-gated, like the KB overview; ``--limit`` debug runs pass + ``skip=True``). Steps, in order: + + 1. ``skip=True`` → a no-op: the zero stats dict is returned, the + LLM is never called, and no rows are touched. + 2. Group the document catalogue by :func:`group_by_folder` (the + recursive-subtree concept) and keep the candidate folders — + the ones whose recursive subtree holds + :data:`MIN_DOCS_PER_FOLDER` (≥ 2) documents. Single-document + folders get no row (their one file line IS their summary). + 3. For each candidate (deterministic ``(source, folder_path)`` + order) call :func:`summarize_folder` and UPSERT — fail-soft PER + FOLDER: one folder's :class:`LLMError` is logged and counted, + its previous row (if any) is kept, and the remaining folders + still land (a ``lite`` outage must never fail the sync — the KB + is the product, the summaries are auxiliary). + 4. DELETE rows whose folder no longer has ≥ 2 documents — a + pruned/renamed folder's summary goes stale and is dropped. + Rows for folders that still qualify persist (regenerated in + step 3 — an unchanged folder's summary is still true). + + Only flushes — the CALLER commits (the phase-53 + ``bump_sources_version`` convention: the sync path owns the + transaction, so a failed sync rolls the summaries back with it). + + Returns the small stats dict ``{"generated", "failed", "pruned"}`` + for the caller's summary-line logging (PLAN §9 ample logging). + """ + stats = {"generated": 0, "failed": 0, "pruned": 0} + if skip: + return stats + + result = db.execute( + select(Document.source, Document.path, Document.title, Document.summary) + .order_by(Document.source, Document.path) + ).all() + rows: list[DocRow] = [ + (source, path, title, summary) + for source, path, title, summary in result + ] + groups = group_by_folder(rows) + candidates = { + key: docs for key, docs in groups.items() if len(docs) >= MIN_DOCS_PER_FOLDER + } + + for source, folder_path in sorted(candidates): + docs = candidates[(source, folder_path)] + try: + summary = await summarize_folder(source, folder_path, docs, llm) + except LLMError as e: + stats["failed"] += 1 + logger.error( + "folder summary failed for %s/%s — %s", source, folder_path, e + ) + continue + _upsert(db, source, folder_path, summary) + stats["generated"] += 1 + + existing = db.execute( + select(FolderSummary.source, FolderSummary.folder_path) + ).all() + for source, folder_path in existing: + if (source, folder_path) not in candidates: + row = db.get(FolderSummary, (source, folder_path)) + if row is not None: + db.delete(row) + stats["pruned"] += 1 + + db.flush() + logger.info( + "folder_summaries: generated=%d failed=%d pruned=%d", + stats["generated"], + stats["failed"], + stats["pruned"], + ) + return stats diff --git a/app/rag/prompts.py b/app/rag/prompts.py index fb65d0a..1d455a9 100644 --- a/app/rag/prompts.py +++ b/app/rag/prompts.py @@ -31,7 +31,15 @@ name*, not a directory or file path, and ``read``/``grep`` take the combined ``source/path`` string *including the source name* (a bare document path will not resolve) — the same two things the phase-72 teaching refusals in :mod:`app.rag.agent` re-state after the fact, so -the model carries the contract before it calls a tool): the **HIGH** +the model carries the contract before it calls a tool; phase 94, +task 03: the ``ls`` clause is rewritten to the drill-down tree +contract — no ``path``: the synced sources with counts + summaries, a +source name: its top-level folders + files, a ``source/folder`` path: +one level deeper — a listing shows only that level's subfolders + own +files (never the whole KB in one call), the folder summaries say +what's in a folder before drilling, and ``grep`` stays the locator for +finding one document without listing; the ``read``/``grep`` clauses +and the discipline rules are byte-identical): the **HIGH** prompt only carries a ```` section after the ```` body — the grounded turn may extend its context through the three server-side tools (round-capped, see :mod:`app.rag.agent`; the cap is @@ -103,7 +111,17 @@ _KB_INTRO = ( #: ``path`` is a *source name* (not a directory or file path) and #: ``read``/``grep`` take the combined ``source/path`` string *including #: the source name* (a bare document path will not resolve) — the same -#: two things the phase-72 teaching refusals re-state after the fact): +#: two things the phase-72 teaching refusals re-state after the fact; +#: phase 94, task 03 — the owner-permitted tool-surface revision, +#: recorded in the phase 94 overview: the ``ls`` clause is rewritten +#: to the drill-down tree contract — no ``path``: the synced sources +#: with counts + summaries, a source name: its top-level folders + +#: files, a ``source/folder`` path: one level deeper — a listing shows +#: only that level's subfolders + own files, never the whole KB in one +#: call, the folder summaries say what's in a folder before drilling, +#: and ``grep`` stays the locator for finding one document without +#: listing; the ``read``/``grep`` clauses and the discipline rules are +#: byte-identical): #: a grounded turn may extend its context through the three server-side #: tools (round cap: ``BOR_AGENT_MAX_ROUNDS`` — the cap is the bound and #: this section does not re-state it, phase 45). Appended after the mode @@ -139,23 +157,30 @@ _KB_INTRO = ( TOOLS_SECTION: str = ( "\n" "You may extend your context with three tools. `ls` lists the " - "indexed documents as `source: X | path: Y | title: Z` lines; its " - "optional `path` argument is a source name (e.g. 'homelab'), not a " - "directory or file path — omit it to list every document. `read` " - "pulls in one document by its combined `source/path` string, " - "exactly as shown in the `ls` output — including the source name — " - "adding its full content to your context. Do not call `read` for a " - "document already shown in the section, even when the " - "user asks you to open or read it — its full text is already in " - "your prompt; answer directly from it. For `read`, a bare document " - "path (without the source name) will not resolve. `grep` locates an " - "exact string (case-insensitive) in the indexed documents and " - "returns up to 20 matching `source/path:line: text` lines — a " - "locator, not a context-adder: read the winner with `read`. A grep " - "pattern is a plain substring, NEVER a regex — '.*' and '\\.' are " - "literal text there; if such a pattern returns no matches, retry " - "with the plain text you expect to see. For a normal search pass " - "only `pattern` — its optional `path` argument " + "knowledge base as a tree, one level at a time: with no `path` it " + "lists every synced source with its document count and a summary " + "of its contents; with a source name (e.g. 'homelab') it lists " + "that source's top-level folders and files; with a `source/folder` " + "path it drills one level deeper. A listing shows only that " + "level's subfolders and its own files — never the whole knowledge " + "base in one call — and each folder line's summary says what the " + "folder contains before you drill into it. File lines are " + "`source: X | path: Y | title: Z`; to find one specific document " + "without listing, use `grep`. `read` pulls in one document by its " + "combined `source/path` string, exactly as shown in the `ls` " + "output — including the source name — adding its full content to " + "your context. Do not call `read` for a document already shown in " + "the section, even when the user asks you to open or " + "read it — its full text is already in your prompt; answer " + "directly from it. For `read`, a bare document path (without the " + "source name) will not resolve. `grep` locates an exact string " + "(case-insensitive) in the indexed documents and returns up to 20 " + "matching `source/path:line: text` lines — a locator, not a " + "context-adder: read the winner with `read`. A grep pattern is a " + "plain substring, NEVER a regex — '.*' and '\\.' are literal text " + "there; if such a pattern returns no matches, retry with the plain " + "text you expect to see. For a normal search pass only `pattern` — " + "its optional `path` argument " "limits the search to one document you already know, by the same " "combined `source/path` string; never a source name — a bare " "document path (without the source name) will not resolve there " diff --git a/scripts/agent_realmodel_check.py b/scripts/agent_realmodel_check.py index f04addb..cb37d0f 100644 --- a/scripts/agent_realmodel_check.py +++ b/scripts/agent_realmodel_check.py @@ -82,17 +82,18 @@ from pathlib import Path from typing import Any from dotenv import load_dotenv +from sqlalchemy import select from app.api.chat import plan_turn from app.api.steering import load_steering_notes from app.config import Settings, get_settings from app.db import SessionLocal, db_available +from app.models import Document from app.rag.agent import ( CORRECTION_INSTRUCTION, AgentHolder, MalformedReplyError, find_document, - list_catalog, list_source_names, run_agent, ) @@ -113,6 +114,19 @@ logger = logging.getLogger("agent_realmodel_check") #: Repo-relative fixture dump (written by scripts.load_test_kb). DEFAULT_DUMP_PATH = Path("tests/fixtures/test_kb.dump.sql") + +def _catalog(db) -> list[tuple[str, str, str]]: + """The full catalog as ``(source, path, title)`` in ``(source, path)`` + order — the battery builder's pre-phase-94 ``list_catalog`` contract, + kept local to this script (phase 94 deleted the agent accessor: the + drill-down ``ls`` lists one bounded source level at a time).""" + rows = db.execute( + select(Document.source, Document.path, Document.title).order_by( + Document.source, Document.path + ) + ).all() + return [(source, path, title) for source, path, title in rows] + #: The per-turn line truncates the question at this width (the locked #: format prints ``turn NN | emitted=E executed=X cap=Y|N | ``). QUESTION_DISPLAY_WIDTH = 40 @@ -263,7 +277,7 @@ def check_preconditions( return 2 return None with SessionLocal() as db: - catalog = list_catalog(db) + catalog = _catalog(db) if len(catalog) < 2: print( f"precondition failed: catalog holds {len(catalog)} document(s) " @@ -443,8 +457,33 @@ async def _run_deflected( ) +def _ls_folder_prefixes(catalog: set[tuple[str, str]]) -> dict[str, set[str]]: + """The existing ``ls`` folders per source (the phase-94 drill-down + contract): for each source, the set of source-relative folder + prefixes — every slash-boundary prefix of its indexed paths. + + Mirrors the ``00_phase.md`` existence rule that + ``app.rag.agent._execute_tool`` applies: a folder ``F`` exists ⟺ + some indexed path of the source starts with ``F + "/"`` — a + document's OWN path is never a folder, so the document's path itself + is not in the set (``ls`` of a file path is a refusal, as is a bare + folder name missing its source prefix). + """ + folders: dict[str, set[str]] = {} + for source, path in catalog: + folder = path[: path.rfind("/")] if "/" in path else "" + while folder: + folders.setdefault(source, set()).add(folder) + folder = folder[: folder.rfind("/")] if "/" in folder else "" + return folders + + def classify_call( - name: str, args: dict[str, Any], catalog: set[tuple[str, str]], sources: set[str] + name: str, + args: dict[str, Any], + catalog: set[tuple[str, str]], + sources: set[str], + ls_folders: dict[str, set[str]], ) -> bool: """Contract correctness of ONE emitted call (the tool-calling accuracy metric, 2026-09-04 controlled methodology). @@ -460,12 +499,25 @@ def classify_call( re-read 15/15 across copy variants — is documented in ``TOOL_CALLING_TESTING.md``). The classification mirrors ``app.rag.agent._execute_tool``'s resolution rules gate-side (no - app-code changes for measurement). + app-code changes for measurement) — including the phase-94 ``ls`` + revision (owner-permitted tool-surface change, ``00_phase.md``): + ``ls(path)`` is contract-correct for ``""`` (the synced sources), + a registered source name (its root folder), or a ``source/folder`` + path naming an EXISTING folder (``ls_folders`` — the drill-down); + an unknown first segment, a bare folder name (no source prefix), or + a folder matching no indexed prefix is the violation. """ if name == "ls": raw = args.get("path") scope = raw.strip() if isinstance(raw, str) else "" - return scope == "" or scope in sources + if not scope: + return True # the top level (the synced sources) + source, _, rest = scope.partition("/") + if source not in sources: + return False # unknown first segment (the phase-72 incident class) + if not rest: + return True # a registered source's root folder + return rest in ls_folders.get(source, ()) if name == "read": raw = args.get("path") arg = raw.strip() if isinstance(raw, str) else "" @@ -498,10 +550,13 @@ def score_contract( and source names. Per-turn counts are attached on each :class:`TurnResult` as ``_contract_ok`` (measurement state, not a dataclass field — the display line stays the locked format).""" + ls_folders = _ls_folder_prefixes(catalog) ok = 0 for turn in turns: turn_ok = sum( - 1 for name, args in turn.calls if classify_call(name, args, catalog, sources) + 1 + for name, args in turn.calls + if classify_call(name, args, catalog, sources, ls_folders) ) turn._contract_ok = turn_ok # type: ignore[attr-defined] ok += turn_ok @@ -763,7 +818,7 @@ def main(argv: list[str] | None = None) -> int: ) else: with SessionLocal() as db: - catalog = list_catalog(db) + catalog = _catalog(db) s2, p2, _t2 = catalog[1] d2 = find_document(db, s2, p2) d2_content = d2.content if d2 is not None else "" @@ -794,7 +849,7 @@ def main(argv: list[str] | None = None) -> int: # source names (the KB is static across the run — the restore, when # any, happened before the battery). with SessionLocal() as db: - catalog_set = set((s, p) for s, p, _t in list_catalog(db)) + catalog_set = set((s, p) for s, p, _t in _catalog(db)) sources_set = set(list_source_names(db)) score_contract(turns, catalog_set, sources_set) passed, conditions = evaluate( diff --git a/scripts/import_docs.py b/scripts/import_docs.py index 83bba71..21b27d4 100644 --- a/scripts/import_docs.py +++ b/scripts/import_docs.py @@ -54,6 +54,20 @@ reports ``overview=failed`` on the summary line: the import's exit code is about files, and the previous outline stays (an old outline is better than none). +The stored folder summaries (phase 94 — the drill-down ``ls``'s +per-level descriptions, the ``folder_summaries`` table) regenerate in +the same run under the same gate: a changed KB (added + updated > 0), +or an empty table after an unchanged walk (the first full run after +migration 0017, or after a ``--limit`` first walk that skipped them). +Same contract — **best-effort, per-folder fail-soft**: a ``lite`` +failure keeps the failed folders' previous rows and only counts into +the stats; ``--limit`` debug runs skip them entirely (never burning a +``lite`` call). The stats land on the summary line as +``folder_summaries=//`` (or +``folder_summaries=skipped`` when the gate did not fire), and the rows +commit in the run's own short-lived session (the phase-53 convention — +a failed commit rolls them back with it). + A run that **changed** the knowledge base (added + updated + pruned > 0 — phase 53, task 02) also advances the single-row ``sources_meta`` version exactly once (``sources_version=`` on the summary line): the @@ -78,6 +92,7 @@ from app.core.debugging import configure_debugging from app.core.logging import configure_logging from app.db import SessionLocal from app.models import KbOverview +from app.rag.folder_summaries import folder_summary_table_empty, generate_folder_summaries from app.rag.git_sources import effective_sources from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient @@ -216,6 +231,20 @@ def _overview_row_exists() -> bool: return session.get(KbOverview, 1) is not None +def _folder_summaries_table_empty() -> bool: + """Whether the ``folder_summaries`` table holds any row (phase 94). + + The ``_overview_row_exists`` pattern extended to a table-emptiness + check (one bounded ``LIMIT 1`` probe): an empty table after an + unchanged re-import — e.g. the first full run after migration 0017, + or after a ``--limit`` first walk that skipped generation — still + gets a fresh batch of folder summaries, while a populated table is + left untouched until the KB actually changes. + """ + with SessionLocal() as session: + return folder_summary_table_empty(session) + + def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) settings = get_settings() @@ -246,9 +275,10 @@ def main(argv: list[str] | None = None) -> int: llm = LLMClient() - async def _run() -> tuple[ImportSummary, str, str]: - """Import, then (change-gated) advance the sources version and - refresh the stored KB overview. + async def _run() -> tuple[ImportSummary, str, str, dict[str, int] | None]: + """Import, then (change-gated) advance the sources version, + refresh the stored KB overview, and regenerate the stored folder + summaries. One event loop, one ``LLMClient`` (phase 31, task 04): the outline that every chat prompt injects as ```` is @@ -268,6 +298,17 @@ def main(argv: list[str] | None = None) -> int: deliberately broader than the overview's). ``--limit`` debug runs and unchanged re-runs never bump. The returned token is the new version, or ``"skipped"``. + + The folder summaries (phase 94, task 02) follow the same gate + — a changed KB, or an empty ``folder_summaries`` table after an + unchanged walk (the first full run after migration 0017, or + after a ``--limit`` first walk) — with per-folder fail-soft + inside the generator (a ``lite`` failure keeps the failed + folders' previous rows). The generator only flushes: this + run's own short-lived session commits (the phase-53 + convention), and the stats land on the summary line as + ``folder_summaries=//`` + (``None`` — rendered ``skipped`` — when the gate did not fire). """ summary = await import_sources( sources, llm, prune=args.prune, limit=args.limit, @@ -294,32 +335,69 @@ def main(argv: list[str] | None = None) -> int: sources_version = "skipped" logger.info("sources: version bump skipped (KB unchanged)") if args.limit is not None: + # An incomplete walk must never rewrite the outline or the + # folder summaries (the --limit skip, mirrored above for the + # sources version). logger.info("overview: skipped (--limit)") - return summary, "skipped", sources_version - if summary.added + summary.updated == 0: + return summary, "skipped", sources_version, None + changed = summary.added + summary.updated > 0 + overview_due = changed + folders_due = changed + if not changed: if summary.files == 0: logger.info("overview: skipped (nothing imported)") - return summary, "skipped", sources_version - if _overview_row_exists(): + return summary, "skipped", sources_version, None + # The unchanged-walk first-run triggers: the overview when + # no row exists yet (the first run after migration 0005), + # the folder summaries when the table is empty (the first + # full run after migration 0017, or after a --limit first + # walk that skipped them). + overview_due = not _overview_row_exists() + folders_due = _folder_summaries_table_empty() + if not overview_due and not folders_due: logger.info("overview: skipped (KB unchanged)") - return summary, "skipped", sources_version - # No outline yet after an unchanged re-import (e.g. the first - # run after migration 0005) — fall through and generate one. - ok = await regenerate_overview(llm) - return summary, "updated" if ok else "failed", sources_version + return summary, "skipped", sources_version, None + overview_status = "skipped" + if overview_due: + ok = await regenerate_overview(llm) + overview_status = "updated" if ok else "failed" + else: + logger.info("overview: skipped (KB unchanged)") + # Phase 94 (task 02): the folder summaries — same event loop + + # LLMClient (phase 31 convention), per-folder fail-soft inside + # the generator (a ``lite`` failure never flips the exit code). + # It only flushes — this run's own short-lived session commits + # (the phase-53 convention), so a failed commit rolls the + # summaries back with it. + folder_stats: dict[str, int] | None = None + if folders_due: + session = SessionLocal() + try: + folder_stats = await generate_folder_summaries(session, llm) + session.commit() + finally: + session.close() + return summary, overview_status, sources_version, folder_stats - summary, overview_status, sources_version = asyncio.run(_run()) + summary, overview_status, sources_version, folder_stats = asyncio.run(_run()) + folder_token = ( + "skipped" + if folder_stats is None + else f"{folder_stats['generated']}/{folder_stats['failed']}/{folder_stats['pruned']}" + ) print( f"import_docs: files={summary.files} added={summary.added} " f"updated={summary.updated} unchanged={summary.unchanged} " f"pruned={summary.pruned} errors={summary.errors} chunks={summary.chunks} " f"embed_batches={summary.embed_batches} summaries={summary.summaries} " f"summary_errors={summary.summary_errors} formats={summary.format_counts()} " - f"overview={overview_status} sources_version={sources_version}" + f"overview={overview_status} sources_version={sources_version} " + f"folder_summaries={folder_token}" ) # Non-zero if any file failed, so cron/CI notice — the rest of the KB # was imported and the failed files are retried on the next run. The - # overview is best-effort: a failed outline never changes the exit code. + # overview and the folder summaries are best-effort: a failed outline + # or a failed folder batch never changes the exit code. return 1 if summary.errors else 0 diff --git a/scripts/load_test_kb.py b/scripts/load_test_kb.py index 19ecbda..fcc0ef6 100644 --- a/scripts/load_test_kb.py +++ b/scripts/load_test_kb.py @@ -15,23 +15,30 @@ guessable by any model. This script: ``BOR_GIT_SOURCES`` env var), 3. imports the fixture documents through the real pipeline (``import_sources`` — real chunking + real ``embed``-model - embeddings; this is the ONLY step that burns model calls, and only - at build time), + embeddings; this is the only step that burns ``embed`` calls, and + only at build time), 4. stores the static KB overview + the sources-version row, -5. prints a **retrieval report** for every fixture-battery question +5. generates the sync-time **folder summaries** through the real + generator (``app.rag.folder_summaries``) against the live ``lite`` + endpoint — the drill-down ``ls`` (phase 94) shows the stored rows, + so the dump must carry them (the build always truncates first, so + the sync paths' table-empty first-run trigger holds; a failed batch + aborts the build — a summary-less dump would be a broken fixture), +6. prints a **retrieval report** for every fixture-battery question (grounded or deflected, which documents would seed) — the battery must be all-grounded for the gate to exercise the tools, -6. snapshots the resulting database state into +7. snapshots the resulting database state into ``tests/fixtures/test_kb.dump.sql`` — a data-only SQL script (TRUNCATE + one multi-row ``INSERT`` per app table, generated in-process — the same file runs in psql or psycopg, in one transaction) — and **verifies the snapshot by restoring it and comparing a per-table checksum**. -Re-run it only when the fixture documents, the chunker, or the -embedding model change — everyday iterations restore the dump in -sub-second time (``scripts/restore_test_kb`` / the gate's -``--restore``), never re-embedding (see ``TOOL_CALLING_TESTING.md``). +Re-run it only when the fixture documents, the chunker, the embedding +model, or the folder-summary prompt (its ``lite`` output is baked into +the dump) change — everyday iterations restore the dump in sub-second +time (``scripts/restore_test_kb`` / the gate's ``--restore``), never +re-embedding (see ``TOOL_CALLING_TESTING.md``). Exit codes: **0** built + verified, **1** build/verification failure, **2** precondition failure. @@ -59,6 +66,7 @@ from app.models import ( Chunk, DocDraft, Document, + FolderSummary, GitSource, KbOverview, QueryLog, @@ -66,6 +74,7 @@ from app.models import ( SourcesMeta, SteeringNote, ) +from app.rag.folder_summaries import generate_folder_summaries from app.rag.importer import import_sources from app.rag.llm import LLMClient from app.rag.retriever import retrieve @@ -111,6 +120,12 @@ _TABLES: tuple[tuple[str, type, tuple[str, ...]], ...] = ( ("doc_drafts", DocDraft, ("id", "token", "title", "path", "body", "status", "branch", "commit_sha", "created_at", "updated_at")), + # Phase 94 (task 05): the sync-time folder summaries the drill-down + # ``ls`` shows — generated against the live ``lite`` endpoint in + # step 5 of :func:`_build` (the build truncates first, so the sync + # paths' table-empty first-run trigger holds). + ("folder_summaries", FolderSummary, ("source", "folder_path", + "summary", "updated_at")), ) @@ -257,7 +272,31 @@ async def _build(kb_dir: Path, dump_path: Path) -> int: db.add(meta) db.commit() - # 4. Retrieval report (all battery questions must stay grounded). + # 4. The sync-time folder summaries (phase 94, task 05): the + # drill-down ``ls`` shows the stored rows, so the dump carries + # them. Generated against the live ``lite`` endpoint through the + # real sync-time generator — the build just truncated the table, + # so the sync paths' table-empty first-run trigger holds (no + # change-gate bookkeeping needed on a fresh build). The generator + # only flushes; this build commits (the phase-53 convention the + # sync paths follow). A failed batch aborts: the dump's folder + # lines are part of the controlled fixture. + with SessionLocal() as db: + folder_stats = await generate_folder_summaries(db, llm) + db.commit() + logger.info( + "load_test_kb: folder summaries generated=%d failed=%d pruned=%d", + folder_stats["generated"], folder_stats["failed"], folder_stats["pruned"], + ) + if folder_stats["failed"]: + print( + f"load_test_kb: {folder_stats['failed']} folder summary call(s) " + "failed — the fixture dump needs the stored rows; check the LLM " + "endpoint (BOR_LLM_SUMMARY_MODEL) and re-run" + ) + return 1 + + # 5. Retrieval report (all battery questions must stay grounded). n_deflected = await _retrieval_report(llm, list(FIXTURE_BATTERY)) if n_deflected: print( @@ -267,7 +306,7 @@ async def _build(kb_dir: Path, dump_path: Path) -> int: "question before running the gate." ) - # 5. Snapshot (data-only) + round-trip verification. + # 6. Snapshot (data-only) + round-trip verification. with SessionLocal() as db: before = {table: _table_checksum(db, table) for table, _m, _c in _TABLES} parts = [ @@ -309,7 +348,8 @@ async def _build(kb_dir: Path, dump_path: Path) -> int: wall = time.monotonic() - started print( f"load_test_kb: ok — docs={summary.added} chunks={summary.chunks} " - f"sources={len(FIXTURE_SOURCES)} dump={dump_path} " + f"sources={len(FIXTURE_SOURCES)} folder_summaries={folder_stats['generated']} " + f"dump={dump_path} " f"({dump_path.stat().st_size // 1024} KB, verified by round-trip) " f"in {wall:.1f}s" ) diff --git a/scripts/restore_test_kb.py b/scripts/restore_test_kb.py index 9692e16..0bc7f1b 100644 --- a/scripts/restore_test_kb.py +++ b/scripts/restore_test_kb.py @@ -11,11 +11,13 @@ snapshot in **one transaction** through the app's own database URL (``BOR_DATABASE_URL``): no git clone of the homelab repo, no re-embedding, no ``lite``-model calls — the whole known state (documents, chunks + embeddings, the source registry rows, the KB overview, the sources -version) lands in a fraction of a second, which is what makes a -tool-calling iteration loop fast (see ``TOOL_CALLING_TESTING.md``): +version, the stored folder summaries — the drill-down ``ls``'s rows, +phase 94, task 05) lands in a fraction of a second, which is what makes +a tool-calling iteration loop fast (see ``TOOL_CALLING_TESTING.md``): uv run python -m scripts.restore_test_kb - # restore_test_kb: ok in 0.41s (8 docs, 2 sources, 16 chunks) + # restore_test_kb: ok in 0.41s (8 docs, 2 sources, 9 chunks, + # 4 folder summaries, dump … KB) The gate runs the same restore inline: ``uv run python -m scripts.agent_realmodel_check --restore``. @@ -57,6 +59,10 @@ APP_TABLES: tuple[str, ...] = ( "query_log", "saved_chats", "doc_drafts", + # Phase 94 (task 05): the sync-time folder summaries the drill-down + # ``ls`` shows — the dump carries the rows (built by + # ``scripts.load_test_kb`` against the live ``lite`` endpoint). + "folder_summaries", ) #: Repo-relative default dump location (the load script writes it there). @@ -71,6 +77,7 @@ class RestoreResult: docs: int sources: tuple[str, ...] chunks: int + folders: int # stored folder summaries (phase 94) dump_bytes: int @@ -127,11 +134,13 @@ def restore_dump(dump: Path) -> RestoreResult: ) ) chunks = db.execute(text("select count(*) from chunks")).scalar_one() + folders = db.execute(text("select count(*) from folder_summaries")).scalar_one() return RestoreResult( seconds=seconds, docs=docs, sources=sources, chunks=chunks, + folders=folders, dump_bytes=dump.stat().st_size, ) @@ -169,7 +178,8 @@ def main(argv: list[str] | None = None) -> int: print( f"restore_test_kb: ok in {result.seconds:.2f}s " f"({result.docs} docs, {len(result.sources)} sources, " - f"{result.chunks} chunks, dump {result.dump_bytes // 1024} KB)" + f"{result.chunks} chunks, {result.folders} folder summaries, " + f"dump {result.dump_bytes // 1024} KB)" ) return 0 diff --git a/tests/e2e/mock_llm.py b/tests/e2e/mock_llm.py index 79ede57..1b57c6d 100644 --- a/tests/e2e/mock_llm.py +++ b/tests/e2e/mock_llm.py @@ -18,6 +18,14 @@ Implements just enough of the aipi surface: - ``KB_OVERVIEW_MODE`` -> the deterministic outline: the first 8 tokens 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 .`` — 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 + ``SUMMARY_MODE`` branch: the folder marker CONTAINS the summary + marker as a substring, so the summary branch would otherwise + shadow every folder-summary call - ``DEFLECT_MODE`` -> honest "I haven't done anything like that" answer - otherwise -> upbeat answer quoting the provided document context - user message containing ``pretend to think slowly`` -> 3s warm-up delay @@ -57,22 +65,31 @@ Implements just enough of the aipi surface: closing tag — same sentinel semantics.) - user message containing ``use your tools`` (phase 37, agent document tools; phase 70: the flow emits the harness-aligned names — ``ls`` - / ``read`` with the combined ``source/path`` identity) **and** the - system prompt carries the ```` section -> the deterministic - SINGLE-READ tool flow, discriminated statelessly from the messages - (the ``tools`` parameter gates the list/read steps — a no-tools - request with no tool results is not the flow): + / ``read`` with the combined ``source/path`` identity; phase 94: + the flow DRILLS through the tree ``ls`` — the top-level listing + carries sources only, so the flow takes one drill step, ``ls`` + scoped to the first source, before the first file line exists) + **and** the system prompt carries the ```` section -> the + deterministic SINGLE-READ tool flow, discriminated statelessly from + the messages (the ``tools`` parameter gates the list/read steps — + a no-tools request with no tool results is not the flow): * request 1 (``tools`` offered, no tool results yet): stream ONLY ``tool_calls`` deltas — ``ls`` (synthetic id ``call_0``, no arguments), ``finish_reason: "tool_calls"``, no content; - * request 2 (a ``tool``-role catalog result in the messages): - parse the FIRST catalog line (``source: X | path: Y | title: Z`` - — the labeled ``source:`` / ``path:`` fields, phase 63) and - stream a ``tool_calls`` delta calling ``read`` on the JOINED - combined ``source/path`` (the mock joins the two labeled fields - — the catalog format is unchanged, so this join is the only - parse change, phase 70) (id ``call_1``); - * request 3 (a ``tool``-role read result in the messages — + * request 2 (the top-level source listing in the messages — the + agent's ``ls`` tree format, phase 94: no file lines yet): + stream a ``tool_calls`` delta — ``ls`` scoped to the FIRST + source of the listing (id ``call_1``) — the drill step (one + drill per flow — ``_drill_target`` skips already-drilled + sources, so the second listing is the folder level); + * request 3 (a ``tool``-role folder listing with file lines in + the messages): parse the FIRST file line (``source: X | path: + Y | title: Z`` — the labeled ``source:`` / ``path:`` fields, + phase 63) and stream a ``tool_calls`` delta calling ``read`` on + the JOINED combined ``source/path`` (the mock joins the two + labeled fields — the file-line format is unchanged, so this + join is the only parse change, phase 70) (id ``call_2``); + * request 4 (a ``tool``-role read result in the messages — content starting with the agent's ``"Document :"`` header): a content answer, deterministic: ``Read . `` section -> the deterministic - MULTI-READ flow (list → read #1 → read #2 → answer), classified by - the COUNT of ``tool``-role read results (content starting with the - agent's ``"Document :"`` prefix); phase 70: the same - flow on the harness-aligned names — ``ls``, then ``read`` on the - JOINED combined ``source/path`` of each catalog line: - * 0 read results, no catalog yet: ``ls`` (id ``call_0``); - * 0 read results, catalog present: ``read`` on the JOINED - combined ``source/path`` of the FIRST catalog line - (id ``call_1``); + MULTI-READ flow (list → drill → read #1 → read #2 → answer; phase + 94: the drill step between the top-level listing and the first + read), classified by the COUNT of ``tool``-role read results + (content starting with the agent's ``"Document :"`` + prefix); phase 70: the same flow on the harness-aligned names — + ``ls``, the drill ``ls`` scoped to the first source, then ``read`` + on the JOINED combined ``source/path`` of each file line: + * 0 read results, no listing yet: ``ls`` (id ``call_0``); + * 0 read results, top-level listing only (no file lines yet): + the drill — ``ls`` scoped to the first source (id ``call_1``); + * 0 read results, file lines present: ``read`` on the JOINED + combined ``source/path`` of the FIRST file line (id ``call_2``); * 1 read result: ``read`` on the JOINED combined ``source/path`` - of the SECOND catalog line — the first listing line whose + of the SECOND file line — the first listing line whose ``source/path`` differs from the one already read (id - ``call_2``); a one-document catalog degenerates to the + ``call_3``); a one-file listing degenerates to the single-read answer (nothing second to read); * 2 read results: the forced answer, byte-stable: the single-read shape quoting the FIRST read result, plus the line ``I read @@ -162,27 +182,83 @@ Implements just enough of the aipi surface: misuse met the terse refusal and the model re-reasoned the same paragraphs over and over) **and** the system prompt carries the ```` section -> the deterministic LS-TEACHING flow, - discriminated statelessly from the messages (streaming only): + discriminated statelessly from the messages (streaming only; + phase 94: the correction's top-level listing carries sources only, + so the flow drills one level before the first file line exists): * request 1 (``tools`` offered, no ``tool``-role result in the messages yet): stream ONLY ``tool_calls`` deltas — ``ls`` with ``{"path": "."}`` (synthetic id ``call_0``), ``finish_reason: "tool_calls"``, no content — the incident's misuse, deterministic; * request 2 (a ``tool``-role result present that is NOT a - catalog listing — i.e. the teaching refusal): a ``tool_calls`` - delta — ``ls`` with no arguments (id ``call_1``) — the - correction; - * request 3 (a ``tool``-role result whose first line matches the - ``^\\d+ documents:`` catalog header): a deterministic content - answer — ``These are the indexed documents: `` (the ``source: X | path: Y | title: Z`` line, parsed - with the ``_CATALOG_LINE_RE`` machinery), ``finish_reason: - "stop"`` — the loop ended in ONE correction, not at the round + listing with file lines — i.e. the teaching refusal): a + ``tool_calls`` delta — ``ls`` with no arguments (id ``call_1``) + — the correction; + * request 3 (the top-level source listing in the messages — + file lines still absent): the drill — a ``tool_calls`` delta + — ``ls`` scoped to the FIRST source of the listing + (id ``call_2``); + * request 4 (a ``tool``-result with a ``source: X | path: Y | + title: Z`` file line in the messages — the folder listing): + a deterministic content answer — ``These are the indexed + documents: `` (the line, parsed with the + ``_CATALOG_LINE_RE`` machinery), ``finish_reason: "stop"`` — + the loop ended in ONE correction + ONE drill, not at the round cap. Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (the trigger phrases are disjoint substrings — the phase-71 ordering convention); no existing E2E question or fixture file contains the phrase, so every other suite is unaffected. + - user message containing ``drill down the tree`` (``DRILL_TRIGGER``, + phase 94 task 04 — the drill-down ``ls``'s dedicated story suite + ``tests/e2e/test_ls_tree_drilldown.py``) **and** the system prompt + carries the ```` section -> the deterministic SCRIPTED + DRILL-DOWN flow: the question carries its own tool call after the + colon — ``drill down the tree: ls [target]`` (no target = the top + level; ``target`` = a source name or ``source/folder`` path) or + ``drill down the tree: read source/path`` — parsed by + ``_DRILL_CALL_RE`` from the RAW user message (the target keeps its + case), then discriminated statelessly from the tool results + (streaming only): + * request 1 (``tools`` offered, no ``tool``-role result in the + messages yet): the SCRIPTED call — ``ls`` with no arguments + for the top level, ``ls``/``read`` with the parsed target + otherwise (synthetic id ``call_0``); + * the last tool result is the NOT-A-FOLDER teaching refusal + (it carries ``"is not a folder"`` — the phase-94 task-03 + teaching line, the refusal being VISIBLE to the model is what + fires this branch): the scripted one-round recovery — ``ls`` + the target's SOURCE segment (id ``call_1``); + * the last tool result is a READ result (``"Document + :…``): the deterministic echo answer ``Read + . `` (the phase-37 single-read shape — the grounded- + turn citation contract); + * the last tool result is the agent's ALREADY_IN_CONTEXT dedupe + refusal (the read target is already a top-2 retrieval + document — with the drill fixture that is DETERMINISTIC: + the read question names the file's path, so the file + self-matches the hybrid gate and its FULL text is in the + ```` prompt): the model answers FROM THE PROMPT — + the deterministic answer ``Already in context: Read + . `` block>`` (same citation + shape as the read-result branch — the document text reached + the model either way, and the answer proves it); + * any other last result (a top-level or folder LISTING landed): + the deterministic ECHO answer ``Here's the level I listed:\n + `` — the mock echoes what it received + (the house scripted-turn way of asserting on tool results): + the suite asserts on the exact tree level — the ``— N + documents`` lines, the stored folder summaries, the + ``source: X | path: Y | title: Z`` file lines, and the 50-line + cap + ``…and N more documents…`` note — through the rendered + answer, the only E2E lens on the LLM's context. + Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint trigger + phrases — the phase-71/72 ordering convention; the trigger needs + the ```` section, so deflected turns never hit it); no + existing E2E question or fixture file contains the phrase, so + every other suite is unaffected. - user message containing ``what are the correct llama.cpp arguments`` (``GREP_TEACH_TRIGGER``, the 2026-09-05 incident — the harness prior is that grep takes a REGEX; this app's grep is a @@ -302,7 +378,8 @@ from typing import Any from fastapi import FastAPI from fastapi.responses import JSONResponse, StreamingResponse -from app.rag.agent import CORRECTION_INSTRUCTION # phase 71: the harness constant +from app.rag.agent import ALREADY_IN_CONTEXT, CORRECTION_INSTRUCTION +from app.rag.folder_summaries import FOLDER_HEADER_PREFIX # phase 94: the mock's key app = FastAPI() @@ -588,9 +665,92 @@ GREP_TEACH_PLAIN = "qwen3.8" #: plain step keys on it (a plain no-match line carries it not). GREP_TEACH_MARKER = "grep matches a plain substring" -#: The agent's ``ls`` listing header (app.rag.agent ``_execute_tool``): -#: ``"N documents:"`` — the first line of every catalog tool result. -_CATALOG_HEADER_RE = re.compile(r"^\d+ documents:") +# --------------------------------------------------------------------------- +# Phase 94 (task 04, the drill-down ls's dedicated story suite): +# the deterministic SCRIPTED drill-down turns — see the module docstring +# --------------------------------------------------------------------------- + +#: A user message containing this substring (case-insensitive) — +#: combined with the ```` section in the system prompt — drives +#: the scripted DRILL-DOWN flow: the question carries its own tool call +#: after the colon (``drill down the tree: ls [target]`` / ``drill +#: down the tree: read source/path`` — the suite's scripted turns, +#: ``tests/e2e/test_ls_tree_drilldown.py``). Checked BEFORE the plain +#: ``TOOLS_TRIGGER`` flow (disjoint trigger phrases — the phase-71/72 +#: ordering convention); verified: no existing E2E question or fixture +#: file contains the phrase, so every other suite is unaffected. +DRILL_TRIGGER = "drill down the tree" + +#: The scripted call in the drill-down question (case-insensitive — the +#: suite's questions capitalize the trigger's first letter): the verb +#: (``ls`` / ``read``) plus the optional target — a source name or +#: ``source/folder`` path (``ls``) or a combined ``source/path`` +#: (``read``), parsed from the RAW user message so the target keeps its +#: case. The target is a ``[a-z0-9_./-]`` run (case-insensitively), so +#: the suite's `` — `` flavor separator (em dash) can never bleed into +#: it; no run = the top-level ``ls`` (no ``path`` argument). +_DRILL_CALL_RE = re.compile( + r"drill down the tree:\s*(?Pls|read)(?:\s+(?P[a-z0-9_./-]+))?", + re.I, +) + +#: The agent's NOT-A-FOLDER teaching refusal marker (app.rag.agent +#: ``NOT_A_FOLDER`` — ``'{arg}' is not a folder — {parent} has: +#: {subfolders}``): the drill-down flow's scripted recovery keys on it +#: in the LAST tool result (the refusal being visible to the model is +#: exactly what triggers the one-round recovery — the phase-72 +#: self-correction contract carrying the tree's teaching line). +_NOT_A_FOLDER_MARKER = "is not a folder" + +#: The stable substring of the harness-owned dedupe refusal the +#: drill-down flow's ``ctx_answer`` branch keys on (a read target that +#: is already a top-2 retrieval document — the mock then answers from +#: the document's text in the ```` prompt block, exactly +#: what the refusal instructs). Keyed on a substring (not the whole +#: constant) so a re-wrap of the constant cannot silently re-route the +#: mock; the module-level assert below fails loudly if the substring +#: ever leaves the constant (the mock must never drift from +#: ``app.rag.agent.ALREADY_IN_CONTEXT``). +_ALREADY_IN_CONTEXT_MARKER = "Already in your context" +assert _ALREADY_IN_CONTEXT_MARKER in ALREADY_IN_CONTEXT, ( + "mock drift: the dedupe marker left ALREADY_IN_CONTEXT" +) + +#: One ```` block of the HIGH prompt's ```` +#: section (``app.rag.prompts.build_high_prompt``): the block is the +#: document identity (``source``/``path``/``title`` attributes) plus +#: the document's FULL text (never truncated on the retrieval path, +#: owner-locked A7) between the tags. +_DOCUMENT_BLOCK_RE = re.compile( + r'\n(?P.*?)\n', + re.S, +) + + +def _document_block(system: str, source: str, path: str) -> str | None: + """The stored text of one ```` block (or ``None``). + + The drill-down flow's ``ctx_answer`` branch: when the agent's read + of a top-2 retrieval document gets the ALREADY_IN_CONTEXT dedupe, + the document's full text is in the ```` prompt — the + mock (the model) extracts it by the block's identity attributes + and quotes it, answering from the prompt as the refusal instructs. + """ + for block in _DOCUMENT_BLOCK_RE.finditer(system): + if block.group("source") == source and block.group("path") == path: + return block.group("content") + return None + +#: The agent's drill-down ``ls`` result shapes (app.rag.agent +#: ``render_ls_top`` / ``render_folder_listing``, phase 94): the +#: top-level header ``"N sources:"`` and the per-source block line +#: ``" — N documents"`` (summary lines are indented — they +#: never match); the folder-level header ``" — N documents, +#: M folders:"`` (identity = the source name or ``source/folder``). +_SOURCE_HEADER_RE = re.compile(r"^\d+ sources:$") +_SOURCE_LINE_RE = re.compile(r"^(?P.+?) — (?P\d+) documents$") +_FOLDER_HEADER_RE = re.compile(r"^(?P.+?) — \d+ documents, \d+ folders:$") #: One DEAD app-level chat attempt costs exactly this many HTTP POSTs #: while the endpoint stays down: the openai SDK's default policy @@ -713,26 +873,60 @@ def _tool_results(body: dict[str, Any]) -> list[str]: ] -def _first_catalog_line(body: dict[str, Any]) -> str | None: - """The first catalog line of a catalog listing in the messages. +def _first_file_line(body: dict[str, Any]) -> str | None: + """The first file line (``source: X | path: Y | title: Z``) across + the ``tool``-role results in the messages, in message order (read + results — full documents, not listings — skipped; the ``_CATALOG_- + LINE_RE`` machinery). - A catalog listing is a ``tool``-role result whose FIRST line is the - agent's ``"N documents:"`` header (``_CATALOG_HEADER_RE``); its - first ``source: X | path: Y | title: Z`` line (the - ``_CATALOG_LINE_RE`` machinery) is returned. ``None`` when no - catalog listing is in the messages — e.g. while only the teaching - refusal is there (the phase-72 LS-TEACH flow's request-2 state). - An empty listing (``"0 documents:"`` with no lines) returns - ``""`` — the listing is present, it is just empty. + Phase 94: the drill-down ``ls`` carries file lines only at folder + levels — the top-level source listing and the teaching refusals + have none, so ``None`` here means "no folder level reached yet". """ for content in _tool_results(body): - lines = content.splitlines() - if not lines or not _CATALOG_HEADER_RE.match(lines[0]): + if content.startswith(_READ_RESULT_PREFIX): continue - for line in lines[1:]: + for line in content.splitlines(): if _CATALOG_LINE_RE.match(line): return line - return "" + return None + + +def _drill_target(body: dict[str, Any]) -> str | None: + """The next source to drill into (phase 94, the drill step). + + The drill-down ``ls`` top level lists SOURCES only (no file lines), + so a deterministic flow that needs a file line must drill one level: + ``ls`` scoped to a source. The target is the FIRST source of the + top-level listing (registry order — the listing's own order) that + does not yet have a folder-level listing in the messages (a folder + header whose identity is the source or ``source/…``); ``None`` when + no top-level listing is in the messages yet (nothing to drill from) + or every listed source has been drilled (an empty KB — the flow + degenerates to the old re-list loop, settling at the round cap + exactly like the phase-70 empty-catalog case). + """ + sources: list[str] = [] + drilled: set[str] = set() + for content in _tool_results(body): + lines = content.splitlines() + if not lines: + continue + if _SOURCE_HEADER_RE.match(lines[0]): + for line in lines[1:]: + match = _SOURCE_LINE_RE.match(line) + if match: + sources.append(match.group("source")) + else: + folder = _FOLDER_HEADER_RE.match(lines[0]) + if folder: + drilled.add(folder.group("identity")) + for source in sources: + if not any( + identity == source or identity.startswith(source + "/") + for identity in drilled + ): + return source return None @@ -853,11 +1047,14 @@ def _tool_flow(body: dict[str, Any]) -> tuple[str, ...] | None: ``TOOLS_TRIGGER`` and ``MULTI_READ_TRIGGER``), classified by the count of ``tool``-role read results: - * 0 read results: ``("list", "", "")`` (no catalog yet) or - ``("read", source, path, "call_1")`` on the FIRST catalog doc. - * 1 read result: ``("read", source, path, "call_2")`` on the SECOND - catalog doc — the first listing line whose ``source/path`` - differs from the one already read. A one-document catalog + * 0 read results: ``("list", "", "")`` (no listing yet), + ``("drill", source, "call_1")`` when the top-level source + listing is in the messages but no file line yet (phase 94: the + drill — the top level carries sources only), or + ``("read", source, path, "call_2")`` on the FIRST file-line doc. + * 1 read result: ``("read", source, path, "call_3")`` on the SECOND + file-line doc — the first listing line whose ``source/path`` + differs from the one already read. A one-file listing degenerates to the single-read ``("answer", ...)`` shape (nothing second to read). * 2 read results: ``("multi_answer", "", text)`` — the forced @@ -880,30 +1077,37 @@ def _tool_flow(body: dict[str, Any]) -> tuple[str, ...] | None: if not body.get("tools"): return None docs = _catalog_docs(body) - if not docs: - return ("list", "", "") - return ("read", docs[0][0], docs[0][1], "call_1") + if docs: + return ("read", docs[0][0], docs[0][1], "call_2") + drill = _drill_target(body) + if drill is not None: + return ("drill", drill, "call_1") + return ("list", "", "") if len(reads) == 1: skip = reads[0][0] second = next( (d for d in _catalog_docs(body) if f"{d[0]}/{d[1]}" != skip), None ) if second is None: - # One-document catalog: nothing second to read — the + # One-file listing: nothing second to read — the # single-read answer shape (deterministic degenerate). return ("answer", reads[0][0], reads[0][1]) - return ("read", second[0], second[1], "call_2") + return ("read", second[0], second[1], "call_3") (sp1, c1), (sp2, _c2) = reads[0], reads[1] answer = f"Read {sp1}. {c1[:80]} I read {sp1} and {sp2}." return ("multi_answer", "", answer) - # Phase-37 single-read flow — byte-identical to the original. + # Phase-37 single-read flow (phase 94: the drill step between the + # top-level listing and the first file line). if reads: return ("answer", reads[0][0], reads[0][1]) if not body.get("tools"): return None docs = _catalog_docs(body) if docs: - return ("read", docs[0][0], docs[0][1], "call_1") + return ("read", docs[0][0], docs[0][1], "call_2") + drill = _drill_target(body) + if drill is not None: + return ("drill", drill, "call_1") return ("list", "", "") @@ -915,13 +1119,17 @@ def _ls_teach_flow(body: dict[str, Any]) -> tuple[str, ...] | None: with ``{"path": "."}`` (id ``call_0``), ``finish_reason: "tool_calls"``, no content. * ``("correct",)`` — a ``tool``-role result is in the messages and - it is NOT a catalog listing (the teaching refusal): the - correction — ``ls`` with no arguments (id ``call_1``). - * ``("answer", line)`` — a ``tool``-role result whose first line - is the ``"N documents:"`` catalog header: the deterministic + no file line exists yet (the teaching refusal): the correction — + ``ls`` with no arguments (id ``call_1``). + * ``("drill", source, "call_2")`` — the top-level source listing + is in the messages (the correction ran) but no file line yet + (phase 94: the top level carries sources only): the drill — + ``ls`` scoped to the first source of the listing. + * ``("answer", line)`` — a ``source: X | path: Y | title: Z`` file + line is in the messages (the folder listing): the deterministic content answer ``These are the indexed documents: `` (the - first catalog line), ``finish_reason: "stop"`` — the loop - settled in ONE correction, not at the round cap. + first file line), ``finish_reason: "stop"`` — the loop settled + in ONE correction + ONE drill, not at the round cap. * ``None`` — not the flow: the trigger is absent, the ```` section is missing (deflected turns never carry it), or ``tools`` are not offered and no tool results are in the @@ -931,9 +1139,12 @@ def _ls_teach_flow(body: dict[str, Any]) -> tuple[str, ...] | None: return None if "" not in _system(body): return None - line = _first_catalog_line(body) + line = _first_file_line(body) if line is not None: return ("answer", line) + drill = _drill_target(body) + if drill is not None: + return ("drill", drill, "call_2") if _tool_results(body): return ("correct",) if not body.get("tools"): @@ -996,6 +1207,92 @@ def _grep_teach_flow(body: dict[str, Any]) -> tuple[str, ...] | None: return ("nomatch",) +def _drill_flow(body: dict[str, Any]) -> tuple[str, ...] | None: + """Classify a phase-94 scripted drill-down request (see the module + docstring). The question carries the scripted call + (``drill down the tree: ls [target]`` / ``drill down the tree: read + source/path``); the step is then discriminated statelessly from the + tool results, like the other marker flows: + + * ``("call", verb, target, "call_0")`` — ``tools`` are offered and + no ``tool``-role result is in the messages yet: the scripted call + (``ls`` with NO path for the top level — empty target —, ``ls`` + with the target for source/folder levels, ``read`` with the + combined ``source/path``). + * ``("recover", source)`` — the LAST tool result is the NOT-A-FOLDER + teaching refusal (it carries :data:`_NOT_A_FOLDER_MARKER`): the + scripted one-round recovery — ``ls`` the target's SOURCE segment + (the refusal listing the parent's subfolders is what lets the + model — and this mock — self-correct in the next round, the + phase-72 contract). + * ``("read_answer", combined, quote)`` — the LAST tool result is a + read result (``"Document :\n"``): the + deterministic echo answer ``Read . + `` (the phase-37 single-read shape — the grounded-turn citation + contract the suite asserts). + * ``("ctx_answer", target, quote)`` — the scripted call was a + ``read`` and the LAST tool result is the ALREADY_IN_CONTEXT + dedupe refusal (the target is already a top-2 retrieval document + — deterministic for the drill fixture: the read question names + the file's path, so the file self-matches the hybrid gate): the + model answers FROM THE PROMPT — ``Already in context: Read + . `` block>`` (the same citation shape as the + read-result branch — the document text reached the model either + way). Falls through to the echo when the target is not a + ```` block (the premise broke — the suite fails + loudly on the answer). + * ``("echo", listing)`` — a listing landed (top-level or folder — + any other last result): the deterministic ECHO — the answer + carries the listing VERBATIM (``Here's the level I listed:\n + ``), so the suite asserts on the exact tree level the + model saw (source ``— N documents`` lines + stored summaries, + subfolder lines, the ``source: X | path: Y | title: Z`` file + lines, the 50-line cap + note) through the rendered answer — the + house scripted-turn way of asserting on tool results (the mock is + the only E2E lens on the LLM's context). + * ``None`` — not the flow: the trigger is absent, the ```` + section is missing (deflected turns never carry it), the scripted + call is unparseable, or ``tools`` are not offered and no tool + results are in the messages yet (e.g. ``agent_max_rounds=0``). + """ + user = _user(body) + if DRILL_TRIGGER not in user.lower(): + return None + if "" not in _system(body): + return None + match = _DRILL_CALL_RE.search(user) + if match is None: + return None + verb = match.group("verb") + target = match.group("arg") or "" + results = _tool_results(body) + if not results: + if not body.get("tools"): + return None + return ("call", verb, target, "call_0") + last = results[-1] + if last.startswith(_READ_RESULT_PREFIX): + head, _, content = last.partition("\n") + # The read result is ``"Document :\n"`` — + # the head carries the server's appended ``:`` (removed here; + # a document path never legitimately ends with one). + combined = head[len(_READ_RESULT_PREFIX):].strip().removesuffix(":") + return ("read_answer", combined, content[:80]) + if _ALREADY_IN_CONTEXT_MARKER in last and verb == "read" and "/" in target: + # The dedupe fired: the read target is already a top-2 + # retrieval document, so its FULL text is in the + # ```` prompt — answer from the prompt (the + # refusal's instruction), quoting the block's text. + src, _, p = target.partition("/") + content = _document_block(_system(body), src, p) + if content is not None: + return ("ctx_answer", target, content[:80]) + if _NOT_A_FOLDER_MARKER in last: + return ("recover", target.split("/")[0]) + return ("echo", last) + + def long_answer() -> str: """~900-word deterministic walkthrough (phase 11): numbered steps plus a unique final line that must survive the stream untruncated.""" @@ -1103,6 +1400,28 @@ def compose_answer(body: dict[str, Any]) -> str: user = _user(body) 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 .`` — 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. + # 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 + # shadow every folder-summary call. Checked BEFORE the + # DEFLECT_MODE branch, like the other lite-mode markers (a + # deflection prompt never carries one). + header = user.splitlines()[0] if user else "" + folder = ( + header.removeprefix(FOLDER_HEADER_PREFIX).strip() + if header.startswith(FOLDER_HEADER_PREFIX) + else "(unnamed folder)" + ) + answer = f"Fixture folder summary 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 @@ -1594,9 +1913,16 @@ def chat_completions(body: dict[str, Any]) -> Any: # The incident's misuse, deterministic: ls(path='.'). stream = _tool_call_stream("ls", {"path": "."}, "call_0") elif ls_teach[0] == "correct": - # The one-round correction: the no-arg full listing. + # The one-round correction: the no-arg top-level + # listing (phase 94: sources only — the drill follows). stream = _tool_call_stream("ls", {}, "call_1") - else: # "answer" — quote the first catalog line + elif ls_teach[0] == "drill": + # Phase 94: the top level lists sources only — drill + # one level into the first source for the file lines. + stream = _tool_call_stream( + "ls", {"path": ls_teach[1]}, ls_teach[2] + ) + else: # "answer" — quote the first file line answer = _apply_max_tokens( f"These are the indexed documents: {ls_teach[1]}", body.get("max_tokens"), @@ -1607,10 +1933,67 @@ def chat_completions(body: dict[str, Any]) -> Any: media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) + # Phase 94 (task 04): the deterministic SCRIPTED drill-down + # turns (sources → folders → files → the grounded read; the + # 50-line cap; the NOT-A-FOLDER teaching + scripted recovery) — + # checked BEFORE the plain TOOLS_TRIGGER flow (disjoint trigger + # phrases — the phase-71/72 ordering convention; the trigger + # needs the ```` section, so deflected turns never hit + # it). + drill = _drill_flow(body) + if drill is not None: + if drill[0] == "call": + # The scripted call: ls with no path at the top level + # (empty target), ls/read with the parsed target. + stream = _tool_call_stream( + drill[1], {"path": drill[2]} if drill[2] else {}, drill[3] + ) + elif drill[0] == "recover": + # The scripted one-round recovery after the NOT-A-FOLDER + # teaching: ls the target's source (the parent level). + stream = _tool_call_stream("ls", {"path": drill[1]}, "call_1") + elif drill[0] == "read_answer": + # Quote the read document (first 80 chars) — the + # phase-37 single-read shape. + stream = _sse_stream( + _apply_max_tokens( + f"Read {drill[1]}. {drill[2]}", body.get("max_tokens") + ), + 0.0, + ) + elif drill[0] == "ctx_answer": + # The dedupe fired: the target's full text is in the + # prompt — answer from it (same citation + # shape, prefixed so the suite pins the dedupe path). + stream = _sse_stream( + _apply_max_tokens( + f"Already in context: Read {drill[1]}. {drill[2]}", + body.get("max_tokens"), + ), + 0.0, + ) + else: # "echo" — the listing verbatim (the suite's lens) + stream = _sse_stream( + _apply_max_tokens( + f"Here's the level I listed:\n{drill[1]}", + body.get("max_tokens"), + ), + 0.0, + ) + return StreamingResponse( + stream, + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) flow = _tool_flow(body) if flow is not None: if flow[0] == "list": stream = _tool_call_stream("ls", {}, "call_0") + elif flow[0] == "drill": + # Phase 94: the drill step — ls scoped to the first + # source of the top-level listing (flow[1], flow[2] is + # the synthetic call id). + stream = _tool_call_stream("ls", {"path": flow[1]}, flow[2]) elif flow[0] == "read": # flow[3] is the synthetic call id — "call_1" for the # single-read flow and the multi-read first read, diff --git a/tests/e2e/test_agent_document_tools.py b/tests/e2e/test_agent_document_tools.py index 2f15fe7..7ff7f04 100644 --- a/tests/e2e/test_agent_document_tools.py +++ b/tests/e2e/test_agent_document_tools.py @@ -11,15 +11,22 @@ deterministic marker flow in ``tests/e2e/mock_llm.py`` (user message contains ``use your tools`` **and** the system prompt carries the ```` section of the HIGH prompt; phase 70: the flow emits the harness-aligned names — ``ls`` / ``read`` with the combined -``source/path`` identity): +``source/path`` identity; phase 94: the drill-down ``ls`` — the top +level lists sources only, so the flow drills one level into the first +source before the first file line exists): 1. request 1 (``tools`` offered, no tool results yet) → streams ONLY ``tool_calls`` deltas calling ``ls`` (id ``call_0``, no arguments, ``finish_reason: "tool_calls"``); -2. request 2 (a ``tool``-role catalog result in the messages) → streams a - ``tool_calls`` delta calling ``read`` on the JOINED combined - ``source/path`` of the FIRST catalog line (id ``call_1``); -3. request 3 (a ``tool``-role read result in the messages) → the content +2. request 2 (the top-level source listing in the messages — no file + lines yet) → a ``tool_calls`` delta — ``ls`` scoped to the FIRST + source of the listing (id ``call_1``) — the drill step (the seed + registers ``Deployments`` first, so the drill — and therefore the + read — lands on the JSON file); +3. request 3 (a ``tool``-role folder listing with file lines) → streams + a ``tool_calls`` delta calling ``read`` on the JOINED combined + ``source/path`` of the FIRST file line (id ``call_2``); +4. request 4 (a ``tool``-role read result in the messages) → the content answer ``Read . `` — so the suite can assert the read document reached the model and landed in the answer. @@ -72,7 +79,7 @@ from sqlalchemy import select, text from sqlalchemy.orm import Session from app.db import SessionLocal -from app.models import Chunk, Document, QueryLog +from app.models import Chunk, Document, GitSource, QueryLog from e2e.auth_helpers import login from tests.e2e.mock_llm import embed_text @@ -148,7 +155,22 @@ READ_CHIP_HREF = f"/document.html?source={READ_SOURCE}&path={READ_PATH}&back=%2F def _seed(db: Session) -> None: - """The two-document pair from the TODO (see the module docstring).""" + """The two-document pair from the TODO (see the module docstring). + + Phase 94: the drill-down ``ls`` top level reads the registry — the + seed registers BOTH sources (TRUNCATEd in ``_reset_db``), + ``Deployments`` FIRST: registry order is ``(added_at, id)``, so the + mock's drill (first source of the listing) lands on the JSON file + deterministically — independent of the operator's + ``BOR_GIT_SOURCES`` (a non-empty table ignores the env fallback). + """ + # COMMIT between the inserts (not flush): ``added_at`` is + # ``server_default now()`` — the transaction timestamp — and the + # tie-break is the random uuid ``id``, so one-transaction rows order + # nondeterministically. + db.add(GitSource(url=READ_SOURCE, kind="local")) + db.commit() + db.add(GitSource(url=SEED_SOURCE, kind="local")) md = Document( source=SEED_SOURCE, path=SEED_PATH, @@ -195,7 +217,10 @@ def _reset_db(seed: Callable[[Session], None] | None = None) -> None: """ with SessionLocal() as db: db.execute( - text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview") + text( + "TRUNCATE chunks, documents, query_log, steering_notes, " + "kb_overview, git_sources" + ) ) db.commit() if seed is not None: @@ -389,12 +414,14 @@ def test_marker_question_lists_reads_and_quotes( assert i_list is not None and i_read is not None, statuses assert i_list < i_read, statuses - # Wire level: exactly two `tool` frames — ``ls`` then ``read`` (the - # combined source/path as the model passed it) — and both ahead of - # the first `delta` frame. + # Wire level: exactly three `tool` frames — ``ls`` (the top level), + # the drill ``ls`` scoped to the first source (phase 94), then + # ``read`` (the combined source/path as the model passed it) — and + # all three ahead of the first `delta` frame. frames = _frames(page) assert _tool_frames(frames) == [ {"type": "tool", "name": "ls", "argument": None}, + {"type": "tool", "name": "ls", "argument": READ_SOURCE}, {"type": "tool", "name": "read", "argument": READ_SP}, ] first_delta = next(i for i, f in enumerate(frames) if f.get("type") == "delta") @@ -408,12 +435,15 @@ def test_marker_question_lists_reads_and_quotes( (READ_SOURCE, READ_PATH), ] - # Both tool lines, in order, above the answer. + # All three tool lines, in order, above the answer (phase 94: the + # drill line is "Listing documents in "). lines = page.locator(".msg.brain .tool-call") - expect(lines).to_have_count(2) + expect(lines).to_have_count(3) expect(lines.nth(0)).to_contain_text("Listing documents") - expect(lines.nth(1)).to_contain_text("Reading ") - expect(lines.nth(1)).to_contain_text(READ_SP) + expect(lines.nth(1)).to_contain_text("Listing documents in") + expect(lines.nth(1)).to_contain_text(READ_SOURCE) + expect(lines.nth(2)).to_contain_text("Reading ") + expect(lines.nth(2)).to_contain_text(READ_SP) # The final answer quotes the read document (the mock's deterministic # quote: "Read . "). @@ -451,18 +481,20 @@ def test_tool_lines_re_render_after_reload( _submit(page, MARKER_QUESTION) _wait_settled(page) - expect(page.locator(".msg.brain .tool-call")).to_have_count(2) + expect(page.locator(".msg.brain .tool-call")).to_have_count(3) page.reload() expect(page.locator("#empty-state")).to_be_hidden() - # The persisted record re-renders BOTH tool lines, in saved order, - # through the same append helper as the live frames. + # The persisted record re-renders ALL THREE tool lines, in saved + # order, through the same append helper as the live frames. restored = page.locator(".msg.brain .tool-call") - expect(restored).to_have_count(2) + expect(restored).to_have_count(3) expect(restored.nth(0)).to_contain_text("Listing documents") - expect(restored.nth(1)).to_contain_text("Reading ") - expect(restored.nth(1)).to_contain_text(READ_SP) + expect(restored.nth(1)).to_contain_text("Listing documents in") + expect(restored.nth(1)).to_contain_text(READ_SOURCE) + expect(restored.nth(2)).to_contain_text("Reading ") + expect(restored.nth(2)).to_contain_text(READ_SP) # Answer + the read-document chip are intact (phase-14 restore path). bubble = page.locator(".msg.brain .bubble").last diff --git a/tests/e2e/test_agent_unlimited_tools.py b/tests/e2e/test_agent_unlimited_tools.py index 1d97011..0ccf01c 100644 --- a/tests/e2e/test_agent_unlimited_tools.py +++ b/tests/e2e/test_agent_unlimited_tools.py @@ -11,19 +11,26 @@ message contains BOTH ``use your tools`` (``TOOLS_TRIGGER``) and ``read two documents`` (``MULTI_READ_TRIGGER``) **and** the system prompt carries the ```` section of the HIGH prompt; phase 70: the flow emits the harness-aligned names — ``ls``, then ``read`` on the JOINED -combined ``source/path`` of each catalog line): +combined ``source/path`` of each file line; phase 94: the drill-down +``ls`` — the top level lists sources only, so the flow drills one +level into the first source before the first file line exists): 1. request 1 (``tools`` offered, no tool results yet) → streams ONLY ``tool_calls`` deltas calling ``ls`` (id ``call_0``); -2. request 2 (the ``tool``-role catalog result) → ``read`` on the - JOINED combined ``source/path`` of the FIRST catalog line - (id ``call_1``); -3. request 3 (one ``tool``-role read result) → ``read`` on the JOINED - combined ``source/path`` of the SECOND catalog line (id ``call_2``) - — the pre-phase-45 per-tool budgets would have refused exactly this +2. request 2 (the top-level source listing in the messages — no file + lines yet) → the drill: ``ls`` scoped to the FIRST source of the + listing (id ``call_1``); the seed registers ``Deployments`` first, + and both read documents live in it — so the drill's folder listing + carries BOTH file lines; +3. request 3 (a ``tool``-role folder listing with file lines) → + ``read`` on the JOINED combined ``source/path`` of the FIRST file + line (id ``call_2``); +4. request 4 (one ``tool``-role read result) → ``read`` on the JOINED + combined ``source/path`` of the SECOND file line (id ``call_3``) — + the pre-phase-45 per-tool budgets would have refused exactly this second read (``No reading budget left — answer with what you have.``); -4. request 4 (two read results) → the forced answer, byte-stable: the +5. request 5 (two read results) → the forced answer, byte-stable: the single-read shape quoting the FIRST read result, plus the line ``I read and .`` naming both read paths in read order. @@ -49,10 +56,11 @@ rejection, not the multi-read flow this story proves. Test → story mapping (Playwright Mapping Rule): -1. ``test_multi_read_turn`` — the turn streams THREE ``tool`` frames / - ``.tool-call`` lines in order (one list — "is listing documents" — - and two reads — "is reading " — the #send-status - transition recorded deterministically via MutationObserver), then a +1. ``test_multi_read_turn`` — the turn streams FOUR ``tool`` frames / + ``.tool-call`` lines in order (the top-level list, the drill list — + phase 94 — "is listing documents", and two reads — "is reading + " — the #send-status transition recorded + deterministically via MutationObserver), then a final non-deflected answer containing the mock's byte-stable ``I read and .`` line; the round cap (default 10) bounds the turn, no budget refusal anywhere. @@ -84,7 +92,7 @@ from sqlalchemy import select, text from sqlalchemy.orm import Session from app.db import SessionLocal -from app.models import Chunk, Document, QueryLog +from app.models import Chunk, Document, GitSource, QueryLog from e2e.auth_helpers import login from tests.e2e.mock_llm import embed_text @@ -209,7 +217,23 @@ def _doc(source: str, path: str, title: str, content: str) -> Document: def _seed(db: Session) -> None: - """The three-document KB from the module docstring.""" + """The three-document KB from the module docstring. + + Phase 94: the drill-down ``ls`` top level reads the registry — + register BOTH sources (TRUNCATEd in ``_reset_db``), ``Deployments`` + FIRST (registry order is ``(added_at, id)``): the mock's drill + (first source of the listing) lands on the folder that carries + BOTH file lines. A non-empty table also ignores the operator's + ``BOR_GIT_SOURCES`` fallback — deterministic. + """ + # COMMIT between the inserts (not flush): ``added_at`` is + # ``server_default now()`` — the transaction timestamp — and the + # tie-break is the RANDOM uuid ``id``, so two rows in one + # transaction order nondeterministically (the integration + # ``registry`` fixture's pattern). + db.add(GitSource(url=READ1_SOURCE, kind="local")) + db.commit() + db.add(GitSource(url=SEED_SOURCE, kind="local")) md = _doc(SEED_SOURCE, SEED_PATH, "AWS Route 53 Notes", ROUTE53_CONTENT) db.add(md) db.flush() @@ -240,7 +264,10 @@ def _reset_db(seed: Callable[[Session], None] | None = None) -> None: """ with SessionLocal() as db: db.execute( - text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview") + text( + "TRUNCATE chunks, documents, query_log, steering_notes, " + "kb_overview, git_sources" + ) ) db.commit() if seed is not None: @@ -399,13 +426,16 @@ def test_multi_read_turn( _submit(page, MULTI_QUESTION) _wait_settled(page) - # Wire level: exactly THREE `tool` frames — ls, read #1, read #2 - # (each read's argument is the JOINED combined source/path), in - # order — and all ahead of the first `delta` frame. This third - # frame is the one the pre-phase-45 read budget refused. + # Wire level: exactly FOUR `tool` frames — the top-level ls, the + # drill ls scoped to the first source (phase 94), then read #1 and + # read #2 (each read's argument is the JOINED combined + # source/path), in order — and all ahead of the first `delta` + # frame. The fourth frame is the one the pre-phase-45 read budget + # refused. frames = _frames(page) assert _tool_frames(frames) == [ {"type": "tool", "name": "ls", "argument": None}, + {"type": "tool", "name": "ls", "argument": READ1_SOURCE}, {"type": "tool", "name": "read", "argument": READ1_SP}, {"type": "tool", "name": "read", "argument": READ2_SP}, ] @@ -439,14 +469,17 @@ def test_multi_read_turn( ), statuses assert i_list < i_read1 < i_read2, statuses - # Three visible tool lines, in order, above the answer. + # Four visible tool lines, in order, above the answer (phase 94: + # the drill line is "Listing documents in "). lines = page.locator(".msg.brain .tool-call") - expect(lines).to_have_count(3) + expect(lines).to_have_count(4) expect(lines.nth(0)).to_contain_text("Listing documents") - expect(lines.nth(1)).to_contain_text("Reading ") - expect(lines.nth(1)).to_contain_text(READ1_SP) + expect(lines.nth(1)).to_contain_text("Listing documents in") + expect(lines.nth(1)).to_contain_text(READ1_SOURCE) expect(lines.nth(2)).to_contain_text("Reading ") - expect(lines.nth(2)).to_contain_text(READ2_SP) + expect(lines.nth(2)).to_contain_text(READ1_SP) + expect(lines.nth(3)).to_contain_text("Reading ") + expect(lines.nth(3)).to_contain_text(READ2_SP) # The final answer is non-deflected, quotes the FIRST read result, # and names BOTH read paths (the mock's byte-stable line). @@ -563,19 +596,23 @@ def test_single_tool_flow_regression( _submit(page, SINGLE_QUESTION) _wait_settled(page) - # Exactly TWO tool frames — ls then ONE read of the first catalog - # line (the JOINED combined source/path) — no second read (the - # marker carries no multi-read trigger). + # Exactly THREE tool frames — the top-level ls, the drill ls + # (phase 94), then ONE read of the first file line (the JOINED + # combined source/path) — no second read (the marker carries no + # multi-read trigger). frames = _frames(page) assert _tool_frames(frames) == [ {"type": "tool", "name": "ls", "argument": None}, + {"type": "tool", "name": "ls", "argument": READ1_SOURCE}, {"type": "tool", "name": "read", "argument": READ1_SP}, ] lines = page.locator(".msg.brain .tool-call") - expect(lines).to_have_count(2) + expect(lines).to_have_count(3) expect(lines.nth(0)).to_contain_text("Listing documents") - expect(lines.nth(1)).to_contain_text("Reading ") - expect(lines.nth(1)).to_contain_text(READ1_SP) + expect(lines.nth(1)).to_contain_text("Listing documents in") + expect(lines.nth(1)).to_contain_text(READ1_SOURCE) + expect(lines.nth(2)).to_contain_text("Reading ") + expect(lines.nth(2)).to_contain_text(READ1_SP) # The single-read answer shape: quotes the read document; it does # NOT carry the multi-read both-named line (READ2 was never read). diff --git a/tests/e2e/test_big_read_progress.py b/tests/e2e/test_big_read_progress.py index 5003f9c..6a76e2e 100644 --- a/tests/e2e/test_big_read_progress.py +++ b/tests/e2e/test_big_read_progress.py @@ -19,27 +19,33 @@ phase-64 default 0.15 is far below the 5 s tool-line threshold): NOTE the prelude: the chat flow's QUESTION EMBEDDING also travels through the proxy (one 6 s sleep) before the agent loop starts, so -the wall-clock timeline of one turn is: +the wall-clock timeline of one turn is (phase 94: the drill-down +``ls`` adds a drill step — the top level lists sources only, so the +flow drills one level before the first file line exists): 1. t≈12 s — the first SSE ``tool`` frame ("🔎 Listing documents"): the 6 s embedding sleep + request 1's 6 s sleep (``tools`` offered, no tool results yet — streams ONLY the ``ls`` ``tool_calls`` delta, id ``call_0``) + the mock's ~0.2 s tool-call stream; -2. t≈19 s — the second ``tool`` frame ("📄 Reading - Deployments/example-record-file.json"): request 2's 6 s sleep (a - ``tool``-role catalog result → streams a ``read`` ``tool_calls`` - delta on the JOINED combined ``source/path`` of the FIRST catalog - line, id ``call_1``); -3. t≈25 s — the content answer ``Read . ``: request 3's 6 s sleep (a +2. t≈19 s — the second ``tool`` frame ("🔎 Listing documents in + Deployments"): request 2's 6 s sleep (the top-level source listing + in the messages — no file lines yet → streams the drill ``ls`` + scoped to the first source, id ``call_1``); +3. t≈25 s — the third ``tool`` frame ("📄 Reading + Deployments/example-record-file.json"): request 3's 6 s sleep (a + ``tool``-role folder listing with file lines → streams a ``read`` + ``tool_calls`` delta on the JOINED combined ``source/path`` of the + FIRST file line, id ``call_2``); +4. t≈31 s — the content answer ``Read . ``: request 4's 6 s sleep (a ``tool``-role read result) → the first answer ``delta``. So each post-tool-frame gap (≈6.3 s — the 6 s sleep + the short tool-call stream) is PAST the 5 s tool-line threshold (``TOOL_LINE_ELAPSED_AFTER_MS = 5_000``), and the 10 s pre-token typing hint (the existing ``startThinkingClock`` gate, ticking from -t≈0) is visible throughout both post-tool gaps — everything before the -answer's first delta (the whole turn runs ≈25 s + overhead, acceptable +t≈0) is visible throughout all post-tool gaps — everything before the +answer's first delta (the whole turn runs ≈31 s + overhead, acceptable for an isolated story suite). The four tests pin: (1) the latest tool line's ticking "(Ns)" suffix, (2) the typing indicator's visible "Ns" hint (+ the kept aria channel), (3) BOTH settling the instant the @@ -55,15 +61,20 @@ task that writes the visible span) — so the two channels read polls until they align (the deterministic same-moment pin). **KB fixture** — byte-identical to the phase-37 suite -(``tests/e2e/test_agent_document_tools.py``): ``Homelab/aws-route53.md`` +(``tests/e2e/test_agent_document_tools.py``), plus the phase-94 +registry rows: both sources are registered in ``git_sources`` +(``Deployments`` FIRST — registry order ``(added_at, id)``), so the +mock's drill (first source of the top-level listing) lands on the +folder that carries the file lines. ``Homelab/aws-route53.md`` carries one chunk embedded with the mock's own bag-of-words vector — the marker question (phase 37's exact question, which carries the trigger phrase) cosines ≈0.69 against it, well past the E2E 0.30 threshold, so the turn is grounded and the HIGH prompt carries the ```` section; ``Deployments/example-record-file.json`` is indexed WITHOUT chunks, and its ``(source, path)`` sorts FIRST in the -catalog (``Deployments`` < ``Homelab``) — exactly the line the mock's -second request reads, so the answer is byte-stable. +folder listing's file lines (``Deployments`` < ``Homelab``) — exactly +the line the mock's read request targets, so the answer is +byte-stable. Test → story mapping (Playwright Mapping Rule): 1. ``test_tool_line_shows_ticking_elapsed`` @@ -92,7 +103,7 @@ from sqlalchemy.orm import Session from app.config import Settings as _Settings from app.db import SessionLocal -from app.models import Chunk, Document +from app.models import Chunk, Document, GitSource from e2e.auth_helpers import login from e2e.conftest import ( ADMIN_PASSWORD, @@ -211,8 +222,10 @@ SUFFIX_TIMEOUT_MS = 12_000 #: the indicator is removed at the first delta (≈25 s): 20 s from the #: check start cannot flake. TYPING_HINT_TIMEOUT_MS = 20_000 -#: The answer settles at ≈19 s from submit; every settle wait here starts -#: ≥12 s in — 30 s keeps ≥2× headroom on the remaining window. +#: The answer settles at ≈31 s from submit (phase 94: the drill step +#: adds a third tool round); every settle wait here starts ≈17 s in +#: (after the first line's suffix) — 30 s keeps ≥2× headroom on the +#: remaining window. SETTLE_TIMEOUT_MS = 30_000 #: The restore is a synchronous boot re-render — 15 s is ample. RESTORE_TIMEOUT_MS = 15_000 @@ -360,7 +373,16 @@ def app_url(app_server: str) -> str: def _seed(db: Session) -> None: """The phase-37 two-document pair, byte-identical (see the module - docstring).""" + docstring), plus the phase-94 registry rows: both sources + registered, ``Deployments`` FIRST — the mock's drill (first + source of the top-level listing) lands on the JSON file.""" + # COMMIT between the inserts (not flush): ``added_at`` is + # ``server_default now()`` — the transaction timestamp — and the + # tie-break is the random uuid ``id``, so one-transaction rows order + # nondeterministically. + db.add(GitSource(url=READ_SOURCE, kind="local")) + db.commit() + db.add(GitSource(url=SEED_SOURCE, kind="local")) md = Document( source=SEED_SOURCE, path=SEED_PATH, @@ -408,7 +430,7 @@ def _reset_db(seed: Callable[[Session], None] | None = None) -> None: db.execute( text( "TRUNCATE chunks, documents, query_log, " - "steering_notes, kb_overview, saved_chats" + "steering_notes, kb_overview, saved_chats, git_sources" ) ) db.commit() @@ -514,7 +536,7 @@ def _wait_settled(page: Page) -> None: #: The FIRST tool line's suffix, pinned to the line ("first .tool-call #: child of the .tool-calls container" — the "Listing documents" line; -#: the read frame's second line arms its OWN clock 5 s later and is +#: the drill frame's second line arms its OWN clock 5 s later and is #: never the target here). FIRST_LINE_SUFFIX = "#messages .tool-calls .tool-call:first-child .tool-elapsed" @@ -611,8 +633,8 @@ def test_typing_indicator_shows_visible_elapsed( ) # The clock ticks while the gap holds: ≥1.5 s later the value is - # strictly greater (the read frame is ≥6 s away; the first delta - # — which removes the whole indicator — is ≈25 s from submit). + # strictly greater (the drill frame is ≥6 s away; the first delta + # — which removes the whole indicator — is ≈31 s from submit). page.wait_for_timeout(int(SAMPLE_GAP_S * 1000)) v2 = _elapsed_value( page.locator("#typing-indicator .typing-elapsed").first.text_content(), @@ -644,7 +666,7 @@ def test_indicators_settle_when_the_answer_arrives( # First the ticking state (the test-1 wait, reused)… expect(page.locator(FIRST_LINE_SUFFIX)).to_be_visible(timeout=SUFFIX_TIMEOUT_MS) - # …then the answer arrives (≈25 s from submit — see the module + # …then the answer arrives (≈31 s from submit — see the module # docstring's timeline) and settles. _wait_settled(page) @@ -661,13 +683,16 @@ def test_indicators_settle_when_the_answer_arrives( expect(page.locator("#messages .tool-elapsed")).to_have_count(0) expect(page.locator("#typing-indicator")).to_have_count(0) - # The tool lines remain — the permanent record, both present with - # their pinned text (phase 37's pattern). + # The tool lines remain — the permanent record, all three present + # with their pinned text (phase 37's pattern + the phase-94 drill + # line). lines = page.locator("#messages .tool-call") - expect(lines).to_have_count(2) + expect(lines).to_have_count(3) expect(lines.nth(0)).to_contain_text("Listing documents") - expect(lines.nth(1)).to_contain_text("Reading ") - expect(lines.nth(1)).to_contain_text(READ_SP) + expect(lines.nth(1)).to_contain_text("Listing documents in") + expect(lines.nth(1)).to_contain_text(READ_SOURCE) + expect(lines.nth(2)).to_contain_text("Reading ") + expect(lines.nth(2)).to_contain_text(READ_SP) # The answer bubble is complete (the mock's deterministic quote). bubble = page.locator(".msg.brain .bubble").last @@ -705,12 +730,14 @@ def test_restored_turn_has_no_timer( expect(page.locator("#empty-state")).to_be_hidden(timeout=RESTORE_TIMEOUT_MS) # The persisted record re-renders the tool lines, in saved order - # (the phase-37 reload pin, mirrored)… + # (the phase-37 reload pin, mirrored + the phase-94 drill line)…” restored = page.locator("#messages .tool-call") - expect(restored).to_have_count(2, timeout=RESTORE_TIMEOUT_MS) + expect(restored).to_have_count(3, timeout=RESTORE_TIMEOUT_MS) expect(restored.nth(0)).to_contain_text("Listing documents") - expect(restored.nth(1)).to_contain_text("Reading ") - expect(restored.nth(1)).to_contain_text(READ_SP) + expect(restored.nth(1)).to_contain_text("Listing documents in") + expect(restored.nth(1)).to_contain_text(READ_SOURCE) + expect(restored.nth(2)).to_contain_text("Reading ") + expect(restored.nth(2)).to_contain_text(READ_SP) # …with NO timer (A6 — the restore never arms the clock) and the # page otherwise settled. diff --git a/tests/e2e/test_harness_aligned_tools.py b/tests/e2e/test_harness_aligned_tools.py index ccf29df..60c7449 100644 --- a/tests/e2e/test_harness_aligned_tools.py +++ b/tests/e2e/test_harness_aligned_tools.py @@ -13,9 +13,13 @@ deterministic marker flows in ``tests/e2e/mock_llm.py`` (phase 70: the flows emit the NEW names with the NEW argument shapes): * the READ flow (``use your tools`` (``TOOLS_TRIGGER``) + the HIGH - prompt's ```` section): ``ls`` (id ``call_0``, no arguments) → - ``read`` on the JOINED combined ``source/path`` of the first catalog - line (id ``call_1``) → the ``Read . `` answer; + prompt's ```` section; phase 94: the drill-down ``ls`` — the + top level lists sources only, so the flow drills one level before the + first file line exists): ``ls`` (id ``call_0``, no arguments) → the + drill ``ls`` scoped to the first source of the listing + (id ``call_1``) → ``read`` on the JOINED combined ``source/path`` of + the first file line (id ``call_2``) → the ``Read . + `` answer; * the SEARCH flow (``search your documents`` (``SEARCH_TRIGGER``) + the ```` section): ``grep`` with ``{"pattern": SEARCH_PATTERN}`` (id ``call_0``) → the ``Found `` answer. @@ -41,13 +45,14 @@ KB fixtures: Test → phase mapping (Playwright Mapping Rule): 1. ``test_read_flow_lines_answer_sources_no_raw_markup`` — the - grounded READ turn: the UI shows the ``ls`` line (unscoped "🔎 - Listing documents", no argument) then the "📄 Reading " - line with the combined path in a ```` element, the answer - streams and quotes the read document, the done-state sources - include the read document, and NO raw tool markup (``<|…|>``, - ``tool_call``) appears anywhere in the DOM — the live incident this - phase fixes. + grounded READ turn: the UI shows the unscoped ``ls`` line ("🔎 + Listing documents", no argument), the drill line ("🔎 Listing + documents in " — phase 94, the source in a ```` + element), then the "📄 Reading " line with the + combined path in a ```` element, the answer streams and quotes + the read document, the done-state sources include the read document, + and NO raw tool markup (``<|…|>``, ``tool_call``) appears anywhere + in the DOM — the live incident this phase fixes. 2. ``test_grep_flow_line_then_answer`` — the grounded SEARCH turn: the "🔎 Searching for " line (sentinel in ````) then the matched-line answer. @@ -74,7 +79,7 @@ from sqlalchemy.orm import Session from app.config import Settings from app.db import SessionLocal -from app.models import Chunk, Document +from app.models import Chunk, Document, GitSource from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient from e2e.auth_helpers import login @@ -156,7 +161,22 @@ READ_ANSWER_QUOTE = RECORD_FILE_CONTENT[:80] def _seed_read_pair(db: Session) -> None: - """The two-document READ-flow KB (see the module docstring).""" + """The two-document READ-flow KB (see the module docstring). + + Phase 94: the drill-down ``ls`` top level reads the registry — + register BOTH sources (TRUNCATEd in ``_reset_db_read_pair``), + ``Deployments`` FIRST (registry order ``(added_at, id)``): the + mock's drill (first source of the listing) lands on the JSON file + — the read the assertions expect. A non-empty table also ignores + the operator's ``BOR_GIT_SOURCES`` fallback — deterministic. + """ + # COMMIT between the inserts (not flush): ``added_at`` is + # ``server_default now()`` — the transaction timestamp — and the + # tie-break is the random uuid ``id``, so one-transaction rows order + # nondeterministically. + db.add(GitSource(url=READ_SOURCE, kind="local")) + db.commit() + db.add(GitSource(url=SEED_SOURCE, kind="local")) md = Document( source=SEED_SOURCE, path=SEED_PATH, @@ -267,7 +287,10 @@ def _reset_db_read_pair() -> None: answers.""" with SessionLocal() as db: db.execute( - text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview") + text( + "TRUNCATE chunks, documents, query_log, steering_notes, " + "kb_overview, git_sources" + ) ) db.commit() _seed_read_pair(db) @@ -386,15 +409,18 @@ def test_read_flow_lines_answer_sources_no_raw_markup( _submit(page, READ_QUESTION) _wait_settled(page) - # The UI shows the ls line (UNSCOPED — no argument, no ) then + # The UI shows the ls line (UNSCOPED — no argument, no ), + # the drill line (phase 94 — the source in a element), then # the "📄 Reading " line with the COMBINED path in a # element (the path is data, never markup). lines = page.locator(".msg.brain .tool-call") - expect(lines).to_have_count(2) + expect(lines).to_have_count(3) expect(lines.nth(0)).to_contain_text("Listing documents") expect(lines.nth(0).locator("code")).to_have_count(0) - expect(lines.nth(1)).to_contain_text("Reading ") - expect(lines.nth(1).locator("code")).to_have_text(READ_SP) + expect(lines.nth(1)).to_contain_text("Listing documents in") + expect(lines.nth(1).locator("code")).to_have_text(READ_SOURCE) + expect(lines.nth(2)).to_contain_text("Reading ") + expect(lines.nth(2).locator("code")).to_have_text(READ_SP) # The answer streamed and quotes the read document (the mock's # deterministic echo: "Read . "). @@ -402,12 +428,14 @@ def test_read_flow_lines_answer_sources_no_raw_markup( expect(bubble).to_contain_text(READ_ANSWER_PREFIX) expect(bubble).to_contain_text(READ_ANSWER_QUOTE) - # Wire level: ls then read — the phase-70 argument rule (ls - # unscoped → null; read → the combined path as passed) — ahead of - # the first delta. + # Wire level: ls, the drill ls scoped to the first source (phase + # 94), then read — the phase-70 argument rule (ls unscoped → null; + # scoped ls → the source name; read → the combined path as passed) + # — ahead of the first delta. frames = _drain_frames(page) assert _tool_frames(frames) == [ {"type": "tool", "name": "ls", "argument": None}, + {"type": "tool", "name": "ls", "argument": READ_SOURCE}, {"type": "tool", "name": "read", "argument": READ_SP}, ] first_delta = next(i for i, f in enumerate(frames) if f.get("type") == "delta") @@ -508,10 +536,12 @@ def test_wire_argument_rule_across_both_flows( read_tools = _tool_frames(read_frames) search_tools = _tool_frames(search_frames) # The ordered, combined tool-frame sequence across both flows: the - # argument rule end-to-end — read → the combined path as passed, - # grep → the pattern, ls → null when unscoped. + # argument rule end-to-end — the drill ls (phase 94) → the source + # name, read → the combined path as passed, grep → the pattern, + # ls → null when unscoped. assert read_tools + search_tools == [ {"type": "tool", "name": "ls", "argument": None}, + {"type": "tool", "name": "ls", "argument": READ_SOURCE}, {"type": "tool", "name": "read", "argument": READ_SP}, {"type": "tool", "name": "grep", "argument": SEARCH_PATTERN}, ] diff --git a/tests/e2e/test_ls_tree_drilldown.py b/tests/e2e/test_ls_tree_drilldown.py new file mode 100644 index 0000000..148a0b1 --- /dev/null +++ b/tests/e2e/test_ls_tree_drilldown.py @@ -0,0 +1,772 @@ +"""Phase 94 task 04 E2E (Playwright, mock-only): the ``ls`` drill-down tree. + +The dedicated story suite for ``94_ls_tree_drilldown`` (owner TODO.md L4): +the LLM drills ``ls()`` → ``ls(source)`` → ``ls(source/folder)`` → +``read(source/file)`` through the real UI, the sync-time folder summaries +(must exist after a changed sync under the deterministic mock's +``FOLDER_SUMMARY_MODE`` branch) show up in the ``ls`` output, and the +50-line cap holds on a wide folder. + +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_ls_tree_drilldown.py -v --no-cov + +MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the real ``turbo`` +does whatever it does with the tools, while this story's gate is the +deterministic SCRIPTED drill-down flow in ``tests/e2e/mock_llm.py`` +(``DRILL_TRIGGER``: user message contains ``drill down the tree`` — +the question carries its own tool call after the colon, +``drill down the tree: ls [target]`` / ``drill down the tree: read +source/path`` — **and** the system prompt carries the ```` +section of the HIGH prompt). The mock echoes the received tool result +into its final answer (the house scripted-turn way of asserting on tool +results — the mock is the only E2E lens on the LLM's context), so every +tree-level assertion below lands on the rendered answer; the DOM +assertions cover the tool lines + the answer. + +KB fixture — a host temp dir tree (``tmp_path_factory``; the app runs on +the same host) with TWO registered local sources (the +``test_local_directory_sources.py`` registration + real-Sync pattern — +registration through the authenticated API, the real in-process +``POST /api/sync`` pipeline; no git anywhere): + +* ``alpha/`` — ``root-note.md`` at the source root, ``one/`` (2 docs), + ``two/`` (2 docs — the read target lives here) and ``wide/`` (51 tiny + files — the 50-line cap subject); +* ``beta/`` — ``gamma/`` (2 docs). + +Every fixture doc carries the words ``drill down the tree`` in its body, +so every scripted question (which contains the trigger phrase) FTS-matches +at least one chunk — the honesty gate is HIGH for all turns regardless +of the mock's cosine distribution, and the ```` section is present +(the flow's precondition). + +The mock's canned ``FOLDER_SUMMARY_MODE`` branch (phase 94 task 01) +stores, per ≥ 2-doc folder, the deterministic one-liner +``Fixture folder summary for [/].`` — 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. + +Test → story mapping (Playwright Mapping Rule): +1. ``test_drill_down_sources_folders_files_and_read`` — the four + scripted turns: ``ls()`` (the ``🔎 Listing documents`` line + the + top-level shape — one ``— N documents`` line per source + the + canned source-root summaries), ``ls(alpha)`` (folder lines with + their summaries + the root file line), ``ls(alpha/two)`` (the exact + ``source: X | path: Y | title: Z`` file lines), and the grounded + ``read(alpha/two/two-a.md)`` (the ``📄 Reading …`` line + the answer + citing the document, the phase-37 assertion pattern). The read + target is DELIBERATELY a top-2 retrieval document for its question + (the question names the file's path, so the file self-matches the + hybrid gate deterministically): the agent's phase-72 dedupe returns + ``ALREADY_IN_CONTEXT`` (a refusal — counts in nothing), and the + mock answers FROM THE ```` PROMPT with the same citation + shape (``Already in context: Read . ``) — the + document text reached the model either way, and the prefix pins + that the dedupe notice itself reached it. +2. ``test_wide_folder_holds_the_fifty_line_cap`` — ``ls(alpha/wide)`` + on the 51-file folder: the mock's echo carries exactly 50 file lines + + the ``…and 1 more documents in this folder — use grep (pattern)…`` + note; the 51st file never reaches the model. +3. ``test_not_a_folder_teaching_and_scripted_recovery`` — the mock calls + ``ls(alpha/nope)``; the NOT-A-FOLDER teaching line (the argument + echoed, the parent's subfolders listed) is visible to the model — + the mock's scripted recovery branch keys on receiving it — and the + scripted ``ls(alpha)`` recovery lands (the answer is the parent's + listing; the loop ends in one refusal + one correction, not at the + round cap). +""" +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import time +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import httpx +import pytest +from playwright.sync_api import Locator, Page, expect +from sqlalchemy import select, text + +from app.config import Settings as _Settings +from app.db import SessionLocal +from app.models import FolderSummary, QueryLog +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 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_LSTREE", "8136")) +APP_URL = f"http://127.0.0.1:{APP_PORT}" + +# -------------------------------------------------------------------------- +# Fixture documents (deterministic, token-controlled) +# -------------------------------------------------------------------------- + +ALPHA = "alpha" +BETA = "beta" + +ROOT_NOTE = "root-note.md" +TWO_A = "two/two-a.md" +TWO_B = "two/two-b.md" +READ_SP = f"{ALPHA}/{TWO_A}" + +ALPHA_COUNT = 56 # 1 root note + 2 one/ + 2 two/ + 51 wide/ +BETA_COUNT = 2 +TOTAL_DOCS = ALPHA_COUNT + BETA_COUNT +WIDE_COUNT = 51 +LS_MAX_FILE_LINES = 50 # app.rag.agent.LS_MAX_FILE_LINES — the cap under test + +#: Every fixture body carries ``drill down the tree`` (the trigger +#: phrase's words): every scripted question FTS-matches at least one +#: chunk → HIGH gate → the ```` section the flow keys on. +DRILL_LEAD = "The drill down the tree fixture note" + + +def _md(title: str, body: str) -> str: + return f"# {title}\n\n{body}\n" + + +#: The read target — its FIRST line is ≥ 80 chars, so the mock's +#: first-80-chars quote (the phase-37 single-read shape) is newline-free +#: and the rendered-text assertion matches it verbatim. Pinned by the +#: assert below. +TWO_A_TITLE = ( + "Alpha Two A — the drill-down read target for the alpha two folder " + "listing turn in the brain of reese fixture" +) +TWO_A_CONTENT = _md( + TWO_A_TITLE, + f"{DRILL_LEAD} for alpha two: this document covers topic A of the " + "alpha source tree; it is the file the scripted read turn opens " + "from the alpha/two listing.", +) +assert "\n" not in TWO_A_CONTENT[:80] # the quote must stay one line + +#: The sync-time folder summaries the mock's canned ``FOLDER_SUMMARY_MODE`` +#: branch stores (task 01's byte-stable template), in +#: ``(source, folder_path)`` order: one row per ≥ 2-doc folder (the +#: recursive-subtree rule) — 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."), +] +assert [ + (source, folder) for source, folder, _s in EXPECTED_SUMMARIES +] == sorted((source, folder) for source, folder, _s in EXPECTED_SUMMARIES) + +# --- the pinned tree levels (app.rag.agent's phase-94 templates) ------- + +#: ``ls()`` — the top level: sources in registry order (alpha registered +#: first), each with its recursive count + stored source-root summary. +TOP_HEADER = "2 sources:" +TOP_LINES = [ + f"{ALPHA} — {ALPHA_COUNT} documents", + EXPECTED_SUMMARIES[0][2], + f"{BETA} — {BETA_COUNT} documents", + EXPECTED_SUMMARIES[4][2], +] + +#: ``ls(alpha)`` — the source root: the subfolder lines (path order) +#: with their stored summaries, then the root's own file line (the +#: canonical ``read``/``grep`` identity format, unchanged). +SOURCE_HEADER = f"{ALPHA} — 1 documents, 3 folders:" +SOURCE_LINES = [ + f"one/ — 2 documents: {EXPECTED_SUMMARIES[1][2]}", + f"two/ — 2 documents: {EXPECTED_SUMMARIES[2][2]}", + f"wide/ — {WIDE_COUNT} documents: {EXPECTED_SUMMARIES[3][2]}", + f"source: {ALPHA} | path: {ROOT_NOTE} | title: Alpha Root Note", +] + +#: ``ls(alpha/two)`` — a leaf folder: the file lines in EXACTLY the +#: existing ``source: X | path: Y | title: Z`` format, path order. +FOLDER_HEADER = f"{ALPHA}/two — 2 documents, 0 folders:" +FOLDER_LINES = [ + f"source: {ALPHA} | path: {TWO_A} | title: {TWO_A_TITLE}", + f"source: {ALPHA} | path: {TWO_B} | title: Alpha Two B", +] + +#: ``ls(alpha/wide)`` — the 50-line cap: 51 files → 50 lines (path +#: order: wide-01 … wide-50) + one deterministic grep-pointer note; +#: wide-51 never reaches the model. +WIDE_HEADER = f"{ALPHA}/wide — {WIDE_COUNT} documents, 0 folders:" +WIDE_FIRST = f"source: {ALPHA} | path: wide/wide-01.md | title: Wide 01" +WIDE_LAST = f"source: {ALPHA} | path: wide/wide-50.md | title: Wide 50" +WIDE_NOTE = ( + "…and 1 more documents in this folder — use grep " + "(pattern) to find a specific one." +) + +# --- the scripted turns (the mock's ``DRILL_TRIGGER`` questions) ------- + +TOP_QUESTION = "Drill down the tree: ls — what sources are indexed?" +SOURCE_QUESTION = f"Drill down the tree: ls {ALPHA} — what's in source {ALPHA}?" +FOLDER_QUESTION = f"Drill down the tree: ls {ALPHA}/two — list that folder" +READ_QUESTION = f"Drill down the tree: read {READ_SP} — read the file" +WIDE_QUESTION = f"Drill down the tree: ls {ALPHA}/wide — how many files does this folder hold?" +NOPE_QUESTION = f"Drill down the tree: ls {ALPHA}/nope — is there such a folder?" + +#: The read target is a top-2 retrieval document for its question (the +#: question names the path — the file self-matches the hybrid gate +#: deterministically), so the read gets the phase-72 ALREADY_IN_CONTEXT +#: dedupe and the mock answers from the ```` prompt with the +#: same citation shape, prefixed (the suite pins the dedupe path). +READ_ANSWER_PREFIX = f"Already in context: Read {READ_SP}." +READ_ANSWER_QUOTE = TWO_A_CONTENT[:80] + +# -------------------------------------------------------------------------- +# Fixtures +# -------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def drill_dirs(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, Path]: + """The two-source temp tree (see the module docstring): the app + server runs on the same host, so the paths are visible to it. The + directory NAMES are the source names (``kind=local`` → the + directory's basename, phase 38).""" + root = tmp_path_factory.mktemp("bor_ls_tree") + alpha = root / ALPHA + beta = root / BETA + (alpha / "one").mkdir(parents=True) + (alpha / "two").mkdir(parents=True) + (alpha / "wide").mkdir(parents=True) + (beta / "gamma").mkdir(parents=True) + + (alpha / ROOT_NOTE).write_text( + _md( + "Alpha Root Note", + f"{DRILL_LEAD} at the alpha source root: this file sits " + "directly under the alpha source, not in any folder.", + ), + encoding="utf-8", + ) + (alpha / "one" / "one-a.md").write_text( + _md( + "Alpha One A", + f"{DRILL_LEAD} for alpha one: this document covers topic A " + "of the alpha source tree.", + ), + encoding="utf-8", + ) + (alpha / "one" / "one-b.md").write_text( + _md( + "Alpha One B", + f"{DRILL_LEAD} for alpha one: this document covers topic B " + "of the alpha source tree.", + ), + encoding="utf-8", + ) + (alpha / TWO_A).write_text(TWO_A_CONTENT, encoding="utf-8") + (alpha / TWO_B).write_text( + _md( + "Alpha Two B", + f"{DRILL_LEAD} for alpha two: this document covers topic B " + "of the alpha source tree.", + ), + encoding="utf-8", + ) + for nn in range(1, WIDE_COUNT + 1): + (alpha / "wide" / f"wide-{nn:02d}.md").write_text( + _md( + f"Wide {nn:02d}", + f"One of {WIDE_COUNT} tiny files in the alpha wide " + f"folder: {DRILL_LEAD.lower()} line {nn:02d}.", + ), + encoding="utf-8", + ) + (beta / "gamma" / "gamma-a.md").write_text( + _md( + "Beta Gamma A", + f"{DRILL_LEAD} for beta gamma: this document covers topic A " + "of the beta source tree.", + ), + encoding="utf-8", + ) + (beta / "gamma" / "gamma-b.md").write_text( + _md( + "Beta Gamma B", + f"{DRILL_LEAD} for beta gamma: this document covers topic B " + "of the beta source tree.", + ), + encoding="utf-8", + ) + assert (alpha / TWO_A).is_file() and (beta / "gamma" / "gamma-b.md").is_file() + return alpha, beta + + +@pytest.fixture(scope="module") +def app_server(mock_llm: int, drill_dirs: tuple[Path, Path]) -> Iterator[str]: + """The real app under test — per-module app (the conftest pattern, + cf. ``test_local_directory_sources.py``): NO ``BOR_GIT_SOURCES`` + (the env fallback is git-only — the sources here are DB-registered + local directories), the mock LLM, the mock-calibrated threshold, + and the leak-guarded code defaults. The session app is never + started in this isolated run, so no port clash.""" + 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 f"http://127.0.0.1:{mock_llm}/v1" + ) + # Mock-calibrated threshold (conftest pattern): every scripted + # question FTS-matches the fixture docs (the ``drill down the tree`` + # words), so the gate is HIGH either way. + 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 two + # local directories this suite registers (a leftover env git list + # would pollute the top-level ``ls`` the whole story asserts on). + 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 + 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 would pollute the + top-level ``ls`` and a leftover document would show up in the + folder listings the drill answers assert on byte-exactly.""" + with SessionLocal() as db: + db.execute( + text( + "TRUNCATE chunks, documents, query_log, steering_notes, " + "kb_overview, git_sources, folder_summaries" + ) + ) + db.commit() + + +def _wait_sync_done_http(client: httpx.Client, timeout_s: float = 180.0) -> dict[str, Any]: + """Poll the (cookie-authenticated) status endpoint until the run + reaches a terminal state (the test_local_directory_sources pattern, + over plain httpx — this fixture has no browser page yet).""" + 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() + if body["state"] in ("success", "failed"): + return body + time.sleep(0.5) + raise AssertionError(f"sync did not reach a terminal state: {body}") + + +@pytest.fixture(scope="module") +def synced_kb(app_server: str, drill_dirs: tuple[Path, Path]) -> None: + """The story's precondition: the folder-structured KB synced under + the deterministic mock. + + Registers the two temp directories through the authenticated API + (the ``test_local_directory_sources.py`` pattern — ``alpha`` FIRST, + committed separately, so the registry order — ``(added_at, id)`` — + lists alpha before beta, the top-level ``ls`` order the suite + asserts), runs the REAL in-process sync (``POST /api/sync`` — + walk → chunk → embed → overview → folder summaries → version bump), + and pins the stored folder summaries: the mock's canned + ``FOLDER_SUMMARY_MODE`` branch (task 01) makes the sync store one + deterministic row per ≥ 2-doc folder — the drill turns' answers + assert on that exact text. + """ + alpha, beta = drill_dirs + _truncate_all() + with httpx.Client(base_url=app_server, 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": str(alpha)} + ) + assert r.status_code == 201, r.text + time.sleep(0.05) # distinct added_at: alpha before beta (registry order) + r = client.post( + "/api/git-sources", json={"kind": "local", "path": str(beta)} + ) + assert r.status_code == 201, r.text + r = client.post("/api/sync") + assert r.status_code == 202, r.text + body = _wait_sync_done_http(client) + assert body["state"] == "success", body + detail = body["detail"] + assert detail["added"] == TOTAL_DOCS, detail + assert detail["pruned"] == 0, detail + assert detail["overview"] is True, detail + # The change-gated folder summaries (phase 94 task 02) landed: one + # row per ≥ 2-doc folder, the mock's byte-stable text (the drill + # answers quote exactly these lines). + with SessionLocal() as db: + rows = db.execute( + select( + FolderSummary.source, FolderSummary.folder_path, + FolderSummary.summary, + ).order_by(FolderSummary.source, FolderSummary.folder_path) + ).all() + assert [(s, f, t) for s, f, t in rows] == EXPECTED_SUMMARIES, rows + + +@pytest.fixture(autouse=True) +def _clean(db_ready: None) -> Iterator[None]: + """Per-test query_log isolation (the KB itself is module-scoped — + the drill turns never change it, so the folder summaries and the + registry persist across the tests of this module).""" + with SessionLocal() as db: + db.execute(text("TRUNCATE query_log")) + db.commit() + yield + with SessionLocal() as db: + db.execute(text("TRUNCATE query_log")) + db.commit() + + +# -------------------------------------------------------------------------- +# Page helpers (the test_agent_document_tools house pattern) +# -------------------------------------------------------------------------- + +#: Captures the raw SSE ``data:`` payloads of the /api/chat stream +#: (a response clone read in the background) — wire-level assertions +#: for the ``tool`` frames, independent of the UI rendering. +SSE_HOOK = """ +() => { + if (window.__sseInstalled) return; + window.__sseInstalled = true; + window.__sseFrames = []; + const origFetch = window.fetch; + window.fetch = async function (...args) { + const res = await origFetch.apply(this, args); + try { + const url = typeof args[0] === 'string' ? args[0] : args[0].url; + if (url.includes('/api/chat')) { + res.clone().text().then((bodyText) => { + for (const block of bodyText.split('\\n\\n')) { + const line = block.trim(); + if (line.startsWith('data: ')) { + window.__sseFrames.push(line.slice(6)); + } + } + }); + } + } catch (e) { /* non-clonable responses: ignored */ } + return res; + }; +} +""" + + +def _install_page_hooks(page: Page) -> None: + page.evaluate(SSE_HOOK) + + +def _frames(page: Page) -> list[dict]: + """The SSE frames captured since the last submit (``_submit`` + clears the buffer), once the hook's background read settles.""" + deadline = time.monotonic() + 30.0 + while True: + raw = page.evaluate("() => window.__sseFrames || []") + parsed = [json.loads(line) for line in raw if line] + if any(f.get("type") == "done" for f in parsed): + return parsed + if time.monotonic() > deadline: + raise AssertionError( + f"SSE hook captured no `done` frame (frames so far: " + f"{len(parsed)}) — hook install failed?" + ) + time.sleep(0.05) + + +def _tool_frames(frames: list[dict]) -> list[dict]: + return [f for f in frames if f.get("type") == "tool"] + + +def _submit(page: Page, question: str) -> None: + page.evaluate("window.__sseFrames = []") + page.fill("#message-input", question) + page.click("#send-btn") + # The user bubble lands synchronously with the submit handler. + expect(page.locator(".msg.user .bubble").last).to_contain_text(question) + + +def _wait_settled(page: Page) -> None: + """The turn is complete: answer text in the bubble, button recovered + (the phase-48 settle wait, the test_agent_document_tools helper).""" + expect(page.locator(".msg.brain .bubble").last).not_to_have_text("", timeout=30_000) + expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000) + expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000) + + +def _last_brain(page: Page) -> Locator: + return page.locator(".msg.brain").last + + +def _assert_turn( + page: Page, + expected_tools: list[dict[str, Any]], + expected_answer_lines: list[str], +) -> None: + """One scripted drill turn, fully asserted: the wire carries exactly + the expected ``tool`` frames (ahead of the first ``delta``), the + bubble carries the expected answer lines (the mock's echo of the + tool results the model received), and the turn was grounded (the + ``done`` frame is not deflected).""" + frames = _frames(page) + assert _tool_frames(frames) == expected_tools, _tool_frames(frames) + if expected_tools: + first_delta = next( + i for i, f in enumerate(frames) if f.get("type") == "delta" + ) + assert all( + i < first_delta + for i, f in enumerate(frames) + if f.get("type") == "tool" + ) + done = next(f for f in frames if f.get("type") == "done") + assert done["deflected"] is False, done + bubble = _last_brain(page).locator(".bubble") + for line in expected_answer_lines: + expect(bubble).to_contain_text(line) + + +def _query_log_rows() -> list[QueryLog]: + with SessionLocal() as db: + return list(db.scalars(select(QueryLog)).all()) + + +# -------------------------------------------------------------------------- +# 1. The scripted drill: sources → folders (summaries) → files → read +# -------------------------------------------------------------------------- + + +def test_drill_down_sources_folders_files_and_read( + page: Page, app_url: str, synced_kb: None, db_ready: None +) -> None: + page.set_default_timeout(30_000) + login(page, app_url, next="/") + _install_page_hooks(page) + + # --- turn 1: ls() — the top level (sources + source-root summaries) - + _submit(page, TOP_QUESTION) + _wait_settled(page) + lines = _last_brain(page).locator(".tool-call") + expect(lines).to_have_count(1) + # The no-arg ls line — NOT a scoped "Listing documents in …" one + # (regex match: string expectations normalize whitespace, so the + # scope check must be a byte-exact pattern). + expect(lines.nth(0)).to_contain_text("Listing documents") + expect(lines.nth(0)).not_to_have_text(re.compile(r"Listing documents in")) + _assert_turn( + page, + [{"type": "tool", "name": "ls", "argument": None}], + [TOP_HEADER, *TOP_LINES], + ) + + # --- turn 2: ls(alpha) — the source root (subfolders + root files) -- + _submit(page, SOURCE_QUESTION) + _wait_settled(page) + lines = _last_brain(page).locator(".tool-call") + expect(lines).to_have_count(1) + expect(lines.nth(0)).to_contain_text(f"Listing documents in {ALPHA}") + _assert_turn( + page, + [{"type": "tool", "name": "ls", "argument": ALPHA}], + [SOURCE_HEADER, *SOURCE_LINES], + ) + + # --- turn 3: ls(alpha/two) — the folder level (the file lines) ------ + _submit(page, FOLDER_QUESTION) + _wait_settled(page) + lines = _last_brain(page).locator(".tool-call") + expect(lines).to_have_count(1) + expect(lines.nth(0)).to_contain_text(f"Listing documents in {ALPHA}/two") + _assert_turn( + page, + [{"type": "tool", "name": "ls", "argument": f"{ALPHA}/two"}], + [FOLDER_HEADER, *FOLDER_LINES], + ) + + # --- turn 4: read(alpha/two/two-a.md) — the grounded read ------------ + _submit(page, READ_QUESTION) + _wait_settled(page) + lines = _last_brain(page).locator(".tool-call") + expect(lines).to_have_count(1) + # The phase-37 "Reading " line (the combined identity). + expect(lines.nth(0)).to_contain_text(f"Reading {READ_SP}") + frames = _frames(page) + assert _tool_frames(frames) == [ + {"type": "tool", "name": "read", "argument": READ_SP} + ], _tool_frames(frames) + bubble = _last_brain(page).locator(".bubble") + # The dedupe notice reached the model (the prefix pins it — the read + # was refused as ALREADY_IN_CONTEXT because the target is a top-2 + # retrieval document)… + expect(bubble).to_contain_text(READ_ANSWER_PREFIX) + # …and the answer still cites the document: the mock quotes the + # FIRST 80 chars of the target's text from the ```` + # prompt (the refusal's instruction — answer from that text; the + # quote is newline-free, pinned above). + expect(bubble).to_contain_text(READ_ANSWER_QUOTE) + done = next(f for f in frames if f.get("type") == "done") + assert done["deflected"] is False, done + # The read document is in the turn's sources (retrieval + agent-read, + # deduped — the grounded-turn record). + assert any( + s["path"] == TWO_A and s["source"] == ALPHA for s in done["sources"] + ), done["sources"] + + # Durable records: all four turns grounded, in order, the read turn + # logging the read document. + rows = _query_log_rows() + assert [r.question for r in rows] == [ + TOP_QUESTION, SOURCE_QUESTION, FOLDER_QUESTION, READ_QUESTION + ] + assert all(r.deflected is False for r in rows) + assert READ_SP in rows[3].sources, rows[3].sources + + +# -------------------------------------------------------------------------- +# 2. The 50-line cap: a 51-file folder costs the model 50 lines + the note +# -------------------------------------------------------------------------- + + +def test_wide_folder_holds_the_fifty_line_cap( + page: Page, app_url: str, synced_kb: None, db_ready: None +) -> None: + page.set_default_timeout(30_000) + login(page, app_url, next="/") + _install_page_hooks(page) + + _submit(page, WIDE_QUESTION) + _wait_settled(page) + + lines = _last_brain(page).locator(".tool-call") + expect(lines).to_have_count(1) + expect(lines.nth(0)).to_contain_text(f"Listing documents in {ALPHA}/wide") + + frames = _frames(page) + assert _tool_frames(frames) == [ + {"type": "tool", "name": "ls", "argument": f"{ALPHA}/wide"} + ], _tool_frames(frames) + done = next(f for f in frames if f.get("type") == "done") + assert done["deflected"] is False, done + + # The mock echoed the listing VERBATIM: the header carries the + # PRE-cap count (51 — the cap hides lines, not the truth), the + # file lines stop at 50, and the one deterministic grep-pointer note + # folds the 51st file away. + bubble = _last_brain(page).locator(".bubble") + text = bubble.text_content() or "" + assert WIDE_HEADER in text, text + assert WIDE_FIRST in text, text + assert WIDE_LAST in text, text + assert WIDE_NOTE in text, text + assert f"wide/wide-{WIDE_COUNT}.md" not in text, text # the 51st file: gone + # Exactly LS_MAX_FILE_LINES file lines reached the model. + assert text.count(f"source: {ALPHA} | path: wide/") == LS_MAX_FILE_LINES, text + + row = _query_log_rows() + assert len(row) == 1 + assert row[0].deflected is False + + +# -------------------------------------------------------------------------- +# 3. The NOT-A-FOLDER teaching is visible to the model; the scripted +# recovery (ls of the parent level) works +# -------------------------------------------------------------------------- + + +def test_not_a_folder_teaching_and_scripted_recovery( + page: Page, app_url: str, synced_kb: None, db_ready: None +) -> None: + page.set_default_timeout(30_000) + login(page, app_url, next="/") + _install_page_hooks(page) + + _submit(page, NOPE_QUESTION) + _wait_settled(page) + + # Two tool lines: the scripted misuse, then the scripted recovery — + # the mock's recovery branch fires ONLY when it RECEIVES the + # NOT-A-FOLDER teaching line (``'alpha/nope' is not a folder — + # alpha has: one/ two/ wide/`` — the argument echoed, the parent's + # subfolders listed): the teaching being visible to the model is + # exactly what the second call proves (the phase-72 + # self-correction contract, now carrying the tree's teaching). + lines = _last_brain(page).locator(".tool-call") + expect(lines).to_have_count(2) + expect(lines.nth(0)).to_contain_text(f"Listing documents in {ALPHA}/nope") + expect(lines.nth(1)).to_contain_text(f"Listing documents in {ALPHA}") + + frames = _frames(page) + assert _tool_frames(frames) == [ + {"type": "tool", "name": "ls", "argument": f"{ALPHA}/nope"}, + {"type": "tool", "name": "ls", "argument": ALPHA}, + ], _tool_frames(frames) + done = next(f for f in frames if f.get("type") == "done") + assert done["deflected"] is False, done + # The loop ended in ONE refusal + ONE correction: the final answer is + # the recovery's PARENT listing (not the round-cap, not an echo of + # the refusal) — the model self-corrected and got the tree level. + bubble = _last_brain(page).locator(".bubble") + for line in [SOURCE_HEADER, *SOURCE_LINES]: + expect(bubble).to_contain_text(line) + + row = _query_log_rows() + assert len(row) == 1 + assert row[0].deflected is False diff --git a/tests/e2e/test_tool_path_teaching.py b/tests/e2e/test_tool_path_teaching.py index 58dbfa3..4e459a5 100644 --- a/tests/e2e/test_tool_path_teaching.py +++ b/tests/e2e/test_tool_path_teaching.py @@ -13,11 +13,14 @@ 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 LS-TEACH flow in ``tests/e2e/mock_llm.py`` (``LS_TEACH_TRIGGER`` — "list the files in this directory" — + the -HIGH prompt's ```` section): the incident's misuse (``ls`` with -``{"path": "."}``, id ``call_0``) → the agent's teaching refusal -(``No source named '.' — check the ls output. (…)``) → the corrected -no-arg ``ls()`` (id ``call_1``) → the deterministic -``These are the indexed documents: `` answer. +HIGH prompt's ```` section; phase 94: the drill-down ``ls`` — +the corrected no-arg listing carries sources only, so the flow drills +one level before the first file line exists): the incident's misuse +(``ls`` with ``{"path": "."}``, id ``call_0``) → the agent's teaching +refusal (``No source named '.' — check the ls output. (…)``) → the +corrected no-arg ``ls()`` (id ``call_1``) → the drill ``ls`` scoped to +the first source of the listing (id ``call_2``) → the deterministic +``These are the indexed documents: `` answer. KB fixture (TRUNCATE-then-seed, house pattern): ONE source with TWO documents of known ``source``/``path``/``title`` (catalog order = @@ -39,21 +42,24 @@ documents of known ``source``/``path``/``title`` (catalog order = Test → phase mapping (Playwright Mapping Rule): 1. ``test_ls_misuse_self_corrects_to_noarg_listing`` — the grounded LS-TEACH turn: the turn settles (composer re-enables, ``done`` - observed), the answer bubble carries the first catalog line — the + observed), the answer bubble carries the first file line — the first document's ``source:`` / ``path:`` / title fields (the - catalog reached the model and landed in the answer), the UI shows - the two tool lines (``🔎 Listing documents in .`` - then ``🔎 Listing documents``), and no error banner. Wire level: - the ``tool`` frames arrive in order — first ``ls`` with - ``argument: "."``, then ``ls`` with ``argument: null`` — and there - is NO third ``tool`` frame (the loop ended in one correction, not + folder listing reached the model and landed in the answer), the UI + shows the three tool lines (``🔎 Listing documents in + .``, ``🔎 Listing documents``, then the drill + ``🔎 Listing documents in `` — phase 94), and no error + banner. Wire level: the ``tool`` frames arrive in order — first + ``ls`` with ``argument: "."``, then ``ls`` with ``argument: null``, + then the drill ``ls`` scoped to the source — and there is NO fourth + ``tool`` frame (the loop ended in one correction + one drill, not at the round cap). 2. ``test_plain_tool_flow_not_swallowed_by_new_trigger`` — in the SAME session, the LS-TEACH turn settles and a follow-up question carrying ``TOOLS_TRIGGER`` (the single-read flow) still settles - with the read flow's answer (``ls`` → ``read`` on the first - catalog line's combined identity → ``Read . ``) - — the new flow did not swallow the existing trigger. + with the read flow's answer (``ls`` → the drill ``ls`` (phase 94) + → ``read`` on the first file line's combined identity → + ``Read . ``) — the new flow did not swallow + the existing trigger. """ from __future__ import annotations @@ -69,7 +75,7 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.db import SessionLocal -from app.models import Chunk, Document +from app.models import Chunk, Document, GitSource from e2e.auth_helpers import login from tests.e2e.mock_llm import ( LS_TEACH_TRIGGER, @@ -211,7 +217,14 @@ def _seed_fixture(db: Session) -> None: cosines well past the E2E 0.30 threshold and FTS-matches too → grounded). DOC2 is the seed context only — the single-read flow reads the catalog-FIRST document (DOC1), which is not in context. + + Phase 94: the drill-down ``ls`` top level reads the registry — + register the source (TRUNCATEd in ``_reset_db_fixture``): the + corrected no-arg listing names it, and the drill scopes to it. A + non-empty table also ignores the operator's ``BOR_GIT_SOURCES`` + fallback — deterministic. """ + db.add(GitSource(url=SEED_SOURCE, kind="local")) db.add( Document( source=SEED_SOURCE, @@ -255,7 +268,10 @@ def _reset_db_fixture() -> None: prompts, byte-stable answers.""" with SessionLocal() as db: db.execute( - text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview") + text( + "TRUNCATE chunks, documents, query_log, steering_notes, " + "kb_overview, git_sources" + ) ) db.commit() _seed_fixture(db) @@ -382,24 +398,30 @@ def test_ls_misuse_self_corrects_to_noarg_listing( expect(bubble).to_contain_text(FIRST_CATALOG_LINE) _assert_no_error_banner(page) - # The UI shows the two tool lines in order: the scoped misuse - # (🔎 Listing documents in .) then the corrected - # unscoped listing (🔎 Listing documents — no ). + # The UI shows the three tool lines in order: the scoped misuse + # (🔎 Listing documents in .), the corrected + # unscoped listing (🔎 Listing documents — no ), then the + # drill (🔎 Listing documents in — phase 94, the top + # level lists sources only, so the file lines need one more level). lines = page.locator(".msg.brain .tool-call") - expect(lines).to_have_count(2) + expect(lines).to_have_count(3) expect(lines.nth(0)).to_contain_text("Listing documents in") expect(lines.nth(0).locator("code")).to_have_text(".") expect(lines.nth(1)).to_contain_text("Listing documents") expect(lines.nth(1).locator("code")).to_have_count(0) + expect(lines.nth(2)).to_contain_text("Listing documents in") + expect(lines.nth(2).locator("code")).to_have_text(SEED_SOURCE) - # Two rounds on the wire: the tool frames arrive in order — first - # ls with argument "." (the incident's misuse), then ls with - # argument null (the correction) — and there is NO third tool - # frame: the loop ended in one correction, not at the round cap. + # Three rounds on the wire: the tool frames arrive in order — + # first ls with argument "." (the incident's misuse), then ls with + # argument null (the correction), then the drill ls scoped to the + # source (phase 94) — and there is NO fourth tool frame: the loop + # ended in one correction + one drill, not at the round cap. frames = _drain_frames(page) assert _tool_frames(frames) == [ {"type": "tool", "name": "ls", "argument": "."}, {"type": "tool", "name": "ls", "argument": None}, + {"type": "tool", "name": "ls", "argument": SEED_SOURCE}, ] first_delta = next(i for i, f in enumerate(frames) if f.get("type") == "delta") assert all( @@ -433,6 +455,7 @@ def test_plain_tool_flow_not_swallowed_by_new_trigger( assert _tool_frames(teach_frames) == [ {"type": "tool", "name": "ls", "argument": "."}, {"type": "tool", "name": "ls", "argument": None}, + {"type": "tool", "name": "ls", "argument": SEED_SOURCE}, ] expect( page.locator(".msg.brain .bubble").last @@ -445,14 +468,17 @@ def test_plain_tool_flow_not_swallowed_by_new_trigger( _wait_settled(page) second_msg = page.locator(".msg.brain").last - # The UI shows the single-read flow's two lines: the unscoped ls - # then the read of the first catalog line's COMBINED identity. + # The UI shows the single-read flow's three lines: the unscoped ls, + # the drill ls (phase 94), then the read of the first file line's + # COMBINED identity. lines = second_msg.locator(".tool-call") - expect(lines).to_have_count(2) + expect(lines).to_have_count(3) expect(lines.nth(0)).to_contain_text("Listing documents") expect(lines.nth(0).locator("code")).to_have_count(0) - expect(lines.nth(1)).to_contain_text("Reading ") - expect(lines.nth(1).locator("code")).to_have_text(DOC1_SP) + expect(lines.nth(1)).to_contain_text("Listing documents in") + expect(lines.nth(1).locator("code")).to_have_text(SEED_SOURCE) + expect(lines.nth(2)).to_contain_text("Reading ") + expect(lines.nth(2).locator("code")).to_have_text(DOC1_SP) # The answer quotes the read document (the mock's deterministic # echo: "Read . "). @@ -461,11 +487,13 @@ def test_plain_tool_flow_not_swallowed_by_new_trigger( expect(bubble).to_contain_text(READ_ANSWER_QUOTE) _assert_no_error_banner(page) - # Wire level for the follow-up: ls (null) → read (the combined - # identity) — the single-read flow, unchanged. + # Wire level for the follow-up: ls (null) → the drill ls (the + # source, phase 94) → read (the combined identity) — the + # single-read flow, grown by the drill step. frames = _drain_frames(page) assert _tool_frames(frames) == [ {"type": "tool", "name": "ls", "argument": None}, + {"type": "tool", "name": "ls", "argument": SEED_SOURCE}, {"type": "tool", "name": "read", "argument": DOC1_SP}, ] done = next(f for f in frames if f.get("type") == "done") diff --git a/tests/fixtures/test_kb.dump.sql b/tests/fixtures/test_kb.dump.sql index bc916cd..538ab77 100644 --- a/tests/fixtures/test_kb.dump.sql +++ b/tests/fixtures/test_kb.dump.sql @@ -1,6 +1,6 @@ -- ============================================================ -- Brain of Reese — controlled tool-calling test KB (fixture dump) --- Generated by scripts/load_test_kb.py on 2026-09-04T12:57:48-04:00 +-- Generated by scripts/load_test_kb.py on 2026-09-11T00:16:45-04:00 -- Data-only snapshot (the schema stays alembic-managed; the -- generated chunks.tsv column is recomputed on restore). -- Sources: deployments, homelab (8 documents, 9 chunks). @@ -8,9 +8,9 @@ -- uv run python -m scripts.restore_test_kb -- psql "$BOR_DATABASE_URL" --single-transaction -f tests/fixtures/test_kb.dump.sql -- ============================================================ -TRUNCATE public.documents, public.chunks, public.git_sources, public.kb_overview, public.sources_meta, public.steering_notes, public.query_log, public.saved_chats, public.doc_drafts; +TRUNCATE public.documents, public.chunks, public.git_sources, public.kb_overview, public.sources_meta, public.steering_notes, public.query_log, public.saved_chats, public.doc_drafts, public.folder_summaries; -INSERT INTO public.documents (id, source, path, full_path, title, content, content_hash, indexed_at, summary) VALUES ('0cee890b-f91e-42d6-94ff-4249a70b19ca', 'deployments', 'ansible/lab-inventory.md', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/deployments/ansible/lab-inventory.md', 'Lab Ansible Inventory', '# Lab Ansible Inventory +INSERT INTO public.documents (id, source, path, full_path, title, content, content_hash, indexed_at, summary) VALUES ('a053e592-9b7d-486e-9da6-81535f6d64a8', 'deployments', 'ansible/lab-inventory.md', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/deployments/ansible/lab-inventory.md', 'Lab Ansible Inventory', '# Lab Ansible Inventory The lab deployment inventory lives in `op-build:~/lab/inventory/hosts.yml` and is managed with ansible-core 2.19.4. @@ -27,8 +27,8 @@ and is managed with ansible-core 2.19.4. - Playbooks run from `op-build` only; the shelf has no internet route. - The `lab-upgrade` play requires the `pve-upgrade` tag on the target. - These files are the single source of truth for shelf addresses. -', '8eb4df05c80ca10ec32f0f5b6c7202f4061b3f4fabfc5f1b518b8a0627ea263b', '2026-09-04 16:57:47.845878+00:00', NULL), -('840dabec-48bb-40e3-9833-cb1a646ed777', 'deployments', 'ci/gitlab-runner.md', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/deployments/ci/gitlab-runner.md', 'GitLab Runner (lab-ci)', '# GitLab Runner (lab-ci) +', '8eb4df05c80ca10ec32f0f5b6c7202f4061b3f4fabfc5f1b518b8a0627ea263b', '2026-09-11 04:16:38.457148+00:00', NULL), +('f90d20cc-a0be-4ab2-89dd-0d57ac6145c4', 'deployments', 'ci/gitlab-runner.md', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/deployments/ci/gitlab-runner.md', 'GitLab Runner (lab-ci)', '# GitLab Runner (lab-ci) CI for the shelf runs on a single GitLab Runner registered against `git.reeseapps.com`. @@ -40,8 +40,8 @@ CI for the shelf runs on a single GitLab Runner registered against `debian:13-slim` for lint. - Concurrency is capped at 2; the `pve-upgrade` job never runs in parallel with itself. -', '5634ed3c87d8e8e2c89f7c56339d74c075f62eb554bf98a818b04fee6f15f754', '2026-09-04 16:57:47.963345+00:00', NULL), -('fd7a1902-7366-49fd-9c33-06bf9fdce547', 'deployments', 'quadlet/mimir-service.md', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/deployments/quadlet/mimir-service.md', 'BOR Vault Sync Unit', '# BOR Vault Sync Unit +', '5634ed3c87d8e8e2c89f7c56339d74c075f62eb554bf98a818b04fee6f15f754', '2026-09-11 04:16:38.568872+00:00', NULL), +('f183f389-a110-4373-8640-4af205d2d026', 'deployments', 'quadlet/mimir-service.md', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/deployments/quadlet/mimir-service.md', 'BOR Vault Sync Unit', '# BOR Vault Sync Unit The Brain-of-Reese vault syncs from `obsidian.container` on `rack7-pbx2`. @@ -56,8 +56,8 @@ The Brain-of-Reese vault syncs from `obsidian.container` on `rack7-pbx2`. - Only `rack7-pbx2` may hold the vault — do not clone it to another node. - Image tags are date-stamped; bump by re-pushing `ghcr.io/reese/obsidian-bor` and restarting the unit. -', '703d8425ba26caf607b240abbebff4be048f828482a6c3297338120e166dc635', '2026-09-04 16:57:48.017861+00:00', NULL), -('f24790a5-dc20-4a28-8f96-37f4e6db1cfb', 'homelab', 'backups/restic-rack7.md', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/homelab/backups/restic-rack7.md', 'Restic Backups for Rack7', '# Restic Backups for Rack7 +', '703d8425ba26caf607b240abbebff4be048f828482a6c3297338120e166dc635', '2026-09-11 04:16:38.623801+00:00', NULL), +('fcf52742-dc89-41f9-aade-380d0cf16cd4', 'homelab', 'backups/restic-rack7.md', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/homelab/backups/restic-rack7.md', 'Restic Backups for Rack7', '# Restic Backups for Rack7 Every config and document on the cluster is backed up nightly with restic into the minio repo `minio01:/backups/rack7`. @@ -68,8 +68,8 @@ restic into the minio repo `minio01:/backups/rack7`. - Retention: `--keep-daily 7 --keep-weekly 4 --keep-monthly 12` - Prune runs only when the repo grows more than 5 GiB. - Restores are tested quarterly; the last dry-run passed on 2026-07-01. -', '7998c1127f41ef3dc8d0786af071d6b91817d791a5b5098b10e56ada239a4e8b', '2026-09-04 16:57:48.073288+00:00', NULL), -('1a49e6a0-dbaf-4210-8266-81aac216fb42', 'homelab', 'containers/qwen38-llamacpp.md', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/homelab/containers/qwen38-llamacpp.md', 'Qwen 3.8 on llama.cpp (pbx-node3)', '# Qwen 3.8 on llama.cpp (pbx-node3) +', '7998c1127f41ef3dc8d0786af071d6b91817d791a5b5098b10e56ada239a4e8b', '2026-09-11 04:16:38.678174+00:00', NULL), +('4b6dcf82-7c3a-46d3-8e17-f27d7b4aebf4', 'homelab', 'containers/qwen38-llamacpp.md', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/homelab/containers/qwen38-llamacpp.md', 'Qwen 3.8 on llama.cpp (pbx-node3)', '# Qwen 3.8 on llama.cpp (pbx-node3) The GPU box (`pbx-node3`) serves Qwen 3.8 through llama.cpp. @@ -84,8 +84,8 @@ Working launch line (verified 2026-08): `/srv/llamacpp/cache`. - First-token target is under 400 ms; sustained throughput about 28 tok/s. - Do not add `-ctk f16` — it doubled VRAM usage with no quality gain. -', 'e145b56dd4945a7ba83fabd52a84d00a1cb7ca41dcb2235aafb303b1056b8016', '2026-09-04 16:57:48.124176+00:00', NULL), -('ba951767-d744-4e62-bd19-2d947090e58e', 'homelab', 'containers/uptime-kuma.md', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/homelab/containers/uptime-kuma.md', 'Uptime Kuma (AIO)', '# Uptime Kuma (AIO) +', 'e145b56dd4945a7ba83fabd52a84d00a1cb7ca41dcb2235aafb303b1056b8016', '2026-09-11 04:16:38.732039+00:00', NULL), +('92d409ac-611d-4dc9-bcdb-c72c2c9a8235', 'homelab', 'containers/uptime-kuma.md', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/homelab/containers/uptime-kuma.md', 'Uptime Kuma (AIO)', '# Uptime Kuma (AIO) Uptime Kuma runs on `rack7-pbx2` as the AIO image, listening on **18443** with TLS terminated by `caddy-rack7`. @@ -96,8 +96,8 @@ Uptime Kuma runs on `rack7-pbx2` as the AIO image, listening on `/opt/kuma/backup/`. - Lab-iot monitors watch every VLAN 130 device; the shelf watchdog restarts the container after 3 missed checks. -', 'a573e8063d9c69cdb3e9823dc718cbe2ef07772deea1e0466c6cfb66611b2335', '2026-09-04 16:57:48.175456+00:00', NULL), -('bfbb483e-6bdb-4003-bc16-2d0d568156bd', 'homelab', 'networking/meridian-notes.md', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/homelab/networking/meridian-notes.md', 'Rack7 DNS and DHCP', '# Rack7 DNS and DHCP +', 'a573e8063d9c69cdb3e9823dc718cbe2ef07772deea1e0466c6cfb66611b2335', '2026-09-11 04:16:38.787448+00:00', NULL), +('f0b1915b-fd38-4811-9b44-3c880c60574e', 'homelab', 'networking/meridian-notes.md', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/homelab/networking/meridian-notes.md', 'Rack7 DNS and DHCP', '# Rack7 DNS and DHCP `minio01` (10.77.42.31) serves DNS and DHCP for the whole lab shelf. @@ -107,8 +107,8 @@ Uptime Kuma runs on `rack7-pbx2` as the AIO image, listening on - Pi-hole runs on 10.77.42.53; the admin UI is on port 18553 behind caddy-rack7 and uses the blocklist `reese-abuse-v3`. - VLAN 42 has no DHCP at all — every device there gets a static address. -', 'a8e53576835de52df06ff5942cda021d5c28ab82a9de211a322beb8c23daa709', '2026-09-04 16:57:48.226163+00:00', NULL), -('47655720-be4c-499c-9761-74aa9ca45385', 'homelab', 'networking/vela-bridges.md', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/homelab/networking/vela-bridges.md', 'Rack7 Proxmox Cluster', '# Rack7 Proxmox Cluster +', 'a8e53576835de52df06ff5942cda021d5c28ab82a9de211a322beb8c23daa709', '2026-09-11 04:16:38.841260+00:00', NULL), +('9c6376c8-8b10-49d4-bbc2-72aa4d04e5e2', 'homelab', 'networking/vela-bridges.md', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/homelab/networking/vela-bridges.md', 'Rack7 Proxmox Cluster', '# Rack7 Proxmox Cluster Three-node Proxmox VE cluster on the rack7 shelf: `pbx-node1`, `pbx-node2` and `pbx-node3`, all running PVE 8.3.4 (build `8.3.4-1-lab1`). @@ -126,9 +126,9 @@ and `pbx-node3`, all running PVE 8.3.4 (build `8.3.4-1-lab1`). lab-iot VLAN. - Node roles: pbx-node1 = hypervisor + gateway, pbx-node2 = AIO containers, pbx-node3 = GPU box. -', '520f7df799d0841b1dbcc636705aa5727d882dbb8c0453a4ccbf8b459df304f8', '2026-09-04 16:57:48.281298+00:00', NULL); +', '520f7df799d0841b1dbcc636705aa5727d882dbb8c0453a4ccbf8b459df304f8', '2026-09-11 04:16:38.895972+00:00', NULL); -INSERT INTO public.chunks (id, document_id, position, content, embedding, is_summary) VALUES ('20a0b278-e32b-4216-949f-a688dd7232e8', '0cee890b-f91e-42d6-94ff-4249a70b19ca', 0, '# Lab Ansible Inventory +INSERT INTO public.chunks (id, document_id, position, content, embedding, is_summary) VALUES ('6eefceee-8abb-4188-b6b5-68b38fadb867', 'a053e592-9b7d-486e-9da6-81535f6d64a8', 0, '# Lab Ansible Inventory The lab deployment inventory lives in `op-build:~/lab/inventory/hosts.yml` and is managed with ansible-core 2.19.4. @@ -144,8 +144,8 @@ and is managed with ansible-core 2.19.4. - Playbooks run from `op-build` only; the shelf has no internet route. - The `lab-upgrade` play requires the `pve-upgrade` tag on the target. -- These files are the single source of truth for shelf addresses.', '[0.0131165,-0.01286068,0.0018187251,-0.025069347,0.026930904,-0.010061519,0.03639165,-0.0024801716,-0.0018757446,-0.0056415047,-0.043978132,-0.0055465554,0.02749457,-0.01588221,-0.0031239141,0.015124069,7.382009e-05,0.006532587,0.017546764,-0.03620404,-0.01957861,0.021899011,-0.01114644,0.024008542,-0.012930608,0.016941814,0.01134548,0.03553374,0.03323714,-0.040675685,-0.023371449,-0.01183408,-0.03358365,-0.0019148266,0.012659925,0.017849788,0.008659159,0.0014803014,-0.026984448,-0.09597992,0.017141668,0.016370578,-0.04151496,-0.012735902,0.025547761,0.0058631687,-0.021245819,0.020999556,-0.0146535,-0.016787102,-0.049144182,0.0018046572,0.004818515,-0.0032424713,0.025226386,0.020392658,0.01968785,-0.051538356,0.030269574,-0.028947547,-0.023559585,-0.008182669,0.018328547,-0.011025023,-0.0111371465,-0.009298838,0.010951343,-0.012942978,-0.04981587,0.0014856033,0.008090676,-0.021374322,0.023719246,0.03198768,-0.0015665763,-0.015229458,-0.03448758,0.014594236,-0.05698102,-0.014050409,0.04142552,-0.0058062198,0.005962302,-0.020460969,0.012542343,-0.0063873543,-0.04456228,-0.004668555,-0.008744117,-0.00531,0.020091264,0.021627929,0.00042647644,-0.07999119,0.01541289,0.0015962933,-0.011626312,-0.00018966512,-0.022663463,-0.017639577,-0.0032521738,0.005813755,0.002983077,-0.013823359,-0.022782136,0.034106754,0.021019833,0.002246202,0.013775662,0.0103806155,-0.0051695616,-0.03904711,-0.020617953,-0.030974977,0.014598646,0.022986306,0.009110181,0.0059745763,0.03091675,0.017885158,-0.008469035,0.02870801,-0.01726025,-0.0051749223,-0.0017321572,0.017581256,-0.011323983,-0.040385064,0.025793534,0.0056867646,0.013468858,0.018149374,0.036006268,0.00028223565,0.013474617,-0.008469555,-0.0008731733,-0.024354368,0.013353442,-0.035059363,0.021096129,-0.049316637,0.038681477,0.0055693034,0.05058907,-0.04688489,-0.0029198604,0.005587252,0.014727143,0.015145581,-0.0011796654,-0.020408705,0.005991874,0.008162653,0.0049857725,-0.008442068,-0.008789493,0.010419884,-0.003699708,0.00068187236,0.007937392,-0.015489746,0.06952851,0.011690855,-0.020956531,-0.019384345,-0.0127140395,-0.013081008,0.012684255,-0.012787022,0.0009325887,0.02177989,-0.013047031,-0.0010223332,-0.015090888,0.024258766,0.01834564,0.021109475,0.0022422075,0.0065516485,0.022992475,-0.026969917,0.031850297,-0.017739773,0.01357889,-0.029354244,0.021714143,-0.034089927,0.0036653916,-0.011513499,-0.013695482,0.0126686515,0.0009216392,0.009412121,-0.032169495,0.017477876,0.006045853,0.008782758,-0.03107135,-0.04462336,0.0131814405,-0.0023575118,-0.029042348,-0.024306199,0.020067867,0.03051005,-0.0060908203,-0.023299135,-0.0016035871,0.011376532,0.028999876,0.0073232003,0.013166376,0.020628486,0.002302301,-0.005367483,0.004913885,-0.011291779,0.01375848,7.4394244e-05,0.031063901,0.0148175685,0.013039951,0.002647959,0.03141401,0.010670585,0.0093488665,-0.0061738733,-0.009797442,-0.028686997,0.003412695,0.017236348,0.022260474,0.0092540225,-0.026708174,-0.06218596,0.0059990524,-0.0014820107,0.00025507188,0.024188561,0.0035349086,0.017452821,0.01835625,0.012374628,0.0026335758,0.0119746765,0.02535802,-0.013420555,-0.03988483,-0.013466745,0.00016415936,-0.01507087,-0.018961946,-0.0246745,0.032628182,0.010423807,0.008183236,0.0017572561,0.005737203,-0.0014550936,0.008316198,0.0090452535,0.0077844854,0.016977962,-0.0012022344,-0.004376255,-0.010913582,-0.027692007,0.013511699,0.00078780745,-0.03394934,0.013872497,-0.015874203,0.030414958,0.0053028674,-0.012132932,0.042498566,-0.026032977,0.017801192,0.014726876,0.034165144,-0.028151013,-0.031138174,-0.020858249,-0.015635792,-0.03237541,0.028584411,-0.017679488,0.030451505,0.011629949,0.013514797,0.004612233,0.009861689,-0.0018757688,0.02215618,0.01661471,0.0033636522,-0.0343125,0.010084312,-0.013842449,-0.009186183,-0.008242853,-0.011063282,0.0050381175,-0.024588477,-0.03500249,0.024365667,-0.0034105931,0.0056030685,-0.0061887987,-0.017193437,0.016555354,-0.020211056,-0.0038000217,-0.002669652,0.022263423,0.019055562,0.014037523,-0.015889019,-0.019400004,-0.015250375,0.011422298,0.024589734,-0.017150318,0.037137877,0.043386366,0.009030979,0.013811393,-0.010015726,0.00017630467,0.062063247,0.08984311,-0.0021623247,0.006418138,0.0070769866,0.010778435,0.011017947,0.03439524,0.037922215,0.03708438,0.033204082,-0.023639949,0.014656817,0.021142203,-0.005920392,0.010694525,0.025984732,0.0046065073,0.057886943,0.0060926527,0.015540811,-0.024981309,0.00033576437,0.0035022316,0.025208408,0.026532006,-0.031548906,-0.027905252,-0.015890941,0.03004793,-0.07028458,0.0054144436,-0.020498654,0.020441564,0.0070006186,0.017677518,0.0288032,-0.00076472457,-0.03619615,0.05617705,0.020549115,-0.01519509,0.0069190897,-0.023318348,0.024461163,-0.010299572,0.0043511586,-0.00057226303,0.007893215,0.0050125355,0.052817654,-0.006322253,0.018882502,-0.021657923,0.0067284703,0.013149733,-0.015150629,-0.030743811,-0.0005092734,0.006335977,0.011268558,-0.019517804,0.025957108,0.000941366,-0.027905758,0.000339371,0.030847032,0.020863445,-0.022446098,-0.0010218013,0.016009472,-0.0042985007,-0.009847958,-0.018530162,-0.0004640672,-0.0027035954,0.012566948,0.59635276,-0.009171548,-0.037606843,-0.013779873,-0.004691728,0.011243977,-0.03144298,0.0133641325,0.0039867465,-0.007636093,0.0069370624,0.0055824723,-0.020940067,-0.002712105,-0.026177289,-0.028106296,0.014336731,-0.00093718903,0.020072507,-0.019253677,-0.059181742,-0.022391887,-0.07360106,0.027982412,-0.021790333,0.008431263,0.025815398,0.048350297,0.017983602,0.037033193,-0.061872233,0.013014431,0.005957642,0.00027277897,-0.014942086,-0.004864335,-0.0031497388,0.015018054,-0.020696027,0.011718267,-0.007008854,-0.007841078,-0.03671604,-0.034091342,0.028270857,0.04130807,-0.053631958,-0.012382015,0.02958962,-0.012777757,0.0022560593,0.016440792,0.059755888,0.012069773,-0.010036424,0.0070035337,-0.008756239,0.007831283,-0.0060547097,-0.0040162476,0.0069832387,-0.0024975527,-0.038695417,-0.019795192,-0.018856833,0.014239451,-0.012453788,-0.023340065,-0.0021102054,-0.0052688587,0.008905307,0.005501118,0.019772707,0.015695196,-0.027787063,-0.01424679,0.006496325,-0.0059887073,-0.028117234,-0.029884756,-0.021109303,-0.0064513106,0.009902882,-0.028903414,0.0047313357,-0.05366732,0.0055113765,-0.0055886656,0.0119221425,0.036369953,-0.0123074,0.03185856,0.0073263496,0.046948254,-0.031405777,-0.025426194,0.010120405,0.03005909,-0.0031823968,-0.0008650769,-0.012015338,0.01172629,0.006966304,-0.0078517925,0.002339648,0.044376176,-0.007015678,0.027852837,0.014237912,0.01713812,-0.03127463,-0.0030446462,-0.032905627,-0.0027269,-0.033659577,0.0319811,-0.022856504,-0.030071594,0.0029330002,-0.00423868,-0.023166299,-0.0041202814,0.035074726,0.030872937,0.015238553,-0.005970995,-0.015170835,-0.061178923,-0.024479464,-0.026980773,0.004577913,-0.006853951,-0.024251157,0.008433742,-0.0044121803,0.0014890775,-0.00257965,0.030345662,-0.038215615,-0.010585281,-0.010026842,-0.015389631,0.012959353,-0.009278415,0.0127769485,-0.004234907,-0.020376243,-0.0075302646,0.03237269,0.0032129334,0.017269187,0.003569198,-0.014391988,-0.018954463,-0.035188325,0.00025434754,0.000939569,-0.043200888,-0.018520815,-0.0027368693,0.008943825,-0.00541213,0.016449772,-0.0013944583,0.022534523,-0.041504536,0.008251646,-0.018995166,0.007647716,0.029666789,0.021601787,-0.035937876,-0.030918177,0.06364845,0.016485205,-0.015964726,0.03000396,-0.016993642,-0.0019130243,-0.061285418,-0.01253577,0.018139657,-0.026724016,-0.03563913,-0.015870398,-0.004549835,-0.011944086,-0.003414527,-0.03239906,0.00038629223,-0.011763248,-0.023547787,0.015392932,0.0296493,-0.030653011,0.015348211,0.00701692,-0.0077916454,-0.0059137493,0.004124048,0.008041351,0.02905869,-0.10374779,-0.06295222,-0.040263094,0.014682933,-0.030838598,-0.0054611247,0.004668311,-0.014015264,-0.010730004,-0.0014100468,0.03193195,-0.0021637154,-0.05062483,0.012391141,-0.03036431,-0.0022266223,0.0027062881,-0.038580798,-0.018354202,0.02190709,-0.031115703,-0.0111874575,-0.014181302,0.0018890346,0.0044531603,0.0033837117,-0.0122280745,-0.0054382933,-0.02962938,0.01322944,0.039573193,-0.024993641,-0.020755073,-0.024019202,-0.00957372,-0.0064056832,-0.016017132,-0.0046407785,-0.030775333,-0.011914737,-0.018981578,0.0053053973,0.0061892024,0.0029660293,0.0003591472,0.012684826,0.008946423,-0.038400162,-0.012959367,0.004144876,-0.0035405816,0.07370259,0.01679415,-0.018476032,0.00875939,0.007371914,-0.033734556,-0.01304836,-0.010558337,-0.013657582,-0.010551398,0.033587027,0.03155703,0.0136844665,0.005659698,-0.012263872,0.015180848,0.021672575,0.009405277,-0.009164207,0.007123921,0.021349486,-0.014721813,0.003946796,0.020994728,0.045647662,0.040279243,-0.054701768,0.0146052595,-0.0020434232,-0.0040447926,-0.01909446,0.009234465,-0.007561519,-0.009864297,0.0011412769,0.047909927,-0.04429991,0.057684667,0.025342135,-0.007899853,0.045845322,0.0034340008,-0.014701811,0.0026646238,0.01241061,-0.0039023224,-0.03161156,0.0111126155,0.0098141795,-0.016404422,0.02437241,-0.03550369,0.026511425,0.025076631,-0.026325518,0.004874741,0.036048334,-0.005205086,-0.015071347,-0.031853907,0.019445237,-0.0042056385,-0.006109089,0.008197366,-0.002533363,0.009060888,-0.0014384133,-0.049183566,-0.014115571,-0.020630077,-0.006084607,0.48384884,-0.026949054,-0.0015469289,0.0007830268,-0.02128331,0.0062062107,-0.0016425354,-0.01038022,-0.0166608,0.010073899,0.02123002,0.0052638557,0.007916107,0.027017174,0.0050063613,0.009687189,-0.0077932584,0.01295948,-0.0048582675,-0.017734041,0.0045086225,0.0024258327,-0.0007371844,-0.019646946,-0.03731544,-0.058102433,0.023769472,-0.010249782,0.010979934,0.005554662,0.05277026,-0.023299856,-0.017914308,-0.0013489639,-0.0060844766,-0.02403478,-0.014391879]', FALSE), -('36528b9a-e1b8-406e-b5ef-3b9e6ee732cd', '840dabec-48bb-40e3-9833-cb1a646ed777', 0, '# GitLab Runner (lab-ci) +- These files are the single source of truth for shelf addresses.', '[0.013243707,-0.0128352465,0.0019547255,-0.025034178,0.02702336,-0.01005102,0.0363859,-0.002513086,-0.0019683652,-0.0056082397,-0.04408114,-0.0054861284,0.027522376,-0.015843553,-0.0032135674,0.01509602,-0.00011724297,0.006546753,0.017614163,-0.03634901,-0.019627508,0.021843048,-0.01116331,0.024140809,-0.01296918,0.01725409,0.011422229,0.03565781,0.033322763,-0.04082196,-0.023349185,-0.01198729,-0.03362099,-0.0019399156,0.0126888985,0.017794173,0.008712221,0.001394899,-0.026989464,-0.09557105,0.017171873,0.016430007,-0.041646946,-0.012717809,0.025636157,0.005930746,-0.02121914,0.021009343,-0.014569487,-0.016641594,-0.04910908,0.0017814087,0.004953145,-0.0031862764,0.025204713,0.020313593,0.01969703,-0.051585037,0.030326033,-0.029000891,-0.02347819,-0.008223794,0.018297756,-0.01098346,-0.011048523,-0.009284077,0.010952927,-0.013212637,-0.050052542,0.0015116878,0.0079887435,-0.021479897,0.023794016,0.032033417,-0.0016191782,-0.015217598,-0.03447513,0.014565385,-0.057121683,-0.014002848,0.041706204,-0.005959589,0.0060224696,-0.0204793,0.012521412,-0.006303612,-0.044611067,-0.00467792,-0.008517854,-0.0053633754,0.020172304,0.021619186,0.0005239858,-0.080017805,0.01529577,0.0015442024,-0.011695493,-5.957887e-05,-0.022696357,-0.017713794,-0.003329005,0.0058646468,0.002935853,-0.013866694,-0.02277299,0.03398878,0.021213993,0.0023002306,0.013768723,0.0103675015,-0.0051184483,-0.03909858,-0.020735694,-0.030986298,0.014522613,0.022964546,0.009084225,0.0061787367,0.030804787,0.017979302,-0.00852432,0.028770719,-0.017288633,-0.0050971103,-0.0018098141,0.017639168,-0.011298532,-0.0403817,0.025823312,0.0056047323,0.013507997,0.018194491,0.03588094,5.5394677e-05,0.013518262,-0.008565419,-0.0010493967,-0.024363104,0.013184747,-0.03515832,0.021115607,-0.049394153,0.03870123,0.005634947,0.050738696,-0.046896733,-0.0029160536,0.0056243804,0.014751591,0.015311016,-0.0012607635,-0.020500273,0.006191942,0.008142317,0.005033765,-0.008505002,-0.00882494,0.010440774,-0.0035533654,0.0006726597,0.007936897,-0.015687428,0.06958979,0.011704834,-0.020871835,-0.01940203,-0.012850117,-0.01290134,0.012459115,-0.01286111,0.0010525442,0.021971611,-0.013106545,-0.0010058926,-0.015072058,0.023954568,0.018268595,0.021084037,0.0021309752,0.006486254,0.023034988,-0.027143888,0.031964358,-0.017557139,0.01367743,-0.029482841,0.021734253,-0.034227744,0.0036470115,-0.011511932,-0.0136344405,0.0126612,0.00082304503,0.009397778,-0.032351434,0.017433247,0.006008721,0.008718234,-0.031024123,-0.044650123,0.0131605435,-0.002495953,-0.029082693,-0.024488,0.020053474,0.030387333,-0.0061772666,-0.023270534,-0.0017449781,0.011454752,0.029030161,0.0072354465,0.013117538,0.020577988,0.002245291,-0.005234876,0.004875423,-0.011284984,0.013831045,6.666326e-05,0.031060737,0.014888739,0.012995825,0.002694933,0.031477913,0.010705367,0.009346292,-0.0061707064,-0.00992261,-0.028578456,0.0033538414,0.017268809,0.022283478,0.009084584,-0.02669227,-0.062048934,0.006088067,-0.0015534722,0.00024874858,0.024307625,0.0033969735,0.017501011,0.018406063,0.012257899,0.0024825914,0.011945767,0.025286388,-0.01336253,-0.04005653,-0.013366831,0.00016858967,-0.0150807295,-0.018939726,-0.024651818,0.032618623,0.010378422,0.00816154,0.0015352393,0.0058718417,-0.0014963901,0.008343149,0.009092777,0.0078062997,0.017059177,-0.0014545813,-0.0044085314,-0.011013153,-0.027818572,0.013625809,0.00088979315,-0.033877566,0.013769177,-0.015751397,0.030415813,0.0053004595,-0.012012374,0.042492907,-0.026129462,0.017857991,0.014757782,0.034212038,-0.028255144,-0.031153738,-0.0208306,-0.015646942,-0.03244824,0.02857202,-0.017769633,0.030526763,0.011767631,0.013512412,0.004398589,0.0098373005,-0.0018924761,0.022184541,0.016675455,0.0033274114,-0.03449235,0.010143173,-0.013732179,-0.009266764,-0.008188583,-0.011176804,0.005126446,-0.02455554,-0.0350694,0.024437597,-0.0035163108,0.005672017,-0.0059790253,-0.017222028,0.016544089,-0.020211818,-0.003794192,-0.0026761636,0.022248005,0.01903592,0.01414654,-0.015811957,-0.019412482,-0.015289699,0.011394453,0.024607353,-0.017270185,0.03723887,0.04343247,0.009117531,0.013847502,-0.010062529,7.1181e-05,0.062179066,0.09010617,-0.0022222723,0.006409135,0.0071915477,0.010895524,0.011107339,0.034558468,0.03802365,0.03720733,0.033421267,-0.023704275,0.01462052,0.021062283,-0.006043507,0.010695791,0.026139956,0.004537822,0.058068894,0.005986127,0.0155631425,-0.024892585,0.00027051507,0.003393463,0.02525237,0.026490193,-0.03162742,-0.027992412,-0.015827494,0.030073948,-0.07057165,0.00539886,-0.02057992,0.020540223,0.0068986923,0.01756063,0.028855225,-0.0008232112,-0.036383916,0.056197934,0.02053185,-0.015247768,0.006972787,-0.023284262,0.024706274,-0.010340418,0.004265057,-0.0005430383,0.007850707,0.0052151224,0.053063285,-0.0063086143,0.018810201,-0.02189751,0.0067011598,0.013246322,-0.015287204,-0.03069716,-0.00035621788,0.006282735,0.011282402,-0.019562107,0.025916709,0.00089787255,-0.027869673,0.0003884989,0.030883497,0.020914564,-0.02251685,-0.0011071025,0.016003989,-0.0043504774,-0.009861116,-0.018504212,-0.00038649884,-0.002730932,0.012599917,0.5962195,-0.009152897,-0.03760986,-0.01365894,-0.004686175,0.011187252,-0.031298935,0.01334345,0.0039640306,-0.007615152,0.0068328436,0.0055018025,-0.020915035,-0.0025845733,-0.026298124,-0.028142655,0.014340335,-0.00097223016,0.020157458,-0.019406704,-0.059332658,-0.022455517,-0.0735775,0.0280202,-0.021778332,0.008305056,0.025796121,0.04828509,0.017930826,0.037004862,-0.062024355,0.013102827,0.0058363783,0.00019557535,-0.014906133,-0.004944261,-0.0032015566,0.014936546,-0.020785434,0.011632971,-0.007209346,-0.0076893046,-0.036852855,-0.034075584,0.028216733,0.04143087,-0.05372739,-0.012303433,0.029553916,-0.01279119,0.0022284638,0.0163803,0.059877735,0.01209722,-0.010004236,0.0070340564,-0.008721748,0.007833697,-0.005983081,-0.0041162767,0.0070409537,-0.0025250516,-0.038625404,-0.019826453,-0.01898965,0.014385476,-0.012470305,-0.023436714,-0.0019519689,-0.0053672073,0.008818317,0.005658672,0.019795803,0.01574107,-0.02779403,-0.014249827,0.0064809597,-0.0058654454,-0.028157549,-0.029932516,-0.021267124,-0.006363535,0.010000626,-0.028941572,0.0048525943,-0.053766098,0.005541182,-0.005576335,0.0118172215,0.036481287,-0.012373216,0.031962298,0.0073529636,0.047022972,-0.031323563,-0.025493877,0.010040255,0.030179927,-0.00326126,-0.00088375044,-0.012069897,0.011701702,0.0067243297,-0.007839504,0.0021989287,0.044502683,-0.0069334586,0.027925314,0.014361307,0.01722135,-0.031227292,-0.0030248833,-0.032946136,-0.0026885124,-0.033750717,0.0319288,-0.02285328,-0.029958645,0.002912204,-0.0041536964,-0.023306262,-0.004009821,0.035103746,0.030880542,0.015122729,-0.006122689,-0.015245125,-0.06124895,-0.02462424,-0.027044022,0.0047263526,-0.0066784336,-0.024198364,0.008289448,-0.0043963064,0.001535028,-0.0025284812,0.030400665,-0.038280647,-0.010751735,-0.010187664,-0.015443415,0.012994212,-0.009203223,0.0127542345,-0.004142154,-0.02055053,-0.007513913,0.032094933,0.003631807,0.017246194,0.0035726673,-0.014342318,-0.018973337,-0.035233963,0.00025584994,0.0010380727,-0.04327715,-0.018499445,-0.0028698205,0.008865057,-0.0054183938,0.016315717,-0.0012779945,0.022601169,-0.041607063,0.008244511,-0.01898295,0.007667829,0.029676897,0.021520728,-0.036045387,-0.030925691,0.06362404,0.016486559,-0.015876304,0.030018562,-0.017137697,-0.0018977048,-0.0614861,-0.012598878,0.018104682,-0.026762657,-0.03586926,-0.015874155,-0.004484293,-0.012034174,-0.0034145596,-0.03244005,0.00052743277,-0.011755669,-0.023678288,0.015359879,0.029775653,-0.030895498,0.015243749,0.0069879577,-0.007775612,-0.005894554,0.00404858,0.008153662,0.029028922,-0.103886396,-0.06300147,-0.040324993,0.014738666,-0.030903505,-0.005380083,0.00479972,-0.013979445,-0.010730124,-0.0015290496,0.03202446,-0.0022236044,-0.05069758,0.01257021,-0.030463068,-0.002194062,0.0026955723,-0.038842022,-0.018324634,0.02187549,-0.03113017,-0.011220648,-0.014118834,0.0018811014,0.004431487,0.0035116111,-0.012229569,-0.005359658,-0.029586976,0.013342969,0.03960584,-0.024912437,-0.02084592,-0.024105053,-0.009539531,-0.006504481,-0.01599788,-0.0047218525,-0.0308851,-0.011813184,-0.018866016,0.005213318,0.006050005,0.0031432123,0.00031233573,0.012762122,0.0089366045,-0.038346514,-0.013111467,0.0041915667,-0.0036324474,0.07344826,0.016927762,-0.018709943,0.008533934,0.007284777,-0.033627763,-0.012821086,-0.0104801,-0.013542603,-0.0104402965,0.033605028,0.03159867,0.013711957,0.005683676,-0.012270489,0.015225894,0.021720594,0.009340693,-0.009213327,0.0071403184,0.021436322,-0.014758995,0.0038774663,0.021148756,0.045758888,0.04046954,-0.05473027,0.014617992,-0.001955312,-0.004101191,-0.019052884,0.009115258,-0.007392201,-0.009878139,0.001136668,0.048035763,-0.04436095,0.05787015,0.025376474,-0.007815715,0.046007283,0.003415971,-0.01476289,0.0027159727,0.012463498,-0.0038894706,-0.031714525,0.011157288,0.009905591,-0.016428512,0.024322804,-0.035546508,0.0264122,0.025084179,-0.026237035,0.004806986,0.036058813,-0.00505341,-0.015136581,-0.031691045,0.019558635,-0.0043123933,-0.006154057,0.008191376,-0.0025635592,0.009082912,-0.0016534218,-0.049305562,-0.014248054,-0.02073708,-0.0061675874,0.48297983,-0.027113182,-0.0016253266,0.0007709881,-0.021306982,0.0060137594,-0.0015741717,-0.010387024,-0.016571535,0.010178796,0.021282358,0.005351372,0.007892563,0.02710505,0.0050336253,0.0097162565,-0.007814699,0.013188433,-0.004998858,-0.017770506,0.0044398014,0.0023147718,-0.00083244033,-0.01972588,-0.037353918,-0.05821515,0.024033142,-0.0102176415,0.011014663,0.005453703,0.052829962,-0.023385895,-0.01782453,-0.0013423671,-0.0060053687,-0.024148583,-0.014389759]', FALSE), +('bfffafed-d351-4447-95c7-c5df5fccafa4', 'f90d20cc-a0be-4ab2-89dd-0d57ac6145c4', 0, '# GitLab Runner (lab-ci) CI for the shelf runs on a single GitLab Runner registered against `git.reeseapps.com`. @@ -156,8 +156,8 @@ CI for the shelf runs on a single GitLab Runner registered against - Job images: `ghcr.io/reese/lab-ci:2026.7` for ansible plays, `debian:13-slim` for lint. - Concurrency is capped at 2; the `pve-upgrade` job never runs in - parallel with itself.', '[-0.02394116,0.013589614,0.0043455637,-0.039593697,0.010795078,-0.029333128,0.00071042817,0.03628535,0.0007485166,-0.042183086,-0.010876326,-0.040158357,0.027548824,-0.017773835,-0.0012339448,0.012739963,0.02140803,0.00435697,-0.0013049871,-0.025358483,0.009087785,-0.020457368,-0.014050455,0.021722456,-0.008037078,0.019195803,0.0133715505,0.0068202117,-0.01373857,-0.052558262,-0.021467341,-0.0048792167,-0.03796804,0.0017941173,-0.019993722,0.0022321232,0.0324762,-0.011305967,-0.01285501,-0.08554007,-0.00459579,-0.00026625913,-0.012708261,-0.021467168,0.0023501804,0.005992647,-0.047074836,0.010084524,0.0021380365,-0.0017837912,-0.039571073,-0.001467435,0.008784448,0.020895327,-0.012526759,0.031282693,-0.0047232904,-0.02374443,-0.026711868,-0.026771476,-0.0030595686,0.028225487,0.0027547968,0.008954544,-0.03348828,-0.017827928,0.022534354,0.047463022,-0.020956405,-0.0043598874,0.011438837,-0.05006089,0.03272723,-0.002906665,6.671013e-05,-0.030840088,-0.022713058,0.002611123,-0.044741053,-0.02435739,0.016601998,0.020138128,-0.017031932,-0.009141122,-0.010905373,-0.012441361,-0.044166207,0.0048189918,0.011554403,-0.013101684,0.0006347179,-0.00018356378,0.002989171,-0.05306866,-0.012389916,0.017050352,0.014652261,-0.02088377,0.008641642,0.0047000716,-0.0048646894,-0.026519548,-0.0067672865,-0.0037962692,-0.0363485,0.014531998,0.016549787,0.02026939,-0.0036979532,0.019903634,0.01975829,0.022829877,-0.00078712456,-0.024566347,0.015455295,0.01445105,0.01951838,0.0013151374,0.00087779475,0.015175662,0.004154677,-0.0052880566,-0.03659207,0.01012364,0.030702367,0.017092623,-0.054150973,-0.042197637,-0.0037574312,0.014304926,0.01606984,0.021216327,0.053747702,0.010063483,0.0030246682,0.054879792,-0.053179838,0.00059382734,-0.0039532897,-0.056530103,-0.0029615858,-0.005998702,-0.0016381601,-0.008502332,0.018982979,-0.046319317,-0.02701774,-0.003752784,-0.0034972976,-0.000993019,-0.01715258,-0.03430529,0.00949175,0.00030833881,-0.04065951,0.016271714,-0.009416144,-0.020305509,-0.031791884,0.010973972,0.004827566,0.002611172,0.027777487,0.018571457,-0.04592281,0.006445065,0.0009649743,-0.033940878,0.002228014,-0.027603883,-0.0045877094,-0.00079201965,-0.011637617,0.016024895,-0.01393219,0.048051875,0.01198274,0.021944577,-0.001690239,-0.027064921,-0.020160798,-0.029952746,0.021349547,0.009534797,0.004462131,-0.03359759,0.0050401497,-0.032910947,0.0003999966,-0.036990706,-0.010895685,0.044636242,0.025826802,0.012226928,-0.023313165,0.028755372,0.022328746,0.057370152,-0.029640831,-0.0137044685,-0.013007493,-0.023454329,-0.060936607,0.022302236,0.014099108,0.024837373,-0.001755567,-0.018326966,-0.01028684,-0.016036946,0.020757949,-0.003207087,0.040503047,0.020435885,-0.0014620235,-0.022534447,0.029166155,-0.038298722,0.02828486,0.029877255,0.02013538,0.0016801162,-0.009130007,0.014692451,0.021315172,0.01479843,-0.00054077344,0.009519538,-0.005273885,-0.01864372,-0.0040200506,0.011707154,-0.032200646,-0.004432217,-0.025890887,-0.07516651,0.00042999946,0.013377287,0.0033987886,0.004326047,0.023963371,0.04086495,-0.008889745,0.021913843,-0.037730616,0.017805226,0.0029632447,0.02643568,-0.028255915,0.0004800803,0.011214384,0.019871213,-0.028053839,-0.011089171,0.08648141,0.006464581,-0.0054583633,0.02239993,-0.035033233,0.011432793,-0.0018063409,0.010378224,0.014258126,0.01863165,0.012805213,0.01890974,-0.0060545206,0.008734008,0.007040066,0.0019934026,-0.020408178,0.026608547,-0.020376226,-0.0028421003,-0.024100404,-0.010188447,0.033134352,-0.021365825,0.008421927,-0.026819114,0.011412033,-0.047056206,-0.03591378,-0.025499092,-0.016637351,-0.033518553,0.02021888,0.0012450535,0.029039023,0.026135424,-0.018355362,0.0041306186,0.018986404,0.0018936774,-0.018083543,-0.0041910824,0.010145072,-0.00037399807,0.008653788,0.00013766447,-0.030971978,0.033316918,-0.016932031,-0.01999877,-0.015537846,-0.0107497,-0.00870449,0.005165062,0.0041138823,-0.011390587,0.007647568,-0.019482654,-0.006951895,0.0034910094,0.001278284,-0.0075397952,0.038096692,0.01737946,-0.011159954,-0.040012524,-0.015274288,0.002639086,0.020329539,0.015609268,0.021286426,0.045173556,0.007906702,0.031131271,-0.014751895,0.034721788,0.026980707,0.008766076,-0.03182555,0.018167058,-0.0063160993,0.017306456,0.021028237,0.040269077,0.015998209,0.020402597,0.02116688,0.004697522,0.018205382,0.016857324,-0.018873518,0.006935081,0.03170388,0.0009679736,0.04804448,-0.0038912485,0.022388894,-0.029064246,0.0064960686,0.00921489,-0.018782575,0.008753811,-0.036871172,-0.03999169,-0.0012991404,0.010463445,-0.022597512,0.024033144,0.002006753,-0.007545056,-0.02307345,-0.0058623482,0.027008653,-0.0024452575,-0.016643835,0.05033172,0.00094273925,-0.032781594,-0.0004603607,-0.017880883,-0.017038371,-0.0059442436,-0.0394055,-0.010719008,-0.013104238,-0.0039513307,0.060532942,-0.0007555663,0.011720407,-0.022064116,0.012932899,0.012562975,0.004713785,-0.037704714,0.04699721,0.008420355,0.018522304,-0.043540016,-0.0028737432,-0.014625257,-0.023144841,-0.02042263,0.014428496,-0.010921717,-0.024630914,0.018698098,-0.0061399126,0.0019750635,-0.03054196,-0.026811648,0.0003519449,0.025999721,0.018858671,0.6081682,-0.030757638,-0.019342732,-0.00029006432,-0.0088748075,-0.011367456,0.009194233,0.06246026,0.023077708,-0.028307626,0.011830814,0.0077994694,-0.0032456247,-0.011430726,0.012325637,-0.0330886,0.024937553,-0.018379753,-0.0049150456,-0.0009395355,-0.072745964,-0.021133123,-0.04357067,0.0047441074,0.02625681,0.0045689596,-0.001498547,0.020894753,0.011414692,0.05577098,-0.034994047,-0.0048337723,-0.0060309,0.015350703,-0.024396917,-0.015943933,-0.016684182,0.01892003,-0.0019145658,-0.0083576515,0.02393349,0.0012702042,0.015651751,-0.024487011,0.023511406,0.012353809,-0.0016158075,-0.015221123,0.01936229,0.028826922,0.0003969893,0.023646189,0.010328314,-0.009650785,0.011109158,-0.012800861,-0.02247046,-0.03042445,-0.013144442,-0.012992953,0.0090354085,-0.0068788542,-0.04218809,-0.037935197,0.007468175,0.0059823757,-0.0034973698,-0.007328373,-0.0058338773,0.012868931,0.004518396,-0.0033247364,0.042194396,0.015682435,-0.014993503,0.040237576,0.02449302,-0.015579161,-0.008351251,-0.025771195,-0.021543106,0.031288367,0.0008355624,-0.011897273,0.0039517875,-0.03250001,0.018979715,-0.028971585,-0.010905844,-0.00168544,-0.040084727,-0.0063644582,0.0011294585,0.05289378,-0.014081261,-0.003274869,-0.0010260791,0.01231831,-0.01822915,-0.020789506,0.006011723,0.007782259,0.005760857,-0.0030175834,0.016054401,-0.056478426,0.030334366,0.02944521,0.0047320416,0.019918827,-0.010361321,0.007141029,-0.0089059165,-0.05159298,0.028771248,0.015948815,-0.0023770074,-0.028388605,-0.01498803,-0.022792768,-0.016431415,-0.030891024,0.057948325,0.044931047,0.019629272,0.016641837,0.013012011,-0.038017634,-0.010931106,-0.031429976,0.020745805,-0.025889667,0.00053549535,0.0041620424,0.014365199,-0.018727895,0.0051019047,0.032298982,-0.039204642,0.007048237,-0.023778465,0.010828415,0.033454258,0.01626041,-0.030489597,-0.0053399773,-0.024236642,0.00021338064,-0.021619696,0.011009642,-0.0031692565,0.0017186416,0.00473508,0.012186864,-0.0073675197,-0.02042489,-0.0013135747,-0.008279113,-0.003870957,-0.02652751,-0.016822139,-0.008251522,0.030554723,0.010867144,0.008320487,0.008651203,0.011989929,0.02342135,0.026869852,0.025539255,0.029988352,-0.044138167,-0.014965856,0.057118785,-0.012609945,-0.016612703,-0.0045674434,-0.0023083542,-0.030269854,-0.04199666,0.010131222,0.014667076,-0.012449214,-0.005117336,0.0167625,0.022660034,-0.010024802,0.0016059515,-0.0027442486,-0.023053799,-0.036603384,-0.0032770757,0.012163055,0.0056813313,-0.010652538,0.022398872,0.016042784,0.00965504,-0.02443599,0.01872035,-0.014599522,0.0076382505,-0.05715234,-0.046215046,-0.0041102953,0.030344712,-0.003944973,0.019834558,-0.013307713,-0.014476681,0.016928867,0.0032097704,-0.008279881,0.0147421,-0.012144355,-0.0030861164,-0.005510272,-0.015533046,-0.04394824,-0.03804021,-0.026478266,0.0140797645,-0.053945698,-0.029282594,0.011083838,-0.035023097,-0.006728347,-0.033264033,0.016073221,-0.012928655,0.0051766285,-6.6841244e-06,0.005322468,-0.0061627342,-0.037305824,0.012630194,0.009776324,-0.009581363,-0.009114885,0.033766855,-0.029959818,-0.008444944,-0.01362997,0.015091438,0.010507495,0.008690331,-0.017237443,0.016783087,-0.004589589,-0.06905845,0.0063057058,0.008017678,-0.009587546,0.013765404,0.006069982,-0.023723576,0.03675805,-0.01280767,-0.017637393,-0.0041231657,0.021714803,-0.003442561,-0.013030136,0.03417862,0.0037155924,0.014742321,0.010649703,-0.0128561165,0.005618433,-0.007975904,-0.031899836,-0.018822843,-0.01959247,0.022411607,-0.03356875,-0.004341604,-0.0017637733,0.025731849,0.042616937,-0.043657135,0.0039601834,-0.017068489,0.0067843706,-0.04915417,0.009980142,-0.008239986,-0.002353952,0.015065371,-0.011745687,-0.02547143,0.045997187,0.027452141,-0.029200055,0.0067262896,0.010724735,-0.013654225,0.021522848,-0.00965876,-0.005858505,-0.00059444987,0.006618734,-0.016075209,0.004025622,-0.014736814,-0.038073767,0.02470224,0.04868952,-0.017739791,-0.011070849,0.031724874,-0.02313497,-0.030563464,-0.02749536,-0.009750251,0.0029681877,-0.01623704,0.028133724,-0.020997284,-0.02010002,-0.004322833,-0.03483266,-0.012605514,-0.01321407,-0.013842247,0.4754688,-0.015343049,-0.031404596,0.020371102,-0.032056965,0.027095824,0.0060868347,0.014269672,0.008583462,-0.03444526,0.022727828,0.015071681,0.014778032,-0.005126549,-0.012179444,-0.032749932,0.0025540907,0.0027157178,-0.007990837,-0.013090978,0.004306154,-0.0045899847,0.012480492,-0.0114657,0.004094901,-0.053243585,0.045387972,-0.019443454,-0.0076749423,0.01760851,0.03594384,0.018864986,-0.023397395,0.008013871,0.038409628,-0.042707648,-0.013492177]', FALSE), -('db5a503f-ec26-413d-9f29-0953e25bf77f', 'fd7a1902-7366-49fd-9c33-06bf9fdce547', 0, '# BOR Vault Sync Unit + parallel with itself.', '[-0.024116822,0.013582843,0.004386523,-0.039468747,0.010714522,-0.02940293,0.00062085653,0.036253355,0.0007905356,-0.042149622,-0.010932942,-0.040079787,0.027607756,-0.017769411,-0.0012000569,0.012741768,0.021396674,0.0043564094,-0.0013055144,-0.025355283,0.009088329,-0.020417389,-0.013989464,0.02165144,-0.008005497,0.019243274,0.013345685,0.0068106353,-0.013564918,-0.052589975,-0.021433057,-0.004943664,-0.03801153,0.0018660363,-0.019979395,0.0022761505,0.032552965,-0.0115053505,-0.012950076,-0.08554341,-0.0045150775,-0.00022509386,-0.012584977,-0.021409336,0.0023448118,0.0060078204,-0.046994373,0.010072697,0.0021553528,-0.0018646555,-0.039557386,-0.0014488944,0.008787829,0.020940749,-0.012491114,0.031436965,-0.004775834,-0.023784265,-0.026694743,-0.026811315,-0.0031969936,0.028058479,0.0028653515,0.009021052,-0.033479102,-0.017815948,0.022679579,0.047686335,-0.02096332,-0.004362952,0.011535343,-0.050018564,0.032637008,-0.002855468,4.602102e-05,-0.030843593,-0.022617461,0.0026673803,-0.04478168,-0.024296636,0.016531428,0.020181855,-0.017000046,-0.009045113,-0.010909765,-0.012419658,-0.044060625,0.00493711,0.011341915,-0.01309127,0.0006641895,-0.00025382204,0.0030397372,-0.053052302,-0.012423507,0.01713795,0.014637437,-0.020871148,0.008674247,0.0049016904,-0.004880794,-0.026537731,-0.006724413,-0.0039100046,-0.036311727,0.014446144,0.016475001,0.020254234,-0.003611665,0.019801605,0.019661797,0.022953292,-0.0009282561,-0.024679419,0.015475549,0.014485649,0.019377733,0.0012895243,0.00096474576,0.015161533,0.004141969,-0.005221057,-0.03668028,0.010144769,0.030615525,0.01702737,-0.05437858,-0.04211706,-0.0037851331,0.014353958,0.016203323,0.021185944,0.053818356,0.0100585,0.002961705,0.054878533,-0.05313572,0.0005841338,-0.003909398,-0.05651002,-0.0029585096,-0.0060187276,-0.0016473212,-0.008509374,0.019071873,-0.04635953,-0.027045835,-0.003749647,-0.00341005,-0.0008702617,-0.01711971,-0.03428456,0.009413125,0.00038029221,-0.0406957,0.01631861,-0.0093980245,-0.020244012,-0.031952895,0.010971265,0.004810953,0.0025394915,0.027870052,0.01853287,-0.045917917,0.0063836654,0.0009425779,-0.034054942,0.0023540575,-0.02760875,-0.004611929,-0.0008282802,-0.011708838,0.015961166,-0.013897487,0.047915746,0.012038858,0.02200958,-0.0017908633,-0.02701742,-0.020263072,-0.029770609,0.021419944,0.009495933,0.0044518136,-0.03370517,0.0050876844,-0.032826453,0.00042420372,-0.03689647,-0.010855446,0.044744257,0.025869358,0.012165939,-0.023371892,0.028597029,0.022319976,0.057358354,-0.029747041,-0.013588111,-0.013032264,-0.023503736,-0.060959935,0.02228466,0.014145311,0.024900433,-0.001708143,-0.018447302,-0.010237136,-0.015946556,0.020831052,-0.0030785536,0.04038536,0.020435654,-0.0014110317,-0.022520095,0.029113073,-0.038251244,0.028171482,0.02982935,0.02014283,0.0015813858,-0.009161687,0.014605978,0.021137128,0.014737973,-0.0006142884,0.009587725,-0.005410448,-0.018578436,-0.0039915033,0.011765063,-0.03235492,-0.0044408115,-0.025799839,-0.07523895,0.00042489005,0.0134699065,0.0033270319,0.004331067,0.023863774,0.040818363,-0.008955573,0.021866206,-0.037772518,0.017683975,0.0029602747,0.026456874,-0.028068474,0.00054297684,0.011153577,0.019826673,-0.028045254,-0.01114085,0.08636642,0.0064883954,-0.0055549894,0.022260739,-0.035033498,0.011486429,-0.001824893,0.010180993,0.0142385885,0.018409695,0.012820815,0.019024586,-0.006076182,0.008856199,0.007005139,0.001980965,-0.02051117,0.026708668,-0.020427698,-0.0028726158,-0.024111988,-0.010099944,0.033126045,-0.021200594,0.008469044,-0.026756683,0.011387502,-0.046990383,-0.03599431,-0.025596738,-0.016734155,-0.033334095,0.020245666,0.0012142167,0.029057834,0.026086109,-0.018239519,0.004188605,0.018955192,0.0018954043,-0.017912008,-0.004315132,0.010276821,-0.0004918768,0.00870519,0.00020935052,-0.030974023,0.033415034,-0.017046014,-0.01998852,-0.015504538,-0.010690331,-0.00866975,0.0051916847,0.0041865488,-0.011581849,0.0077387444,-0.01949865,-0.006847701,0.0035261721,0.0012294085,-0.007572184,0.038176216,0.017446775,-0.0111778015,-0.040084463,-0.015286507,0.002566728,0.020304082,0.015757266,0.021291269,0.045004603,0.00788068,0.031150691,-0.014897958,0.034656532,0.026969617,0.008756257,-0.031970605,0.018212654,-0.0062813074,0.017376753,0.021032576,0.04024,0.016006358,0.020420633,0.021126747,0.0046155606,0.018095518,0.016892124,-0.018886218,0.0069549107,0.031638943,0.001078911,0.04806689,-0.0038476884,0.02233359,-0.029053902,0.00647414,0.0093020825,-0.018790632,0.008803533,-0.036765456,-0.039927974,-0.0013289462,0.010331271,-0.022647886,0.024121935,0.0019764155,-0.0076370207,-0.02303141,-0.005925559,0.027104931,-0.0026038338,-0.016714126,0.050322898,0.00096380815,-0.0328902,-0.0004168509,-0.017785175,-0.017214833,-0.005997206,-0.0393216,-0.010692921,-0.013098847,-0.003761648,0.060534116,-0.0008535708,0.0117305145,-0.022003153,0.0127784815,0.012391986,0.004742131,-0.037683997,0.047139637,0.008477822,0.018390706,-0.04353765,-0.0028005517,-0.014573723,-0.023155732,-0.02024789,0.014385394,-0.010911122,-0.024676522,0.01862267,-0.006288318,0.002037177,-0.030564914,-0.026819915,0.00036598442,0.026073795,0.018819705,0.6082579,-0.03076146,-0.019254243,-0.00032471766,-0.0087986505,-0.011274565,0.009158637,0.06254641,0.023093596,-0.02842072,0.011834288,0.0077121546,-0.0031696297,-0.011469505,0.012349181,-0.033126276,0.025087358,-0.018347804,-0.0049358904,-0.00097267557,-0.072676435,-0.021054696,-0.04360654,0.0048467987,0.026226593,0.0046063815,-0.0014406224,0.020856412,0.011408706,0.055701267,-0.034958713,-0.0046967915,-0.0060248063,0.015220075,-0.024398033,-0.015909787,-0.016725216,0.018912228,-0.0019152377,-0.008403512,0.024036737,0.0012114088,0.015556748,-0.024352588,0.023466606,0.012584397,-0.0015827273,-0.015215252,0.0193538,0.028887581,0.00053882296,0.02361732,0.010345831,-0.009642387,0.011155847,-0.012833427,-0.022515038,-0.03034843,-0.013201996,-0.012981448,0.008977614,-0.0067193964,-0.04222164,-0.037844915,0.007520403,0.0061633145,-0.0034429356,-0.0074062003,-0.005778101,0.012826829,0.004585153,-0.0033465729,0.04210328,0.015616376,-0.015145456,0.040293545,0.024506524,-0.015472268,-0.008369547,-0.02565445,-0.021565529,0.031324647,0.00076559896,-0.012005482,0.003926164,-0.032529734,0.019109204,-0.029080424,-0.010937735,-0.0016042006,-0.040208135,-0.006432157,0.0011765962,0.052878164,-0.01404623,-0.0033037702,-0.0009563177,0.012319135,-0.018224334,-0.020786881,0.0058521307,0.007672871,0.005807977,-0.0029974934,0.016115382,-0.05616418,0.030537229,0.029368784,0.0047586937,0.019905247,-0.010384243,0.007051042,-0.008827569,-0.051557805,0.028857669,0.015849125,-0.0023454516,-0.02829373,-0.015052764,-0.022746792,-0.016548665,-0.030782826,0.057959914,0.045012232,0.019627927,0.016590364,0.013007738,-0.037896518,-0.011078249,-0.031482972,0.020727562,-0.026125627,0.000576315,0.004130303,0.014417994,-0.01879066,0.00500434,0.0322011,-0.039125834,0.0070535266,-0.02370904,0.010999503,0.033413537,0.016256576,-0.03044955,-0.0053935912,-0.024242504,0.00020798342,-0.021646354,0.01099937,-0.0031402665,0.0017626273,0.0048102513,0.012284462,-0.0073124515,-0.020521821,-0.0012996235,-0.008350085,-0.0038437056,-0.026358362,-0.017053569,-0.008282087,0.030537201,0.010937768,0.008246693,0.00866275,0.011908377,0.023442801,0.026611324,0.025424074,0.030008439,-0.044106,-0.014928423,0.0571431,-0.012711779,-0.016584154,-0.0045287935,-0.0022971826,-0.030279748,-0.042003736,0.009975218,0.014749385,-0.012462775,-0.0052131754,0.016805423,0.022552723,-0.010052815,0.0016899536,-0.0027734602,-0.023034649,-0.036692962,-0.0032133225,0.012313432,0.0055910056,-0.010615684,0.022262927,0.015987296,0.009635059,-0.024490345,0.018711839,-0.014715654,0.007568695,-0.05719657,-0.046245568,-0.004131033,0.03042342,-0.004074865,0.019796697,-0.013365249,-0.014446556,0.017023593,0.0032932954,-0.008381949,0.014810913,-0.012148249,-0.0030019968,-0.005564855,-0.01544994,-0.04391753,-0.037873812,-0.026438795,0.0142047135,-0.05379285,-0.0293535,0.0109109925,-0.035060443,-0.006734033,-0.03322203,0.016078046,-0.013065838,0.005103742,9.221103e-06,0.005402875,-0.0061820894,-0.037343495,0.012524797,0.009832902,-0.009529433,-0.008971119,0.033547264,-0.02994443,-0.008482362,-0.013524741,0.015089063,0.010441941,0.008710904,-0.017310515,0.016912488,-0.004576312,-0.06900509,0.006175203,0.008040931,-0.009450683,0.013769464,0.006098804,-0.023779249,0.036455184,-0.012823508,-0.017660301,-0.0042583463,0.021754405,-0.0033332643,-0.012997082,0.034163292,0.0037508735,0.014684624,0.010679393,-0.012764521,0.005695331,-0.007978649,-0.03197465,-0.018878765,-0.019604955,0.02245284,-0.03362725,-0.00434011,-0.0017746734,0.025670532,0.042606004,-0.04360027,0.00401066,-0.016906202,0.006705162,-0.049133934,0.0097822435,-0.008350082,-0.0024336234,0.015097091,-0.011603665,-0.025388477,0.046054125,0.027392337,-0.02911631,0.0067302366,0.010617388,-0.013633539,0.021514878,-0.009625878,-0.005938455,-0.000515592,0.0066952948,-0.016069094,0.0040316302,-0.014688354,-0.038111035,0.024709098,0.048691783,-0.01766583,-0.0110362815,0.03181625,-0.023211205,-0.030641198,-0.027594946,-0.009887914,0.002914938,-0.016349852,0.028198335,-0.020968823,-0.020051096,-0.0043889903,-0.034783382,-0.012574511,-0.013167687,-0.013881421,0.47548082,-0.0152872745,-0.03137056,0.020359354,-0.032009386,0.027026398,0.005965917,0.014332174,0.0085998755,-0.03438479,0.022741614,0.015041919,0.01468982,-0.0051415614,-0.012292642,-0.032745294,0.0027117417,0.0027087643,-0.008033892,-0.013110882,0.0043599326,-0.0046270015,0.012419274,-0.011490081,0.004123226,-0.053247765,0.045349568,-0.01951295,-0.0076957433,0.017521137,0.036079083,0.018929943,-0.023364812,0.0080572795,0.038413588,-0.04283349,-0.013518736]', FALSE), +('e64a231d-3fa4-4831-801b-ece57ad95cf5', 'f183f389-a110-4373-8640-4af205d2d026', 0, '# BOR Vault Sync Unit The Brain-of-Reese vault syncs from `obsidian.container` on `rack7-pbx2`. @@ -171,8 +171,8 @@ The Brain-of-Reese vault syncs from `obsidian.container` on `rack7-pbx2`. - The vault is a bind mount from `/data/vaults/rack7` (Btrfs subvolume). - Only `rack7-pbx2` may hold the vault — do not clone it to another node. - Image tags are date-stamped; bump by re-pushing - `ghcr.io/reese/obsidian-bor` and restarting the unit.', '[-0.034927994,0.03581605,-0.015919589,-0.014969843,0.023104362,0.003942424,0.023816373,0.013501817,0.0017010501,0.0048210155,-0.014216858,-0.00841087,0.011326528,0.03372647,0.0122917425,-0.0017838015,0.015251865,-0.006387349,0.001205264,-0.053797588,0.00924057,0.011510034,0.03063006,0.03910991,-0.01582097,0.018148659,0.0036202718,-0.012048634,0.0029272123,-0.0075285933,-0.022817668,-0.004744225,-0.021465147,0.029683432,-0.015999332,0.0070492104,0.027701573,-0.0029718257,-0.08239857,-0.14185132,0.009774235,-0.010739144,-0.03322081,-0.0014771614,0.03387035,0.0038823893,-0.053461846,0.010744604,0.010858496,-0.008584236,-0.046623558,-0.0038832827,0.009877069,-0.005649265,0.001220409,0.008942952,-0.036843635,-0.055596992,0.014242529,0.004140609,-0.025510168,0.007221458,-0.003310579,0.007829745,0.00090325845,-0.030501219,0.03241062,-0.068120405,0.0074142376,-0.012155859,-0.004025893,-0.017773205,0.015408123,-0.008137845,-0.0006938982,-0.00038841873,-0.0014751343,-0.0024646719,-0.0055454043,-0.02645798,0.049045645,-0.0033834437,-0.023975251,-0.021580542,0.004554619,-0.0070732674,0.0037938883,0.02056934,0.025284199,0.025843551,0.0012510123,0.01985017,-0.011078139,-0.034818042,-0.019823713,-0.012800928,0.015459423,-0.0062580258,0.013468766,-0.037364002,-0.0046948087,0.018266993,0.0111319395,-0.017167997,-0.013460256,0.033160325,0.015582079,0.010964723,-0.0067386436,-0.02029143,0.023540372,-0.032809947,-0.01278382,-0.028434193,0.027994934,-9.934851e-05,0.0054859486,-0.016275441,0.038697064,0.041745868,0.0018829855,0.018350087,-0.028227508,-0.022418717,0.0048902584,0.031447895,-0.028224263,-0.026244937,0.028053997,0.0063617914,0.0064196074,0.017052472,0.032934334,0.02551254,-0.012396483,0.008322405,-0.01675726,-0.0594679,0.008667546,-0.034908213,0.02048087,-0.024542347,0.009340499,0.014854106,0.035751,-0.06002764,-0.00024268652,0.0017722582,-0.029558584,-0.014348443,-0.024038741,-0.02226877,0.01643164,-0.015006683,0.0070720753,0.021601737,0.005491154,0.002736049,-0.003446433,0.017922994,0.00045934983,0.014640768,0.04566964,-0.004243714,-0.033306725,-0.020363826,0.018427612,-0.005393921,0.016081298,-0.013078507,-0.012250547,0.0012059341,-0.023743955,-0.01735065,0.04971684,0.040711332,-0.00091828936,-0.0060869413,-0.0058863303,-0.038194142,-0.023159686,-0.02480007,0.030541109,0.011836081,-0.007980738,-0.034721334,-0.008338749,-0.023009492,-0.014351332,0.0066233804,0.024081068,0.0025688896,-0.0059153754,0.01130356,-0.030011402,0.017857466,0.010958422,0.051137388,0.0071478607,-0.00026991792,-0.0064783962,-0.020495452,-0.03674646,0.031269073,0.012417582,-0.019585336,-0.016818471,-0.010990102,0.038683444,0.0031920045,0.05009478,0.011479679,0.009833807,0.007060671,-0.011554308,-0.038555462,0.022950832,0.00073681347,0.0043560914,-0.0018660291,0.0018133254,0.0018565704,0.01219116,0.032774955,0.06257339,0.0438672,-0.031071296,0.012840099,-0.02600756,-0.0011557054,-0.03024176,-0.009873496,0.0073004523,-0.020450056,-0.02123496,-0.07365904,-0.017470611,0.037297282,0.013071751,-0.008681177,0.02545702,-0.021986995,-0.014516656,0.021261888,-0.027722947,0.018766994,-0.009598153,0.018443223,-0.033945188,-0.025104133,0.006805151,0.013808302,-0.026093887,-0.0015227891,-0.00022503512,0.0368062,-0.031463034,0.024668295,-0.031084478,5.252025e-05,0.0104905395,0.013140671,-0.0036925406,0.025114698,-0.0047284137,-0.003733264,0.0077099847,0.0014369407,0.0095134815,0.02093328,-0.009338527,0.021841722,-0.013798292,0.003582863,-0.017940491,-0.044529535,0.05095362,0.0113113625,0.035331346,0.032491628,0.030405147,-0.02602559,-0.023942523,-0.030100279,-0.008982121,-0.03061812,-0.021948313,-0.016310932,0.01389326,-0.01982524,-0.014128104,0.040859386,0.0009933395,-0.010045633,-0.036346573,-0.025849266,0.022235803,0.008432182,0.021959804,-0.0006165535,-0.012034408,0.019796815,0.016294055,-0.021048423,-0.016938334,-0.04498977,-0.073088095,0.022255644,-0.01535786,-0.01053818,0.0070896843,-0.0031272606,0.0014503731,-0.0055055562,-0.006096765,-0.004004879,0.04878048,0.008321836,-0.02321705,-0.019729717,0.0025504213,0.014843679,-0.0013751044,-0.0024958656,-0.03221714,0.056195628,0.023518246,0.009394448,0.017034654,0.019863548,0.017010814,-0.025170686,-0.007096249,0.04200081,-0.013628967,0.0030979656,-0.015414722,0.02127552,0.0473957,0.05501603,0.017582215,-0.0053943573,0.030755427,-0.0010197068,-0.017840706,-0.00647113,0.014630894,-0.026355065,-0.0034447536,0.0034833937,0.0384957,-0.004013649,0.025669921,0.0072445357,0.0015347019,0.037597157,-0.018969614,0.0065296325,0.008015431,-0.017251661,-0.053151026,0.037321877,0.014596314,0.033343803,-0.029850086,0.007023303,0.03534467,-0.041505016,-0.042219914,0.05349588,-0.022652218,-0.0034080723,0.0324992,0.0022214917,0.05529833,-0.0023045677,0.009634192,0.008721681,-0.016947826,-0.025604747,0.020484015,-0.0065855626,0.0077140736,-0.010589768,0.020519458,-0.017926542,-0.004567926,-0.04570518,0.0367822,-0.0035985915,0.0085488,0.012377435,0.029772595,-0.016199298,-0.012180007,0.018997082,0.021008227,-0.016596287,-0.03589979,0.0029481275,-0.021845844,-0.0013696477,0.008972326,-0.01957745,0.00883342,-0.01253184,-0.0034841967,0.57306767,-0.0032351129,-0.013690586,-0.034050316,0.0064615267,-0.01587463,0.012486392,-0.009032403,-0.028841244,-0.028569816,-0.0004245411,0.005905909,-0.011475815,-0.0001353641,-0.023589443,-0.019442916,0.030834086,0.014507823,-0.006482109,0.022787604,-0.028879074,-0.022945445,-0.051563658,-0.0036393376,-0.020084938,0.016628541,0.028767334,0.021255357,-0.018766914,0.03657417,-0.028901733,0.0066127963,0.0044203163,0.009212962,0.00026956847,0.0036427632,-0.015466737,0.0035851577,-0.009030128,-0.012019086,-0.0026192348,-0.028529268,0.0002866394,-0.01571918,0.008810382,0.040654767,-0.016903987,0.013192454,0.021249745,0.013782772,-0.016598629,0.029290898,0.018022254,-0.006529287,0.008883789,-0.026399784,-0.015106799,0.0026815126,-0.006119373,0.008163179,-0.008011649,-0.008640337,-0.04463574,-0.043707922,0.015982207,-0.008255912,-0.034364104,-0.022291744,0.0024028276,0.013516474,0.0075456756,0.015278781,0.018974092,-0.027498784,0.009490068,-0.0054086433,0.06594973,-0.03725581,-0.01289325,-0.0012455803,0.0015592515,0.00866639,0.0075154337,0.0066252127,0.042920955,0.016661577,-0.015151891,-0.010291332,0.006063763,0.047120146,-0.00941602,-0.008012047,0.0005263801,0.053926658,-0.004490881,-0.017894903,0.036355518,0.029454352,0.0008446574,-0.01651273,-0.0047027217,0.00031976512,0.0223733,0.011409689,0.022826875,-0.018123332,-0.010348561,0.016192436,-0.00014248735,0.010837452,-0.032986272,0.026756886,-0.045197237,-0.03986081,-0.02272427,0.0041163573,-0.048471455,-0.024005359,0.024394823,-0.0013113092,0.033900462,-0.027647896,0.018593442,0.044482242,-0.011991837,0.038008623,0.01910124,-0.050227463,-0.008769065,-0.024871739,-0.0077622104,-0.040867973,-0.03728654,0.009256649,0.009203407,-0.0011110974,0.06680131,0.018549096,-0.047885194,0.01659111,-0.026744576,-0.033982746,0.014567947,0.010656002,-0.023603523,0.016940711,0.00018650011,0.0028217416,-0.008809087,-0.017893735,-0.016016847,0.004467661,0.006509493,-0.017969986,0.002890076,-0.0032897522,0.0042143343,-0.017786387,-0.008839755,-0.015757803,0.0200543,0.019841857,0.05403403,-0.030210648,-0.020458072,0.004502997,0.0077428753,0.024568256,-0.022551224,-0.01430269,0.015123038,-0.023150347,-0.013032639,0.056927964,0.008507384,-0.021907764,0.0025054137,0.0065117464,0.014373521,-0.0449918,-0.0003840058,0.014238386,0.00086204906,-0.023373311,0.016051898,-0.0059168735,0.0019287328,0.032397218,-0.020736396,-0.030808877,-0.021575008,-0.010048529,0.053430382,0.0054600923,0.0021110189,0.02053228,-0.0036281934,0.013941913,-0.027024621,0.030725488,0.0018790467,0.012110993,-0.073496714,-0.01966684,-0.037439853,0.04212979,-0.019169543,0.013056914,-0.0284626,-0.021363357,-0.016915455,0.0069531086,-0.039375894,-0.05684862,-0.022147201,0.008239587,-0.0067893066,-0.014917293,0.015110488,-0.022877451,-0.03524684,0.011508828,-0.014718321,0.00534065,0.01911085,-0.027969457,0.0018609591,-0.019459222,-0.010987313,0.010948302,-0.005882144,0.0039054316,0.04652691,-0.00069903955,-0.0016362006,-0.011965807,-0.024347456,0.0064053247,-0.009855416,0.031972595,-0.008248491,-0.02863889,-0.008155853,-0.016785147,0.015404648,-0.007909812,0.0014813227,-0.002549537,0.044576954,-0.028579475,0.0135531025,-0.0149551565,-0.005591214,0.02366824,0.025862802,0.0024851935,0.030415133,-0.012238127,-0.04716214,-0.016231392,0.022691347,-0.02482465,-0.011914859,0.023215054,0.031714138,-0.0034040832,0.035612993,-0.009754671,-0.035605766,0.010992588,-0.008304019,0.0061131543,0.0011883129,0.015247001,0.02441507,0.015327133,0.018907811,0.0073393765,0.03520345,-0.014841351,0.017741553,-0.05597474,-0.035327643,0.0032599706,0.00978332,-0.048760027,-0.00044644915,0.0054810224,-0.009341119,-0.014660195,0.020735323,0.0013525415,-0.019843668,0.024597261,-0.008170793,-0.01494519,-0.0075685107,7.96766e-05,0.0028567628,-0.009174722,0.049673893,-0.050565634,-0.0005792455,0.017407771,-0.009163939,0.047892693,0.01894894,0.02574025,-0.01170768,0.0058390475,-0.008103895,-0.030417722,-0.007060908,0.014635748,0.00228361,-0.036417432,-0.0010232927,-0.014784385,-0.01834012,-0.0028734533,-0.004318586,-0.010236185,0.0021429441,0.015617409,0.4834761,-0.012636616,-0.022687469,0.020701395,0.0051027783,0.022181049,0.03529972,0.0351972,0.00046523323,-0.0076142275,0.036174092,0.009535818,-0.010017993,0.026272342,-0.006706261,-0.03577366,-0.018832838,0.03730758,-0.011704337,-0.01835008,-0.011085872,0.026068581,-0.003491386,0.0051502106,0.0063009546,-0.032249182,0.019407172,-0.008202471,-0.038159885,-0.021650437,0.0309004,0.0035308886,0.0038892007,0.04404218,0.028102491,-0.005017166,-0.015273466]', FALSE), -('14ca895a-2048-45e9-a4f0-900c1525080e', 'f24790a5-dc20-4a28-8f96-37f4e6db1cfb', 0, '# Restic Backups for Rack7 + `ghcr.io/reese/obsidian-bor` and restarting the unit.', '[-0.03491476,0.035869095,-0.01582354,-0.014913304,0.023047931,0.0040641287,0.023789382,0.013562909,0.0017575669,0.0048258016,-0.01417429,-0.008365432,0.011217217,0.03382932,0.012256219,-0.0019164067,0.015187311,-0.006443484,0.0011336063,-0.053635865,0.009237916,0.011571051,0.030657401,0.039006814,-0.015935235,0.018259618,0.0036865643,-0.012118487,0.0029672247,-0.007449511,-0.022886971,-0.004606291,-0.021222001,0.029964825,-0.01598977,0.0070107137,0.027741048,-0.0028492126,-0.08241297,-0.1417608,0.009792102,-0.010859354,-0.03323338,-0.0014661638,0.03373998,0.0038416851,-0.053533446,0.010505285,0.010886497,-0.008709819,-0.046715036,-0.0038849588,0.009949761,-0.005753343,0.0012441397,0.008868596,-0.03682936,-0.055505987,0.014378312,0.0041284286,-0.025578251,0.00713573,-0.0032559428,0.0077388682,0.0008104813,-0.030581586,0.032466806,-0.068177715,0.007285088,-0.012180802,-0.0039089452,-0.017825395,0.015365129,-0.00805161,-0.0007870331,-0.0004605712,-0.0014293618,-0.002529493,-0.005489246,-0.026496267,0.049179796,-0.0034335267,-0.023806706,-0.021579027,0.0046472456,-0.006944113,0.003547938,0.020514354,0.025287619,0.025854977,0.0011490957,0.019939594,-0.01112379,-0.034943715,-0.019842505,-0.012838516,0.015448881,-0.00620218,0.013366523,-0.037232224,-0.0046703382,0.01824641,0.011178504,-0.017197588,-0.013704094,0.03313564,0.015647413,0.01104509,-0.0067171594,-0.02021759,0.023525463,-0.03285407,-0.012680353,-0.02852033,0.02787499,-6.196879e-05,0.0054960675,-0.01637356,0.03862195,0.0417377,0.0018851402,0.018348562,-0.028157426,-0.022434404,0.004896942,0.03125376,-0.028167827,-0.026175734,0.027954767,0.006358384,0.006367786,0.016984489,0.032957263,0.025355808,-0.012507706,0.008274623,-0.01684413,-0.05940413,0.008672528,-0.03477825,0.020603681,-0.024629997,0.009412474,0.014947181,0.035822663,-0.06005317,-0.00018374424,0.0018438756,-0.029478507,-0.014245474,-0.024093123,-0.022279184,0.016383216,-0.01502709,0.007129251,0.021606432,0.005494586,0.0027172498,-0.0034462877,0.01787134,0.00047053257,0.014607162,0.04576581,-0.0043632304,-0.033313267,-0.020357693,0.01830546,-0.0052860407,0.016146543,-0.013151272,-0.012257923,0.0013569269,-0.023861503,-0.01740621,0.049803916,0.04077827,-0.0010482303,-0.0061541926,-0.0059006014,-0.03815713,-0.023245918,-0.024858383,0.03052257,0.011753797,-0.007863504,-0.034726307,-0.008400688,-0.022926077,-0.01456653,0.0067407084,0.024232756,0.0027375778,-0.0059344117,0.011247073,-0.029990338,0.017855464,0.0109511,0.05115164,0.0072480584,-0.0003062795,-0.006528544,-0.020522743,-0.036752623,0.031061467,0.012485196,-0.019710775,-0.016817026,-0.011010415,0.038690325,0.0031353275,0.049972866,0.011392703,0.00990533,0.006939394,-0.011593379,-0.038645104,0.022984905,0.00081058266,0.0044187657,-0.0018131812,0.0018593954,0.0019916233,0.012191942,0.032604717,0.062565275,0.043910276,-0.031168763,0.012807796,-0.026026959,-0.0011220289,-0.030335443,-0.010007707,0.007320958,-0.02043068,-0.02124878,-0.07352998,-0.017434547,0.037229653,0.013079997,-0.008766941,0.025550947,-0.02187038,-0.014684178,0.021304678,-0.027758067,0.018734075,-0.009583994,0.01842437,-0.033987056,-0.025148863,0.0067491517,0.013744916,-0.026198892,-0.001434952,-0.00022633,0.036939465,-0.03147357,0.024529599,-0.031058442,0.00026222316,0.010650576,0.013139437,-0.0037367512,0.024971241,-0.004753872,-0.0038758945,0.007683009,0.0014173533,0.009532024,0.02085894,-0.009302611,0.021705484,-0.013778506,0.0036563086,-0.017993152,-0.04451264,0.050999034,0.011215426,0.035474434,0.032429412,0.030460324,-0.025795631,-0.023880223,-0.030148467,-0.008933386,-0.030688606,-0.02209962,-0.016402403,0.013983203,-0.019845042,-0.014157701,0.040775757,0.0009724358,-0.010064222,-0.036574826,-0.025851998,0.022322934,0.008369289,0.021926058,-0.0005764995,-0.012018586,0.0198562,0.016225602,-0.021030318,-0.016870515,-0.044984996,-0.07310409,0.022306092,-0.015412706,-0.0105398875,0.006997247,-0.0029632817,0.001379999,-0.0054038214,-0.0061594453,-0.0039874674,0.048765134,0.008192606,-0.023259778,-0.019667588,0.0024709594,0.014753283,-0.0013687283,-0.002567522,-0.032247514,0.056194823,0.023484748,0.009422233,0.01699597,0.019818343,0.017051738,-0.02528219,-0.007012189,0.041996308,-0.013598569,0.003208542,-0.01536727,0.021425601,0.047321938,0.05487729,0.01745316,-0.0053871637,0.030941315,-0.0010349898,-0.01787658,-0.0065305955,0.014605657,-0.026412044,-0.0033485107,0.003414621,0.038437217,-0.0039284267,0.025778882,0.007315828,0.0016050945,0.037665047,-0.018974716,0.006362325,0.00805567,-0.017442137,-0.053135034,0.03726995,0.014838984,0.033377457,-0.029895764,0.0069851466,0.035346188,-0.041558076,-0.04230425,0.05348194,-0.022533108,-0.0033933253,0.032567564,0.0020994702,0.055183176,-0.0023375393,0.009626058,0.008747565,-0.016893225,-0.025735695,0.02054109,-0.006647928,0.00780003,-0.010545069,0.020434823,-0.017858805,-0.004559389,-0.04571354,0.036758926,-0.0036382321,0.008531458,0.0123026455,0.029869044,-0.01612412,-0.01212411,0.018952169,0.021056773,-0.016444402,-0.03579193,0.0027450295,-0.02168778,-0.0014023158,0.009042949,-0.019703439,0.009014894,-0.012542053,-0.003582477,0.57318133,-0.0031027657,-0.013744698,-0.034019068,0.00639313,-0.015967177,0.012343676,-0.008962466,-0.028848631,-0.028546672,-0.00045014618,0.0057781683,-0.011496155,-0.00022388811,-0.02368052,-0.019405328,0.030829953,0.0144582875,-0.0064275977,0.022805054,-0.028728545,-0.023028705,-0.05155202,-0.0036293478,-0.020149566,0.0166887,0.02884873,0.021299012,-0.018780882,0.0364361,-0.028830837,0.0066860425,0.0044301082,0.009249094,0.00039838586,0.0037026329,-0.015297888,0.0035080435,-0.009073404,-0.012039886,-0.0025210136,-0.028569546,0.0002678635,-0.015705079,0.008743415,0.040644348,-0.016816141,0.013186344,0.021340754,0.013723544,-0.016519127,0.029227776,0.01797647,-0.00647885,0.008866187,-0.02637539,-0.015101548,0.002648872,-0.006036967,0.008096322,-0.008027847,-0.008591394,-0.04459291,-0.043529388,0.015997197,-0.008370768,-0.034368902,-0.022297088,0.002527394,0.013468776,0.0076101366,0.01528596,0.019102382,-0.02749151,0.009466012,-0.005390122,0.0660544,-0.037319526,-0.012797196,-0.0011945106,0.0016030549,0.008626481,0.0074641034,0.006661069,0.042736422,0.016737409,-0.015245205,-0.010233676,0.006139119,0.047145944,-0.009454054,-0.008019231,0.0006373843,0.053716168,-0.0044701276,-0.017820418,0.036264654,0.02956405,0.00086162734,-0.016460339,-0.0046347273,0.00036732393,0.022411222,0.011528445,0.022649102,-0.018078348,-0.010379539,0.016014194,-6.465884e-05,0.010844828,-0.03306597,0.026766581,-0.045150746,-0.039773285,-0.022638274,0.004153827,-0.048574105,-0.023961393,0.024349779,-0.0012804773,0.033930607,-0.027680773,0.018665504,0.044573132,-0.012049181,0.037799064,0.019078441,-0.050386243,-0.008727531,-0.024944123,-0.007611353,-0.040783368,-0.037229132,0.009166551,0.009216431,-0.0011333114,0.06680629,0.018572992,-0.047817096,0.016538981,-0.026672367,-0.033986483,0.014523136,0.010669626,-0.023458576,0.016847022,0.00010316847,0.0028071275,-0.008591283,-0.017822465,-0.015972188,0.0046595517,0.006438011,-0.018010251,0.0028868932,-0.0033013613,0.004247601,-0.017765738,-0.008802803,-0.015839059,0.019932894,0.01970615,0.054040957,-0.030290313,-0.020429738,0.004522077,0.007726137,0.024426457,-0.022549707,-0.014129789,0.015152783,-0.02317134,-0.013029866,0.057063207,0.008516101,-0.021897566,0.0024167574,0.00664235,0.014301064,-0.04496261,-0.0004537507,0.014201957,0.00092583406,-0.023396222,0.016048005,-0.0059766453,0.0019315595,0.03244023,-0.020635746,-0.030667413,-0.021497164,-0.01001244,0.053333867,0.005531757,0.002220426,0.020501351,-0.0037081225,0.013925581,-0.027093364,0.030647254,0.0017512044,0.01218534,-0.07351095,-0.019544061,-0.03731257,0.04219197,-0.019219792,0.012973138,-0.028414922,-0.021399394,-0.016947908,0.0069144266,-0.039505754,-0.056913342,-0.022146625,0.008300099,-0.00679111,-0.014975441,0.014995372,-0.022811677,-0.03524119,0.011367382,-0.01469032,0.005239235,0.019098163,-0.027872873,0.0018983616,-0.019308453,-0.010995269,0.010991139,-0.005752357,0.003990531,0.04642447,-0.0008055107,-0.0016246375,-0.011999439,-0.024374854,0.006509696,-0.009689294,0.03192983,-0.008194086,-0.028688682,-0.008159369,-0.01663278,0.015483363,-0.00786583,0.001604683,-0.0025028028,0.044583995,-0.028584065,0.013350136,-0.01490404,-0.005565288,0.023649152,0.025876304,0.002370156,0.030393127,-0.012227227,-0.04718138,-0.01612711,0.022822697,-0.024859339,-0.011959154,0.023162702,0.03174487,-0.0033841974,0.03564113,-0.009820065,-0.035548832,0.01090328,-0.008216658,0.0060808943,0.0012314146,0.015314087,0.024478298,0.015156573,0.019020867,0.0073133414,0.03517479,-0.014739921,0.017773269,-0.05603017,-0.035431985,0.0032994282,0.009655588,-0.048770532,-0.00045394446,0.005502618,-0.009288288,-0.014752971,0.020892184,0.0012755902,-0.019935332,0.024496567,-0.00809341,-0.01500759,-0.0076748957,7.184657e-05,0.0028789213,-0.009207791,0.04956478,-0.050457936,-0.00058725406,0.017516803,-0.0091628665,0.04787639,0.018898109,0.025621148,-0.011736344,0.005751374,-0.008064948,-0.030344509,-0.007107051,0.014693758,0.0023579502,-0.03637552,-0.0009408563,-0.01474573,-0.018284312,-0.0028875344,-0.0043632598,-0.010369,0.0021809652,0.015590178,0.48350582,-0.012569357,-0.022647247,0.02064792,0.0050278096,0.022019563,0.035367757,0.03511219,0.0005456674,-0.0076610725,0.036181826,0.009352723,-0.010042959,0.026403217,-0.006695136,-0.035746977,-0.0190866,0.037352737,-0.011659422,-0.018410359,-0.01106692,0.026087696,-0.0034166353,0.0051437463,0.0062516513,-0.032287315,0.019403517,-0.008193781,-0.03824763,-0.021597223,0.030820336,0.0035690118,0.003914854,0.04397935,0.028178155,-0.00507096,-0.015304896]', FALSE), +('15509e99-fa3d-4299-919e-b89caba47ad0', 'fcf52742-dc89-41f9-aade-380d0cf16cd4', 0, '# Restic Backups for Rack7 Every config and document on the cluster is backed up nightly with restic into the minio repo `minio01:/backups/rack7`. @@ -182,8 +182,8 @@ restic into the minio repo `minio01:/backups/rack7`. - Schedule: `17 2 * * *` (02:17 nightly) - Retention: `--keep-daily 7 --keep-weekly 4 --keep-monthly 12` - Prune runs only when the repo grows more than 5 GiB. -- Restores are tested quarterly; the last dry-run passed on 2026-07-01.', '[0.03765728,0.038334936,-0.046351578,-0.018821554,0.013876744,-0.026855422,0.027965782,-0.014691262,0.04054008,0.012495218,-0.032717604,0.020404503,0.029641442,0.056214623,0.0041096895,0.0021433483,0.013298268,-0.011906581,0.017864369,0.0043434827,0.003455686,0.003213155,0.009039273,0.014017991,-0.0013855388,-0.0086553,0.02374252,0.006526439,0.022547401,-0.0033297495,-0.0014988133,-0.0023512796,-0.0014143041,-0.02824291,-0.029488139,0.047540624,0.004302925,-0.014533925,-0.044490933,-0.18681955,0.0060919416,-0.0043272353,-0.0061544185,-0.0134371165,0.016437095,0.010977944,-0.03827594,-0.010501733,-9.103888e-05,-0.017213993,-0.052952036,-0.01814852,-0.01681489,-0.02458776,-2.7317636e-05,0.012569827,0.000104241524,-0.018048206,0.0056127138,-0.02190811,-0.009739061,0.017429793,0.0024581996,0.009735135,-0.018552558,-0.024861272,0.022891004,0.020569192,-0.028005533,-0.022450544,-0.010317253,-0.011980621,0.032764282,-0.011709641,-0.02451471,0.0010362185,-0.0112718195,-0.00058853626,-0.026112061,0.026495771,0.0394547,0.0063182334,-0.004829977,0.023855412,-0.008800795,0.00013841609,0.059158158,0.021808602,0.020425223,-0.011426819,0.0038955163,0.006198093,0.024862297,-0.03499104,-0.009743883,-0.0052778074,0.003565241,-0.015560559,0.018064352,-0.011610284,0.0213316,-0.0020373324,-0.026138688,-0.006718646,-0.02382629,0.03147264,0.01057506,0.015455392,0.030844104,-0.00040196432,0.043418042,-0.011088326,0.01047154,-0.009914557,0.04262582,0.005975505,-0.023595681,-0.027923705,0.023374308,0.014240866,-0.023139395,0.0132414745,0.012261659,0.012351101,0.00759727,0.010675114,-0.042177144,-0.036517777,0.024029138,0.0008647431,0.045483645,0.016919408,0.0154435625,0.02498494,0.024675457,0.01296122,-0.022764636,-0.03074655,0.0023986194,-0.020427136,0.019278254,-0.02343757,-0.0045941286,0.012369004,0.008186335,-0.055012554,-0.010311422,0.026978279,0.0070707668,-0.0077663674,-0.01688138,-0.014623421,0.019912325,-0.0003497982,-0.020409018,0.0112192305,-0.0075505367,-7.7092336e-05,-0.027347475,0.0071208742,0.0047974004,0.004188812,0.0587663,-0.0047277417,-0.038093217,-0.055897597,0.022347312,-0.01999832,0.039594453,1.8601851e-05,-0.014344009,-0.008892714,0.0007157801,-0.0034329656,0.036234237,0.018580243,0.02141671,0.012906504,-0.026177874,-0.035237502,-0.015372497,0.0026581096,0.023054581,-0.0072749895,-0.023454232,-0.0302729,0.015159943,0.018423636,-0.010861041,0.0068292604,0.0036673471,0.00458393,0.020198898,0.020174254,-0.044208188,0.0024966907,0.02225607,-0.008997944,0.00027228252,0.018289514,-0.02137625,0.010978942,-0.01478448,-0.019089295,0.009514669,0.00135242,0.017126871,-0.011768444,-0.0036299368,-0.0071196756,-0.0039458284,0.020854903,0.021303082,0.003383208,-0.018576924,-0.0018162059,-0.0062110205,0.009581173,-0.00065892516,0.013272138,-0.031364355,0.038910545,-2.9999776e-05,0.027097302,0.054840326,0.01879512,-0.012150908,0.03438013,-0.019233715,-0.0253048,-0.017594246,0.029802598,0.030668894,-0.01806078,0.0024415858,-0.039311863,-0.008764049,-0.0018331626,-0.015291746,-0.040220007,0.013071065,-0.0029009392,0.012560927,0.012128048,0.0033504188,0.0018645789,0.030299652,-0.0033257708,-0.017664924,-0.0063763475,0.012735351,-0.014566946,-0.016044049,-0.0027962588,0.008364955,0.043264057,-0.019038687,0.028206872,-0.039978486,0.012162933,-0.014637838,0.007937867,6.3552e-05,-0.0014468052,-0.018153032,-0.012141031,0.018178517,-0.0019470536,0.02165357,0.03599511,-0.047247127,0.014983349,0.007325488,0.007896805,0.0029061085,-0.005892867,0.057212666,0.0003108842,0.0049345368,-0.01017636,0.07777481,0.034172833,-0.013770022,0.0104009025,-0.0067824996,0.02633782,0.051740896,-0.03785145,0.010126083,-0.022845639,-0.018558517,0.028088631,0.022172028,-0.021721382,-0.02802638,0.002701408,0.00631969,0.023341086,0.0050143334,-0.005936248,-0.00649931,0.052228,-0.021869704,-0.026436927,0.010057275,-0.0012251851,-0.021630067,0.014995222,-0.020993529,-0.022995675,-0.008903742,0.014099872,-0.006660026,0.0027657822,-0.013600488,-0.0005519864,0.010083361,0.020859107,-0.028482731,0.02335078,0.005408595,-0.0022911269,-0.010747963,-0.004863878,0.021576466,0.014350394,0.0084841475,-0.011579432,-0.00840245,-0.0029425886,0.046152513,-0.015531281,-0.025707778,0.017022155,0.0010964996,0.0033316745,0.0028006227,0.0022403777,0.0029772378,0.04995672,-0.003450879,-0.0030131864,0.030433359,0.049474545,-0.011020551,0.015403584,0.024361098,-0.0170686,0.020266196,0.015697118,0.016095487,-0.01418157,0.03101593,0.019279985,-0.013348444,0.029173823,-0.022153415,-0.023290837,0.015863357,0.022105897,-0.08664832,0.0052066026,-0.050512716,0.029474081,-0.0005393885,-0.00729477,0.0056055356,0.011223275,-0.009768521,0.0043229507,-0.015051219,-0.008070263,0.022394596,0.004025711,0.04887875,-0.0099531645,0.012347623,0.0074809515,0.0065537007,0.010653278,0.04619585,-0.015801083,0.009214493,-0.006615826,0.0067170607,-0.019312533,-0.025120696,-0.02150698,0.046160415,-0.031438716,0.018355371,-0.010104739,-0.014449082,0.0023550948,0.0034354993,-0.0010040792,0.030163309,-0.009813039,-0.0011970354,-0.007269155,-0.012104716,0.0004576431,-0.027927483,-0.0112438835,0.007711328,0.0072407,0.031491797,0.5855954,-0.011705483,-0.002519899,-0.011646644,-0.009687139,-0.02810026,-0.017851278,0.01355212,0.019514717,-0.017334336,-0.013329,0.007299452,-0.02475016,-0.017193284,-0.029050875,-0.034359504,0.030590521,-0.0040367236,-0.011627629,0.021701759,-0.015861968,-0.011391526,-0.007437609,0.016650945,0.012666709,0.009692704,0.032083657,0.023919018,0.011844009,0.014596554,-0.022851193,0.018428052,0.023641137,0.028354498,-0.012140154,0.00068355975,-0.00017404395,0.015791805,-0.027252026,0.0068805343,-0.03880202,-0.0014438682,-0.030657245,-0.05042099,0.016152978,0.020362753,-0.014427749,0.004222905,0.032379612,0.0024853244,0.002615424,0.00065739296,0.034098763,-0.0077121104,0.028807469,-0.02194229,0.010225607,0.006194918,0.0075616282,0.0018466074,-0.008868893,-0.026673285,-0.054886766,0.0061955983,0.027999042,0.046393447,0.011631195,-0.023513779,-0.005969504,0.018104352,-0.015129896,-0.008477863,0.04764666,0.0015734643,-0.0044234754,0.02583787,0.018319072,-0.02941819,0.008713474,0.0028772072,-0.02296738,0.012660091,-0.008690366,-0.0027357715,0.037066355,-0.0020266322,-0.0049187583,-0.0186728,-0.020976955,0.030019846,-0.03137183,0.0036706415,-0.02170068,-0.020383446,0.012754387,-0.015834805,0.032624964,-0.0123627735,0.016845996,0.0008380769,0.013839545,-0.04854779,0.01801363,-0.011402668,0.014450469,0.011496518,-0.014589295,0.038672924,0.014880273,-0.009208022,-0.020215582,0.012070364,-0.010717352,-0.031394474,-0.029608568,0.0059334827,-0.04941082,-0.027466664,0.0028551635,0.024320604,-0.0067758746,-0.010788156,0.002434749,0.00024038937,0.0038323135,0.028561682,-0.007280207,-0.029512528,-0.019200042,-0.058666863,-0.009878556,-0.004981913,-0.03252119,-0.018675433,0.015794085,0.03382888,0.012079236,0.023161078,-0.055957552,0.029172637,0.017497877,-0.04882249,0.001005362,0.007224797,0.025171544,-0.0041955407,-0.018064879,0.02599292,0.008141949,-0.017698115,0.0009992578,0.018612523,0.029167352,-0.017511941,0.047830738,-0.0016970604,-0.03342456,0.002256909,-0.040389184,-0.015719287,0.017932897,0.025826607,0.02617294,0.026908452,-0.026747135,-0.0068037524,0.010618854,-0.008336077,0.0012758428,0.029316187,0.05570935,-0.0125425095,0.00075464207,0.047359694,0.014396019,-0.0348266,0.020926295,-0.012599121,0.043956477,-0.06318605,-0.027331065,0.010607075,-0.00035537782,-0.0048795515,-0.02423879,-0.016178101,0.02487686,0.009964757,0.01755477,0.019971088,-0.007599422,0.01384676,0.023156084,0.010532891,-0.025033776,0.032559227,-0.012488059,-0.008000905,-0.019233895,0.029618016,-0.042143576,0.034648743,-0.08001428,-0.02396177,-0.009797746,0.04212926,0.016658954,0.010214039,-0.014485462,-0.0069744117,0.010182965,-0.015897593,-0.04123628,-0.013281543,-0.025925653,0.017538581,-0.012467193,0.027845053,-0.0056426544,0.01452097,0.0007248078,0.021703148,0.011604538,0.013004888,0.034938097,-0.041922428,0.00980726,0.00086455996,-0.0016896203,-0.0021795812,0.038065806,0.0126470225,0.027638435,-0.005214372,-0.022019595,-0.03696419,0.00915007,0.016050981,0.0023811671,0.001536581,-0.010900103,0.005151981,-0.014749812,-0.01295987,0.022595461,0.010873864,-0.007490829,-0.0042017633,0.006805323,-0.03668042,-0.007326009,-0.026846098,0.02109508,0.028364006,0.0038840715,-0.018318992,-0.039246906,-0.038382415,-0.039409768,-0.020308152,0.0070075803,-0.05031155,0.008024022,0.046404373,0.022979999,-0.0033566854,0.013772428,0.010950262,0.013604902,0.025332538,-0.032813415,0.01024245,0.013602661,-0.0031171837,0.042402614,0.0107318,0.0012717372,0.025204852,0.06796378,-0.033957534,-0.009672578,-0.018721519,-0.026907887,-0.0070482036,0.0213326,-0.04347461,-0.010013564,0.01074703,-0.015149851,-0.017510485,0.013982754,-0.012870458,-0.0028808555,0.018362237,0.009648416,0.007982275,-0.006894674,0.023263844,-0.003854283,-0.018210435,0.025481757,-0.020052627,0.00837186,-0.023932552,0.026130382,0.052231703,0.0111993365,0.01618058,-0.012195557,-0.005369019,-0.011121801,-0.004454871,0.008454058,-0.0151241,0.007711219,-0.012282985,-0.009167551,-0.045043703,0.0052505317,-0.0066139363,-0.047352266,-0.0054119425,0.01232257,0.00934875,0.48089156,-0.014909598,-0.052889615,0.032320537,-0.008179006,0.027554596,0.006773632,0.006471981,0.0010383184,-0.005128439,-0.0014304385,0.038950432,0.00065187796,0.036422927,0.0013785404,0.012022498,0.020414976,0.043646295,-0.026806211,-0.018933928,-0.01184762,-0.00021922585,-0.035630666,-0.006336042,-0.01070482,0.016082479,-0.0015896712,-0.0039193165,-0.027802156,-0.021763721,0.03351918,-0.009985709,-0.012559996,0.026368473,-0.029675566,-0.030700319,-0.0033530314]', FALSE), -('759eadcb-ae4d-4305-835f-18adca4f886a', '1a49e6a0-dbaf-4210-8266-81aac216fb42', 0, '# Qwen 3.8 on llama.cpp (pbx-node3) +- Restores are tested quarterly; the last dry-run passed on 2026-07-01.', '[0.03761096,0.03845751,-0.046449885,-0.018830845,0.013782317,-0.026799997,0.028011672,-0.014759444,0.04048799,0.0125113735,-0.03270166,0.020369474,0.029716162,0.056183625,0.004086181,0.0021839845,0.013344225,-0.01195098,0.017994436,0.0040622517,0.0033874852,0.0031000057,0.008979001,0.013740418,-0.0012302804,-0.008771627,0.023737019,0.006424138,0.022654146,-0.0033294868,-0.001471167,-0.0022851627,-0.0014323967,-0.028203065,-0.029449882,0.047680467,0.004218848,-0.014611294,-0.044602387,-0.18689843,0.006125248,-0.0042640874,-0.006139907,-0.013590747,0.016424276,0.010886777,-0.038443487,-0.010576557,-0.0001617777,-0.017242054,-0.05279384,-0.018232,-0.016828304,-0.024608774,-0.00017247943,0.012613475,6.3012696e-05,-0.018185548,0.005539525,-0.021843316,-0.009800368,0.017444877,0.0024649997,0.009993986,-0.018718682,-0.024884202,0.022708906,0.020322997,-0.027833063,-0.02249481,-0.010331156,-0.0118693225,0.03266986,-0.011695564,-0.024418436,0.0010251983,-0.011319636,-0.00064347807,-0.026136942,0.026511464,0.039550077,0.006194668,-0.0046101552,0.023782557,-0.008779051,0.0001826582,0.0590264,0.021849662,0.020296859,-0.0113497395,0.0038890531,0.006403461,0.024841694,-0.034869667,-0.009757708,-0.005141348,0.0034396513,-0.015564897,0.017966133,-0.011501329,0.021277754,-0.002021882,-0.02602786,-0.0067004897,-0.023739833,0.031453557,0.010629095,0.01538254,0.030754486,-0.00034020317,0.043437373,-0.010995092,0.0104634045,-0.010023622,0.042626232,0.005970923,-0.023613768,-0.0279048,0.023494547,0.014301009,-0.023132104,0.013174213,0.01227915,0.012290398,0.0076261703,0.010518905,-0.042261988,-0.036460213,0.024107073,0.00078200153,0.04550258,0.016715905,0.01544688,0.024979662,0.024689354,0.013024123,-0.022708489,-0.030681428,0.0024540261,-0.020500494,0.01917579,-0.023505684,-0.0044378205,0.012491571,0.008246802,-0.054716535,-0.010336207,0.026989613,0.0068090567,-0.007533021,-0.01673501,-0.014486656,0.01986469,-0.0002712997,-0.020300835,0.011188979,-0.007495112,-8.997209e-05,-0.027488157,0.0072011873,0.004804083,0.004209749,0.058572426,-0.00479968,-0.038208302,-0.055804368,0.022218633,-0.01997908,0.03951621,5.8289872e-05,-0.014424578,-0.009032474,0.00073962956,-0.0034119012,0.036099695,0.01852639,0.021552198,0.0127526475,-0.026182782,-0.035445888,-0.015438639,0.0027175683,0.023037706,-0.007262907,-0.023425858,-0.030220727,0.015140303,0.01839637,-0.010851032,0.006931794,0.0036087362,0.0046170573,0.020288102,0.02002083,-0.044236816,0.0025493335,0.02237714,-0.008931042,0.0003407974,0.018352956,-0.021331111,0.01104459,-0.014687541,-0.019028705,0.009501976,0.0013785468,0.017071674,-0.011701193,-0.0035739534,-0.0071924054,-0.0039796885,0.020758366,0.021297554,0.0033917052,-0.018489916,-0.0016993035,-0.0063099177,0.009485167,-0.0005972616,0.013237664,-0.031366646,0.038725346,7.4767795e-06,0.027179282,0.0547354,0.018808955,-0.012212906,0.034384247,-0.019159215,-0.025321016,-0.017608784,0.029767074,0.030611057,-0.018098311,0.002305545,-0.03935213,-0.008776912,-0.0018306598,-0.015316228,-0.040251862,0.01285102,-0.0031037815,0.01263414,0.012208599,0.0034347405,0.0018293039,0.030281797,-0.0032900763,-0.017589536,-0.0064115785,0.01272321,-0.014466027,-0.016066836,-0.0028359646,0.008342219,0.043442994,-0.01913109,0.028180197,-0.039988346,0.012164127,-0.014547484,0.007841115,7.388102e-05,-0.0012689865,-0.018071882,-0.012062892,0.018275686,-0.0019326421,0.021694044,0.03596519,-0.047186945,0.015006069,0.0074002277,0.007906536,0.0028772077,-0.0059939907,0.05708875,0.0005403434,0.004942406,-0.01015348,0.07780932,0.034239862,-0.013970348,0.010422828,-0.0069098566,0.026320975,0.05176009,-0.03791575,0.010149191,-0.022847103,-0.018528596,0.028213969,0.022199374,-0.021671794,-0.028123785,0.0027973787,0.0062475232,0.02320845,0.0049380832,-0.0060180086,-0.006489382,0.052308332,-0.021829002,-0.02643016,0.010096845,-0.0012172251,-0.021524867,0.014934391,-0.021118557,-0.022989336,-0.00895462,0.014305431,-0.0067681195,0.0028159288,-0.013418973,-0.00047048926,0.010038304,0.02075569,-0.028722212,0.023547674,0.005346972,-0.0025025706,-0.010678364,-0.0048973276,0.021656714,0.014410446,0.008417302,-0.0115486905,-0.008412803,-0.0028947804,0.046248313,-0.015704505,-0.025723837,0.017168032,0.001019225,0.0030896533,0.0027074066,0.0021803342,0.0029365693,0.049839474,-0.003381094,-0.0031961831,0.030304339,0.04933238,-0.011017354,0.015305227,0.024338406,-0.017067926,0.020326449,0.015574955,0.016124824,-0.014013079,0.031115657,0.019007782,-0.013144482,0.0292043,-0.022218157,-0.02324806,0.015768329,0.022160605,-0.08665321,0.0052939453,-0.050235044,0.029383892,-0.00054845936,-0.0071919668,0.0055732946,0.011285889,-0.009802767,0.0043365476,-0.014954502,-0.008088233,0.022363022,0.0040325704,0.048826504,-0.009965912,0.012336393,0.0075166486,0.006612405,0.01064673,0.045952834,-0.015774496,0.009098071,-0.006717549,0.0067918757,-0.01928878,-0.025228461,-0.021348912,0.046100885,-0.031358406,0.01848588,-0.009997917,-0.014437652,0.0023690509,0.0033770115,-0.0010565926,0.030118087,-0.00967853,-0.0011700417,-0.0072166943,-0.0121249305,0.0004005595,-0.027963022,-0.01125453,0.0077636377,0.007157014,0.031584684,0.5855089,-0.011663495,-0.0024803379,-0.0115528805,-0.009733317,-0.028117035,-0.017949805,0.013395014,0.019470725,-0.017198669,-0.013404827,0.007306281,-0.02482736,-0.01712637,-0.028973598,-0.034433573,0.030809833,-0.0038871488,-0.011646894,0.021613702,-0.015741246,-0.011423277,-0.007403161,0.016648663,0.012707949,0.009709885,0.03213687,0.023952544,0.011794202,0.01467203,-0.022777429,0.018281553,0.023862937,0.028303035,-0.012100144,0.0007123921,-0.0002629568,0.015857702,-0.027246853,0.006870157,-0.038692597,-0.0014972732,-0.030566974,-0.050413456,0.016157929,0.020323312,-0.014329851,0.0040810653,0.032390717,0.0026425903,0.002546029,0.00071060454,0.03411991,-0.007807385,0.028760796,-0.021841938,0.010217062,0.0060451734,0.007598971,0.0018214614,-0.008900798,-0.026798876,-0.05486691,0.0062237177,0.027850548,0.046236355,0.011708924,-0.023480145,-0.0058089364,0.0181067,-0.015160994,-0.0084482115,0.04767011,0.0016210005,-0.004564487,0.025731487,0.01829214,-0.029410899,0.008616046,0.0029778685,-0.022747375,0.012595691,-0.008717069,-0.0027399282,0.0372084,-0.0019402337,-0.0048225424,-0.018705647,-0.020919837,0.03009349,-0.0314376,0.003784681,-0.021762853,-0.020258566,0.012773146,-0.015708165,0.03272881,-0.012274982,0.016842317,0.0009903049,0.013883126,-0.048592817,0.018047998,-0.011396761,0.014007741,0.011222103,-0.0145386085,0.038577247,0.014752438,-0.009243947,-0.020052353,0.0122023625,-0.010715282,-0.031433746,-0.029768387,0.006028392,-0.049416814,-0.027611403,0.0029618572,0.024212373,-0.006705885,-0.01079005,0.0025553394,0.00033352952,0.0038378115,0.028561989,-0.007332827,-0.029395266,-0.01914031,-0.05887388,-0.009600884,-0.0050616795,-0.032508913,-0.018510865,0.015708232,0.033725187,0.012103836,0.0232546,-0.05595556,0.029001812,0.017541206,-0.0489441,0.0010217237,0.0071330625,0.025188176,-0.0041661575,-0.017974136,0.026003418,0.007979857,-0.017892009,0.0010450962,0.018773438,0.029164443,-0.017492268,0.047877554,-0.0016484922,-0.03336197,0.0020111904,-0.04045502,-0.015797758,0.017935364,0.025911553,0.02618955,0.026777921,-0.026671449,-0.006852658,0.010420287,-0.008348874,0.0011636202,0.029316692,0.055606592,-0.012509326,0.0008563652,0.04740409,0.014411872,-0.03487884,0.021067595,-0.0126126325,0.044001736,-0.063164264,-0.027318986,0.010695179,-0.0003432193,-0.005149867,-0.024190817,-0.016059043,0.02489626,0.00994767,0.01742785,0.020036422,-0.007741041,0.0138123445,0.023263868,0.010511255,-0.025223354,0.032618422,-0.012514445,-0.008064137,-0.019283054,0.02968576,-0.042000186,0.034744922,-0.08000382,-0.023906216,-0.009709265,0.042285435,0.016569715,0.010252344,-0.014524059,-0.0071021784,0.010271416,-0.015865302,-0.04126646,-0.013233949,-0.025880793,0.017527044,-0.012520677,0.027858503,-0.0056137885,0.014634232,0.000799132,0.021729574,0.011616285,0.013146522,0.034776356,-0.041942805,0.0097889835,0.0007933133,-0.001745205,-0.0022480197,0.03805336,0.012616475,0.027529,-0.005266023,-0.021932572,-0.03740726,0.009104739,0.01594064,0.0024212725,0.001330272,-0.010808129,0.0050421227,-0.014689926,-0.012917686,0.022572238,0.010961904,-0.0074047623,-0.004245147,0.006762044,-0.03644341,-0.0074358312,-0.026903765,0.021245304,0.028217973,0.0039303284,-0.018399272,-0.03921105,-0.038483188,-0.039419714,-0.020354215,0.007132286,-0.050268583,0.008026643,0.046403915,0.022957828,-0.0032463854,0.013751087,0.0108671505,0.013702765,0.025366062,-0.032861084,0.010498066,0.013494579,-0.0032024528,0.042453922,0.010593409,0.0012229299,0.02512064,0.06790153,-0.033845257,-0.00976506,-0.018716387,-0.027007371,-0.007153931,0.021231068,-0.043418415,-0.009926794,0.01090704,-0.015320391,-0.017510762,0.014164759,-0.01283177,-0.0028165567,0.018318905,0.009508245,0.007911559,-0.006961052,0.02335703,-0.0041041384,-0.018327875,0.025319248,-0.019910257,0.008480754,-0.023945875,0.02610638,0.052361015,0.011040081,0.01621559,-0.012236439,-0.0052494104,-0.01117124,-0.0043942006,0.008391458,-0.015080645,0.007826582,-0.012133952,-0.009219443,-0.04504332,0.00516558,-0.0066840453,-0.047285356,-0.005357862,0.012288057,0.00930124,0.48115137,-0.015110162,-0.053059775,0.032381345,-0.008177271,0.027620994,0.0067838496,0.006392116,0.0010489281,-0.0051077995,-0.0013732259,0.039070234,0.0005995462,0.036348723,0.0014011143,0.011963445,0.020486841,0.043508593,-0.026771178,-0.019147022,-0.011938439,-0.00010539129,-0.035564005,-0.006321061,-0.010632165,0.0161966,-0.0016563269,-0.003962014,-0.027796913,-0.021819497,0.033436473,-0.010124782,-0.012545251,0.026379736,-0.029676761,-0.030830748,-0.003297667]', FALSE), +('61fde899-250f-412c-a82c-2d7cca3fda65', '4b6dcf82-7c3a-46d3-8e17-f27d7b4aebf4', 0, '# Qwen 3.8 on llama.cpp (pbx-node3) The GPU box (`pbx-node3`) serves Qwen 3.8 through llama.cpp. @@ -197,8 +197,8 @@ Working launch line (verified 2026-08): - Context 32768, KV cache q8_0/q8_0, prompt cache persisted to `/srv/llamacpp/cache`. - First-token target is under 400 ms; sustained throughput about 28 tok/s. -- Do not add `-ctk f16` — it doubled VRAM usage with no quality gain.', '[0.004906916,0.006456007,-0.007188873,-0.0009238908,-0.000118610325,0.0041262177,0.034794684,-0.010512289,0.046363723,-0.000606719,-0.0150095485,-0.026422955,0.0041964194,0.015166408,0.022708891,0.0011259858,0.028386818,0.033530004,-0.000272862,0.0044750404,0.011663354,0.02666821,0.008705275,0.005409874,0.009979055,-0.0055155237,0.0023672055,-0.0145913735,0.046046358,0.019987473,-0.0053705187,0.016608628,0.011224631,0.021290213,-0.01004889,0.02518744,0.050391167,-0.02405134,0.00058293005,-0.124092765,0.019886658,0.016166205,-0.012361832,-0.007074048,-0.0053084227,0.026913222,-0.018272342,-0.013375628,0.011152763,-0.028163211,-0.04226925,0.03804625,0.008183197,-0.0020625778,-0.0021609538,-0.015395272,0.03717305,-0.07384544,0.026139624,0.027039947,-0.010458923,-0.01054288,0.011398656,0.041775715,0.016047446,0.0033026396,0.005185916,-0.0854565,-0.016121343,0.01755088,0.038015246,-0.0136615215,-0.011892471,0.014911134,0.013989346,0.001857857,-0.0013364337,-0.009718968,-0.061917517,-0.035037667,0.01738148,0.034213133,-0.006054879,-0.012983797,-0.035788544,-0.056444593,0.040929683,0.011836732,-0.012147225,0.0029661932,0.04569166,-0.018594246,0.0010035214,0.008013796,-0.02038564,-0.00055973046,0.014162515,-0.022119664,0.024522541,-0.0048128483,-0.015942382,-0.0013849332,-0.00053557387,-0.02084357,-0.0213113,-0.009068833,0.009994138,0.026871547,0.005815414,0.01602612,-0.010167179,-0.0166093,-0.009444927,-0.023251846,0.021124298,-0.00674383,0.00017626733,-0.022481265,0.008221531,-0.015372913,-0.058682777,0.0018473851,-0.008022528,-0.028758377,-0.0036108366,0.015999116,-0.0063405107,0.009805578,0.01059827,0.022497129,0.018912321,-0.0034235634,0.005937667,0.021929383,0.0018963979,0.011462979,-0.044259317,-0.045317836,0.027230069,-0.030520419,0.016192254,-0.006736952,0.011794924,-0.009808151,0.011587667,-0.027761698,-0.01532937,0.003698323,-0.06661423,0.002715809,0.0145380115,-0.010483502,0.019942906,-0.005093028,0.0051621944,-0.029352557,0.009237764,0.0030812006,0.0052176192,0.03641057,0.03355945,-0.011518262,-0.0135680055,0.01995512,0.00012003938,0.0064016315,-0.004795992,0.017724808,0.026190061,-0.0044881916,0.010012445,0.037060056,0.0015625969,-0.040955182,0.009580645,0.03476194,0.015384619,-0.041615143,0.014988771,0.0036535633,0.00096626824,0.0049872333,0.041760735,-0.012362016,-0.046003405,-0.018122712,-0.025817504,-0.031042315,0.009928874,-0.013900935,0.013260691,-0.012305741,-0.031081388,-0.013502392,-0.015836122,-0.02869068,-0.001068602,-0.015398816,-0.0332364,0.0403027,-0.0062508886,0.005213992,-0.008876871,0.029584307,0.009084996,0.03537422,-0.012121749,0.0009489513,-0.0003954117,0.0070037046,0.020933313,0.0136353085,0.006504187,-0.031617552,0.002992829,-0.0046658325,0.031578876,-0.01679145,-0.022937503,-0.0036870195,-0.022594174,0.0059498455,-0.0056773047,-0.0025736475,0.052146327,0.00893569,-0.010093247,0.027326731,-0.013973046,-0.03487444,-0.0028272849,-0.03416152,0.0012509516,-0.02789542,-0.013947732,-0.114633806,0.014450892,-0.009747401,0.018876895,-9.051834e-05,0.026701575,-0.0025911883,-0.014321548,0.018565923,0.00718232,-0.0052945935,0.006750384,0.02041332,-0.043238882,0.0012280531,0.00025791503,-0.03763744,0.026372131,-0.02456068,0.02276994,-0.00022850574,-0.013499884,-0.03020725,-0.0007399498,0.010689958,-0.015193351,-0.01644527,-0.016917335,0.0050319163,0.016723877,-0.0054169255,0.024451993,0.006997635,0.02882798,0.032276236,-0.06018527,0.033994816,-0.010552437,-0.0083699925,-0.03586196,0.0021914735,0.029977042,0.0018355238,0.007109248,-0.03225808,-0.009911548,-0.053890295,-0.009326061,-0.03306934,-0.023082633,-0.01059039,0.029539904,-0.013281995,0.013006165,0.009475907,-0.022196928,0.049717486,0.014863898,0.009604583,-0.03591369,-0.012478122,0.004377259,-0.0061541484,0.020670006,0.01373235,-0.016809946,0.010334824,0.014988906,0.0040431796,-0.041119885,0.0127653135,0.0051707076,0.014419583,0.00650896,-0.010287407,-0.008795972,0.0018323881,0.010770522,0.0049167215,0.00936526,-0.02508502,0.0072778235,0.04563838,-0.013496911,-0.0173033,-0.015317526,-0.007889284,0.03175395,0.002003391,-0.0021924663,0.030394852,-0.00069624675,0.020704044,0.027886068,0.02762333,0.014035284,0.01654181,-0.03899793,0.02087429,0.016071657,-0.0062754885,-0.021844983,0.028700225,0.03766843,-0.046431784,0.012101235,0.017013961,0.02747407,-0.007655756,-0.013407637,-0.033471037,-0.014546696,0.028630817,0.008982008,0.026006,0.01677444,-0.03471433,-0.0022379234,0.0055728946,-0.0077810143,0.0013937261,0.017746871,0.021605,0.040962756,0.0070188814,-0.009967747,0.01273787,-0.033534065,-0.005305199,-0.035510264,-0.039379925,0.026744207,-0.008604813,-0.0039082207,-0.0044822954,-0.0088469675,-0.028601604,-8.0912294e-05,0.004470854,-0.020053793,0.03998865,-0.0141425645,0.007797722,-0.0102526555,-0.017722767,0.0686069,-0.009135741,-0.016234249,-0.021621566,0.014535573,0.015754718,0.014164537,-0.019203294,-0.012466663,0.034600537,0.0068200435,-0.03419361,0.0079800775,-0.018447468,-0.031217193,-0.0072390707,0.020659447,-0.008191455,-0.014997229,-0.002165527,-0.0076478436,-0.024402162,-0.023636317,0.02932148,-0.009688069,0.0011310569,0.021694932,0.5715141,-0.0038225024,-0.0016790443,0.021320958,-0.014914242,-0.023419889,0.008144954,0.048406098,0.013591611,-0.012175149,0.008054675,0.0512858,0.0008912528,0.040094815,-0.009734309,-0.012076115,-0.025351707,0.012837881,-0.042196188,-0.03161997,-0.0379239,-0.0005819548,-0.032063887,-0.0005143354,0.03740754,0.02488251,0.024509883,-0.0072609554,-0.0022386592,0.04750714,-0.014558142,-0.0040288162,0.018878523,0.03327417,-0.004163784,-0.017925752,-0.012998249,0.020356508,0.023633389,-0.018931953,-0.027854599,0.006231788,-0.01153814,0.020663124,-0.0017388125,0.067935705,0.029296348,-0.007780076,0.016128931,0.011073908,-0.049259424,0.01269973,0.023268139,0.016713535,-0.015788209,-0.012492067,-0.015602908,0.008636031,-0.00024210043,-0.007579757,-0.031625237,-0.010611543,-0.030509407,-0.020369291,0.029296048,0.006953915,0.0014250218,0.014339599,0.004191337,0.0067475755,-0.014606891,0.019333253,0.010553306,0.0047668894,0.004948881,-0.021775356,-0.023305146,0.02372491,-0.028523874,-0.015274663,0.010051219,-0.007282009,-0.010038693,-0.032733105,-0.0020153876,-0.009332712,0.025308426,-0.0018304805,0.017502509,0.023978205,-0.0032532897,-0.036123194,-0.0053018923,0.06226937,-0.0024003258,-0.018078387,0.002183948,-0.040361945,-0.028334303,-0.018242473,0.0006282814,0.010286075,-0.0062547335,-0.012389634,0.01196164,0.01271925,0.0103054745,-0.012717946,-0.050431717,-0.038263008,0.011334124,0.003481882,0.010076823,-0.00904898,-0.025005259,-0.008100363,-0.0038964872,0.024797121,0.0044426275,0.019206045,0.0032552343,-0.0588058,0.0055935197,0.06473668,0.015533918,0.04567951,0.024283042,-0.032287795,-0.005871,0.0030397933,0.0024484268,-0.052880578,0.0044671185,-0.0021110012,-0.03313513,0.006123912,0.009387669,0.035467263,-0.0075831297,-0.010714004,0.0027596888,-0.054168034,2.0219544e-05,0.015232016,0.0016128462,0.02699787,0.0154918665,0.0077593937,-0.029063925,-0.009622622,-0.020289576,0.0052438932,-0.017425237,0.0008231685,-0.022507673,-0.01075652,-0.016170673,-0.0016022251,-0.02438394,-0.04417805,-0.039179347,0.008670499,0.06376034,0.040335864,0.027618492,0.009137288,-0.029111417,0.028033702,-0.00343559,0.0040178667,0.031317253,-0.064813405,0.01373273,-0.020199584,-0.015780427,-0.04338776,-0.020906894,0.012748846,0.015861813,0.0036612353,0.032253806,0.028695056,0.029775364,-0.012596399,0.01709375,0.008642521,0.011716538,0.027455112,0.01852999,0.031462297,0.010621819,0.004659449,0.027314827,0.018217443,-0.012133268,0.012184392,-0.010667446,-0.010947791,-0.02931279,0.017184244,0.0018991032,-0.008176583,-0.040296547,-0.007439916,-0.014969276,0.024563216,-0.0028976062,-0.005789192,-0.017361993,-0.0038953219,-0.0126501275,0.017369047,-0.045210723,0.018072907,-0.02953176,-0.009939644,0.0016326611,-0.06367325,-0.0047002463,0.015472689,0.021914592,0.029067505,0.013700611,-0.008019365,-0.026344938,-0.004926492,0.0069742217,0.0091774,0.03467588,0.010049456,-0.027063357,0.0018104803,-0.027776,-0.04164002,-0.039374765,-0.03470609,-0.029873682,0.015378697,-0.022916965,0.016020175,0.041835174,-0.023922656,-0.00973589,-0.03584754,0.0023531509,-0.00071640615,-0.022200791,-0.0116852205,0.050996486,-0.0067681023,0.024496153,-0.031229932,0.012020029,0.01887921,0.022653103,-0.020136286,0.026000703,-0.012733552,-0.051865578,0.009926211,0.025864415,-0.024490546,-0.045287423,0.03526147,0.0324647,0.023480445,-0.0059160828,0.0004954989,0.043922395,0.0012934246,-0.00218072,0.0017724655,0.0049270755,-0.00024383095,0.008791398,0.025928196,0.035438117,0.012793123,0.07458995,0.00688506,0.019181672,-0.01695006,-0.0146594765,-0.02098825,-0.0076851225,0.006612468,0.02737507,-0.004219136,0.011097702,-0.0152805885,0.09384753,-0.032352034,-0.013140767,0.031971924,-0.0051288432,-0.01625363,0.0031787823,-0.028409956,-0.003950356,-0.01789569,-0.00017963733,0.007979669,0.004672666,-0.002137598,-0.010888451,0.03931264,0.041141845,0.0018557319,0.012479248,0.019799987,-0.020040277,0.016263643,-0.062916495,0.019419322,-0.0011392571,-0.024135968,0.017182056,-0.006468974,0.0006467475,0.0020119678,-0.012808353,-0.020771088,0.0009864348,-0.0048578945,0.47838664,-0.054653436,0.006858096,0.005452959,-0.012132784,0.013347625,0.0021665914,0.02250279,0.017631315,-0.00892203,-0.044655927,-0.0035942886,-0.0150718,0.03142226,0.012312508,-0.031123387,0.018857885,-0.0020205963,-0.0041906005,0.009723671,-0.0053943996,0.008282193,-0.010327774,-0.030658191,-0.020753093,-0.028013792,0.04665689,0.008744662,0.016389638,0.051546767,0.020381214,-0.002605621,-0.0061373413,-0.024535293,0.027562302,-0.051640674,-0.024688333]', FALSE), -('015b7e8e-5d7f-4348-9502-2809a4b102f5', 'ba951767-d744-4e62-bd19-2d947090e58e', 0, '# Uptime Kuma (AIO) +- Do not add `-ctk f16` — it doubled VRAM usage with no quality gain.', '[0.0048955665,0.006563279,-0.007181519,-0.00087667035,-0.0001494127,0.0041002524,0.03486239,-0.010442664,0.04654968,-0.00062298507,-0.014839416,-0.026391102,0.0042345123,0.015116379,0.022619056,0.0009777708,0.028190494,0.03359672,-7.516318e-05,0.0044173757,0.011689127,0.026692953,0.008683573,0.0054613664,0.009840982,-0.00543539,0.002404915,-0.014604245,0.046088442,0.01996778,-0.0053201355,0.016592607,0.011093611,0.021259272,-0.0100452,0.025072746,0.050448727,-0.024068335,0.0005037724,-0.123938255,0.019930616,0.016123548,-0.012261513,-0.007168551,-0.0052392534,0.026928259,-0.01830889,-0.013332525,0.01113737,-0.028172234,-0.04211392,0.038134392,0.008170393,-0.0019649295,-0.0021533538,-0.015615097,0.03715273,-0.07404866,0.026345331,0.027053162,-0.0103950715,-0.010379613,0.01142355,0.04201101,0.016158048,0.0032359394,0.0051045036,-0.08533491,-0.016216135,0.01755373,0.037980907,-0.013595591,-0.011804061,0.014737857,0.013910744,0.0017709701,-0.0014323548,-0.0099557135,-0.061918937,-0.03499627,0.017452922,0.03419971,-0.005971018,-0.012934874,-0.03589443,-0.056448344,0.040954806,0.01189212,-0.012140317,0.0029239808,0.04582019,-0.018504582,0.0010148176,0.007992842,-0.020338904,-0.00057266373,0.01419736,-0.022202691,0.024471182,-0.0047688927,-0.015945453,-0.001319592,-0.0004144271,-0.020826649,-0.02122115,-0.009065083,0.009961357,0.026947564,0.005761857,0.016008656,-0.010263227,-0.016692484,-0.009426757,-0.023408469,0.020954221,-0.006834443,0.00015946043,-0.022398654,0.00811421,-0.015216082,-0.058738913,0.0019529116,-0.008094718,-0.028743854,-0.003629762,0.01607263,-0.006443377,0.010046084,0.010847033,0.022551531,0.01890359,-0.0033641905,0.005947516,0.022039598,0.0018877899,0.011570262,-0.04431249,-0.045278054,0.027297659,-0.030513378,0.016191365,-0.0068402844,0.011648631,-0.009792368,0.011688657,-0.027694903,-0.015376687,0.0036972517,-0.06667251,0.00279258,0.014512869,-0.010571717,0.019950487,-0.00510253,0.0051548383,-0.029431343,0.009242776,0.0029232616,0.0051241084,0.036594886,0.033472266,-0.011438357,-0.013577757,0.020002194,6.408319e-05,0.006390332,-0.0047882586,0.01786309,0.02623147,-0.004530237,0.010013077,0.037040416,0.0015922404,-0.04090499,0.009565517,0.034942735,0.015371917,-0.041636758,0.015157398,0.0035107376,0.0010699562,0.0049742297,0.041617032,-0.0122791175,-0.046042874,-0.018129803,-0.025809463,-0.031154087,0.009840487,-0.013892415,0.013241036,-0.0122767305,-0.031039575,-0.013409763,-0.015825685,-0.028761823,-0.0010036855,-0.015593802,-0.033522908,0.040459964,-0.006285017,0.0052159233,-0.008955376,0.02954908,0.009136782,0.035330363,-0.011976576,0.00085887464,-0.00058417296,0.006988886,0.020804772,0.013841128,0.0065909354,-0.031734783,0.003101882,-0.004596761,0.03152351,-0.01682704,-0.02279396,-0.0036088282,-0.022577504,0.0059727123,-0.0055290004,-0.002467074,0.052295998,0.009077599,-0.010048351,0.027179865,-0.013771848,-0.034916595,-0.0028860553,-0.03409196,0.0013420157,-0.028017856,-0.0139933545,-0.11455335,0.014553773,-0.009769525,0.018900985,-0.00018000547,0.02674599,-0.0026077859,-0.014292218,0.018549707,0.007176582,-0.00524445,0.0067480793,0.020493506,-0.04315773,0.0012774172,0.00016863417,-0.037763555,0.026371205,-0.024641762,0.022864973,-0.00026404535,-0.013460384,-0.030186862,-0.0006765427,0.010698746,-0.0151727395,-0.016492585,-0.016962964,0.0050722617,0.016741289,-0.005336824,0.024588695,0.007054809,0.028952006,0.032294203,-0.06015683,0.03386316,-0.010625896,-0.008354121,-0.03576819,0.0022805019,0.03007562,0.0017865172,0.006932809,-0.03219113,-0.009874747,-0.05370649,-0.0093274005,-0.033166938,-0.023110712,-0.0106076915,0.02953865,-0.013281908,0.012887829,0.009703634,-0.0223772,0.049679916,0.014743398,0.009506544,-0.035854444,-0.012600612,0.004360275,-0.0060984665,0.020735657,0.013778773,-0.016842883,0.010328998,0.014963391,0.004014235,-0.040971603,0.012713414,0.005264425,0.014449455,0.006594772,-0.010362017,-0.008873379,0.001828986,0.010795534,0.0047058337,0.009414113,-0.025088096,0.0072225197,0.04566632,-0.0134786675,-0.01717815,-0.015313934,-0.007852127,0.03187852,0.0019300855,-0.0022634403,0.030619174,-0.00076825224,0.020829678,0.027920464,0.027656483,0.0140968,0.016732715,-0.039085284,0.021018768,0.016044175,-0.006376451,-0.02166852,0.028667368,0.037805025,-0.046582635,0.012060221,0.017108422,0.027381478,-0.0077456515,-0.013147688,-0.03350115,-0.014701538,0.028656946,0.008880647,0.025945505,0.016721597,-0.034841914,-0.002273065,0.005764383,-0.007783028,0.0013623552,0.017819587,0.021723904,0.0408222,0.0071258824,-0.010116982,0.012574956,-0.03361452,-0.00546187,-0.035441924,-0.039337095,0.026577402,-0.008789138,-0.0038227334,-0.0045391712,-0.008881999,-0.028659098,-3.4475514e-05,0.0044263904,-0.020026846,0.039916992,-0.0142340455,0.007635163,-0.010122548,-0.017784249,0.06864606,-0.009090166,-0.01620657,-0.021489706,0.014632963,0.015785113,0.014206889,-0.019216552,-0.0127429785,0.03449181,0.006859396,-0.034090742,0.007956646,-0.018511686,-0.031121837,-0.0073925373,0.020612573,-0.008365267,-0.014917866,-0.0021894078,-0.007577364,-0.024495602,-0.023563232,0.029264096,-0.009388129,0.0011837069,0.021435851,0.57144135,-0.0037046075,-0.001773113,0.021439508,-0.014951692,-0.023537952,0.007987271,0.04831866,0.013583593,-0.0124364905,0.0082983915,0.05114194,0.0010810867,0.040192854,-0.009587394,-0.012170911,-0.02550272,0.012826917,-0.04221689,-0.031700388,-0.038051162,-0.0004655864,-0.03210607,-0.0006102221,0.03724679,0.024805026,0.024591077,-0.007270306,-0.002306738,0.047509495,-0.014774238,-0.004008489,0.018793672,0.0333338,-0.0041552726,-0.018083137,-0.013136035,0.02026712,0.023770852,-0.018911786,-0.027863882,0.0063823643,-0.011584553,0.020738417,-0.0016652654,0.067888916,0.029373188,-0.007804247,0.01616861,0.01121884,-0.049425237,0.012759104,0.023202403,0.016641427,-0.015898827,-0.01252207,-0.015623935,0.008582678,-0.00021590645,-0.007648496,-0.031614356,-0.010606006,-0.03037187,-0.020407232,0.029394396,0.0069672726,0.001531273,0.014256806,0.0040911012,0.0067095845,-0.01460142,0.019386606,0.010528491,0.004631454,0.004955289,-0.02191551,-0.023333808,0.02373839,-0.028561832,-0.01531578,0.010180001,-0.00722261,-0.009954034,-0.032802477,-0.0020075992,-0.00926649,0.025394544,-0.0018348336,0.017538806,0.024107987,-0.003267733,-0.03632002,-0.0053524547,0.06220804,-0.0022804793,-0.018087465,0.0023589076,-0.040393636,-0.02822909,-0.018264702,0.0007215282,0.010310542,-0.0064700684,-0.012421958,0.011986024,0.012995388,0.010285612,-0.012728171,-0.050489485,-0.038417924,0.011247069,0.0034419291,0.010115315,-0.008901029,-0.025021082,-0.00806476,-0.003912413,0.024752563,0.004432443,0.019115897,0.0034502454,-0.058956992,0.005726109,0.06468911,0.015542277,0.045765214,0.024322657,-0.03229392,-0.0058277035,0.0029522167,0.0025867443,-0.052941095,0.004423396,-0.0023595837,-0.03322513,0.006161832,0.009435967,0.03544027,-0.0077349064,-0.01067597,0.002711163,-0.05410377,8.08606e-05,0.0153931575,0.0016065796,0.02697184,0.01550072,0.00765706,-0.029126698,-0.009670663,-0.020443369,0.005098447,-0.017370118,0.0008378353,-0.022455212,-0.010795827,-0.016161384,-0.0016622384,-0.024454175,-0.044175886,-0.039290726,0.008705461,0.06391128,0.040215548,0.02761868,0.009053639,-0.028968822,0.028128045,-0.003375794,0.003947923,0.031200934,-0.06509073,0.013665974,-0.020265613,-0.015686924,-0.043482274,-0.020814456,0.012766796,0.015871856,0.0036653874,0.032306027,0.028730683,0.029821305,-0.012606251,0.016982393,0.008684355,0.011789271,0.027443055,0.018474393,0.031362277,0.010853364,0.0044677523,0.027239934,0.018246273,-0.01210991,0.011967698,-0.010611614,-0.010974056,-0.029387115,0.01715941,0.0019640236,-0.008255181,-0.04020242,-0.0074104,-0.0150027275,0.024445185,-0.0030437366,-0.006090408,-0.017326897,-0.0039222953,-0.012711753,0.017433658,-0.045412827,0.018128024,-0.029545136,-0.010056471,0.0016170235,-0.06369609,-0.0047071553,0.015662374,0.021821082,0.028972296,0.013798789,-0.008147158,-0.026444456,-0.0051401877,0.0069444994,0.009159432,0.03477793,0.010061883,-0.026974952,0.0018418212,-0.027717479,-0.0414512,-0.039439455,-0.034816936,-0.029772568,0.015421248,-0.022874575,0.015967619,0.041933674,-0.023928823,-0.009823949,-0.03581972,0.0024727695,-0.00070218724,-0.022126721,-0.011641544,0.051030524,-0.0067454507,0.02464649,-0.031181823,0.011941172,0.01865798,0.0227618,-0.020178411,0.026008785,-0.01254727,-0.05196905,0.009817773,0.026001647,-0.024467824,-0.045146246,0.03524986,0.032573443,0.023551475,-0.005872927,0.00051757006,0.04396492,0.0011700821,-0.002264675,0.0018914959,0.005025549,6.283882e-06,0.008800582,0.025943873,0.035621837,0.012905208,0.074440226,0.0069493204,0.01927294,-0.01680089,-0.014648021,-0.020952731,-0.0075518047,0.0065574157,0.027262304,-0.0042370157,0.011130277,-0.015295326,0.09357574,-0.03247258,-0.013211409,0.032024637,-0.005070768,-0.016264796,0.003244228,-0.028404089,-0.003958001,-0.017947234,-0.00014456721,0.007935018,0.0046610995,-0.002032552,-0.011023005,0.039254826,0.041368302,0.0017380503,0.012548076,0.01972549,-0.020155054,0.016230797,-0.0627952,0.019442117,-0.0011268728,-0.024063507,0.017257866,-0.0064519406,0.00055164465,0.0019923886,-0.01294924,-0.020687189,0.00090918806,-0.004862219,0.47812584,-0.054451454,0.00702057,0.0053896084,-0.012131436,0.013340367,0.0021163258,0.022390652,0.017757623,-0.009057089,-0.044811495,-0.0037117407,-0.015191798,0.031603582,0.012217852,-0.03098824,0.018898344,-0.0020048036,-0.004285091,0.009732921,-0.0054604798,0.008219087,-0.010395033,-0.030771334,-0.020701116,-0.02800187,0.046767753,0.008777982,0.01642557,0.051535062,0.020504575,-0.0027047421,-0.0060711633,-0.024570538,0.02776279,-0.051648192,-0.024737744]', FALSE), +('7ad73e21-f953-495f-b514-4ecb803d17f5', '92d409ac-611d-4dc9-bcdb-c72c2c9a8235', 0, '# Uptime Kuma (AIO) Uptime Kuma runs on `rack7-pbx2` as the AIO image, listening on **18443** with TLS terminated by `caddy-rack7`. @@ -208,8 +208,8 @@ Uptime Kuma runs on `rack7-pbx2` as the AIO image, listening on - History is kept for 30 days; the database is backed up hourly to `/opt/kuma/backup/`. - Lab-iot monitors watch every VLAN 130 device; the shelf watchdog - restarts the container after 3 missed checks.', '[0.038106993,0.018634057,-0.008217301,-0.013628453,0.027404172,-0.03877489,0.0311994,0.02465902,0.0024459895,-0.021807522,-0.038296077,0.024558239,-0.0023262263,0.011050194,0.030026698,0.017113961,-0.0033969188,0.004039134,0.009008223,-0.051173065,0.02592171,-0.003917853,-0.01238643,-0.024053885,-0.00024851924,0.032166224,0.014062944,-0.015169448,-0.005225658,-0.0042016893,-0.01123889,0.0004635895,-0.032697055,-0.017006787,-0.007335916,0.02580753,0.03261475,-0.012524324,-0.05857942,-0.13035922,0.028068826,-0.0004909649,0.008304582,0.00028216033,0.0018224564,0.022837613,-0.046190467,0.0123833185,-0.026489494,-0.0018505072,-0.028140096,-0.0020819786,0.011087048,-0.005066901,-0.0014365623,-0.0036917927,-0.023711855,-0.03588957,0.015389655,-0.017935254,0.0026078268,0.046831407,0.019794863,0.015847689,-0.01030789,-0.008568072,0.009648942,0.011848424,-0.017210871,0.012200696,0.021016102,-0.028046189,0.0010585381,-0.013444968,-0.012422198,-0.017197818,0.005396348,-0.011171766,-0.025050566,-0.017291093,0.047166303,0.0138644725,0.030163022,-0.033566613,0.00094499375,-0.008609101,0.0029411141,0.009440743,0.033943594,0.013803009,0.010265881,0.014866854,-0.00937658,-0.012975772,-0.0067992844,0.024789833,0.025133912,-0.038422156,0.0280066,-0.03585288,0.014918304,0.0065574846,0.017106203,-0.007814379,-0.0039850557,0.020553024,0.012011159,-0.02346571,-0.0051349793,0.007332733,0.033706095,0.00079152815,-0.013978022,-0.02445136,0.022460915,-0.0032079138,-0.024291102,-0.02661807,0.0059516635,0.0069870083,0.02042671,0.05334843,-0.005034359,-0.00956489,-0.003907171,0.002769809,-0.040247224,-0.074619494,-0.0015881418,0.02868011,0.046569843,0.012639592,0.0066075497,0.029720109,0.04744354,0.037766926,-0.005725736,-0.029371332,0.017992377,-0.079285465,0.01878195,-0.056645542,-0.007868872,-0.00208263,-0.012480636,-0.034866847,-0.0021931652,0.011911377,0.019486906,0.021688007,-0.030558916,-0.016631396,0.0072167427,0.018208137,-0.030761741,0.059866942,0.016288664,-0.009572141,-0.029575933,0.058776673,-0.017822789,0.016467273,0.034793973,0.024051145,-0.0079384,-0.039598923,0.010722575,-0.013311449,0.024028081,-0.029127918,-0.014751515,0.049159955,0.022571053,-0.015190259,0.030467868,0.022814635,0.056081712,-0.002271787,0.009412051,-0.008253789,-0.0097564645,-0.01146133,0.034274988,-0.0067151655,-0.03362125,-0.01903806,-0.028452013,-0.02260626,0.013794295,0.0010383719,-0.0116121,-0.0004088451,0.02212328,-0.01430799,-0.03320451,0.013438234,-0.0078630075,-0.04343393,0.050368387,-0.020127948,0.0013781807,-0.005974917,-0.031138388,-0.00606929,0.0017739536,0.029510228,0.00012915762,0.011622922,-0.015467424,-0.012347629,-0.0010018977,0.0708217,0.032622647,-0.033221178,-0.016068736,-0.018218724,0.0075088814,-0.008474871,0.012021168,0.01394918,0.010850891,0.0097159315,0.0010421303,-0.0077235103,0.0018145508,0.02441232,-0.02752355,0.02108498,-0.014134946,-0.019849766,-0.013992006,0.022705596,0.0019783624,0.024398847,0.0226116,-0.039615802,0.002247258,0.010758826,0.021020792,0.031348504,-0.0019311132,0.007912154,0.023376185,0.013901945,-0.004031933,0.00059725385,0.030810928,-0.022914764,-0.046300393,-0.0007079661,0.0018089672,-0.01343276,-0.023964891,-0.023551881,0.04084971,-0.0050511155,-0.034382816,0.007039022,-0.016816135,0.0011618218,-0.019647088,-0.02235321,-0.018496986,0.004418245,0.002015185,0.022616263,0.019669777,0.018710969,0.020171262,0.0065068775,-0.0094575165,0.028560799,0.026498334,-0.018881548,0.01779934,-0.02339102,0.03274112,0.009155856,0.03688592,0.0124018015,0.03710459,-0.014070767,-0.021527326,-0.029835764,-0.031258795,0.015677053,0.010864739,-0.027017793,0.00490162,-0.009072934,0.014188736,0.012163424,-0.010023558,0.027260082,0.0048967893,0.002274717,-0.006461237,-0.003600291,0.025682367,-0.014112495,-0.019908749,0.040578227,-0.007009708,-0.0146240145,-0.018676424,0.0013891037,-0.014643823,-0.021128826,0.00072751916,-0.03504254,0.004105655,0.026931705,-0.007704711,-0.017538644,-0.020946752,-0.0066418652,0.0510803,0.023047732,-0.011718495,-0.013410385,-0.01014068,0.014250093,0.037130404,0.0063563436,0.0067466605,0.025111927,0.023752602,0.0057971505,-0.006658047,0.024181658,0.033148665,-0.06762279,-0.0027979522,0.023139752,-0.00017422637,-0.0010298613,-0.019527595,0.021093024,0.018087953,-0.00981142,0.021309,0.016757656,-0.00061614806,0.043578032,-0.0046561025,-0.005860671,0.00028400763,-0.005222961,0.0066212034,0.007465418,0.04327694,-0.018172314,0.013598587,0.04156662,-0.008201251,0.04050671,-0.0011988352,-0.03066368,-0.02395785,0.02446814,-0.0899048,0.008258288,-0.08213539,-0.011409116,-0.021910544,-0.00015463975,0.02213294,-0.012563223,-0.034270573,0.012813273,0.008907631,-0.017326519,0.024288397,-0.01439124,-0.008639811,-0.0030159368,-0.0046915947,0.01408972,-0.027495638,0.028871214,0.041411635,-0.008411061,-0.010576511,-0.019422347,0.0358918,-0.0035719725,-0.020388497,-0.034472022,-0.030834252,0.008924118,0.01758393,0.015400725,0.0022139917,-0.016835183,0.015300206,-0.016525537,0.0035283864,0.0077783214,0.008913325,-0.0058559845,-0.0035000376,0.005387798,-0.009809309,-0.025696075,0.015413322,-0.025870109,0.032177497,0.5819093,-0.0010938785,0.008852723,-0.040263142,0.017681291,-0.033451736,0.007959036,0.0011315593,0.017408729,0.0009294567,-0.0036882618,0.015779817,-0.013982584,0.001050123,-0.0034167818,-0.035604995,-0.014674744,0.04562627,-0.029904135,0.019736819,-0.05951159,-0.017620193,-0.057386372,-0.011835631,0.0043453015,0.024171991,0.008909662,-0.006362779,0.0060504084,0.0353629,-0.016277611,-0.015112068,-0.016833913,0.01615376,-0.0382774,-0.006274632,-0.012079389,-0.012810141,-0.03127888,0.018849475,-0.03153307,-0.0051420406,0.0475348,-0.018489812,0.027892888,-0.01578764,0.030782497,-0.015763914,0.0057280236,-0.031014316,-0.0040112175,-0.017352164,0.003295842,-0.012723419,-0.00018768809,-0.0036262327,0.021889303,-8.784531e-05,-0.015115497,-0.01563112,-0.012527264,-0.020078734,-0.07101837,-0.0012737609,0.019103205,0.021137692,-0.008841041,0.002043895,0.040060893,0.009378264,0.003924061,-0.008163346,0.008578758,-0.011697094,-0.0020075962,0.03814163,0.012349691,-0.016639309,0.0018154195,-0.012198164,-0.016159683,0.008949393,0.011084626,-0.015990986,-0.0027949982,-0.007574095,0.0025306214,-0.0047155516,-0.0146174785,0.013264729,0.0034637377,0.0052147587,-0.006747056,0.035153598,-0.007931121,-0.015750369,0.037277468,0.024675064,-0.035585944,0.014082659,0.0027795336,-0.037926342,-0.0032182198,-0.028705005,0.023173422,-0.02909124,-0.020845013,0.019356843,-0.04316178,0.009516027,-0.034976315,-0.013217918,0.007926913,-0.03198133,-0.014095516,0.022814764,-0.014098736,0.0071098562,0.03146074,-0.015683679,-0.0463617,-0.013456991,0.036812555,0.04712305,0.018903363,0.029695055,0.032387566,-0.04042986,-0.0016993514,-0.03639544,0.019010685,-0.023286201,-0.036577202,0.020009337,0.009996621,0.0032217293,0.0022028838,0.035814818,-0.02660499,0.00750493,-0.03340861,-0.02957968,-0.008230315,0.0024217288,-0.0015929361,0.009152928,-0.0073593175,-0.00852812,-0.02739346,0.038931727,-0.007273162,0.024249732,0.006339556,-0.02318446,0.0041034175,0.024684688,-0.013802073,0.0027833842,-0.015338234,-0.06604368,-0.019109128,0.01648798,0.032982398,0.004132715,0.004773943,-0.0073432242,0.004852035,0.039872233,-0.00349147,0.028956523,0.02584804,-0.040897533,-0.0043680193,-0.0088932,-0.009021032,-0.047275838,0.012099508,-0.0088350875,0.034433976,-0.05199298,-0.007571556,-0.007182572,-0.02005062,-0.028842174,0.002793788,-0.024149403,0.031235987,0.02877264,-0.008673858,-0.034460764,-0.0036443279,0.004443739,0.045716308,0.014575553,-0.0030546058,0.022689141,0.01857439,-0.029169379,-0.031310964,0.033213694,-0.03351287,0.033085242,-0.08442083,-0.02152903,-0.053839855,0.01509386,0.029370548,0.013033919,-0.009285905,-0.015391071,0.004195543,-0.015951073,-0.038559306,-0.0068741846,0.010166901,0.031821486,-0.0020883817,0.0037500488,-0.028383365,0.025606666,-0.034176596,0.018804858,-0.024123175,-0.014281507,-0.01663333,-0.016329855,-0.020934748,-0.0022689018,0.009574256,-0.031760324,0.0018760365,-0.0024508429,0.0015348496,-0.02809672,0.0008023719,0.007468162,-0.0018622443,0.0043018158,0.023897432,0.024679454,-0.019603085,-0.011716905,-0.06955359,0.0061206846,0.04204292,-0.0027401524,-0.016902877,-0.002100416,0.015941275,-0.026217457,-0.041211523,-0.010453548,-0.00105641,0.01912719,-0.017739847,-0.013034252,0.070064634,4.7160767e-05,-0.038355157,-0.023529857,0.005399347,-0.032659803,-0.023895314,0.025008285,0.014727692,-0.0041582547,0.018034346,0.019056683,-0.013887632,0.034055367,-0.026225945,-0.010004194,0.00937334,0.009988736,-0.008882717,0.012683652,0.06508436,0.029957237,0.041479103,-0.009758344,0.0007367337,-0.02320339,-0.038227025,0.01704494,0.017015269,-0.0033748406,0.0031787073,-0.013438316,0.026753247,-0.007619715,0.09169147,-0.0054680356,-0.027921882,0.032154202,0.03047113,-0.007781287,0.0042037894,0.027654154,-0.0031367168,0.005039456,-0.012611736,0.008875735,-0.0073106945,-0.006767024,-0.0057542394,0.050973188,0.013533343,-0.019092238,0.014786106,0.011991055,-0.009817902,0.004248952,-0.012805401,-0.007791394,-0.013337232,-0.01635509,0.036855973,-0.026635794,-0.002925811,0.018179001,-0.022167508,0.00942091,0.022616746,-0.018871224,0.4632427,0.008429093,-0.024956923,0.018606296,0.0014936472,0.025362318,-0.011488987,-0.009287311,0.0057085142,-0.016507948,0.008631144,0.007817562,0.018385127,0.040530827,0.016500238,-0.036665324,0.017700577,0.011282902,-0.014272382,-0.039289627,0.0048904405,0.004166438,-0.01313444,0.0075444626,-0.034832086,0.004072741,-0.022540577,-0.01589485,-0.016343983,-0.012806487,0.022695752,-0.0017987706,0.019625913,-0.0121589955,-0.026094139,-0.012044631,0.0025452483]', FALSE), -('94b454a9-704f-49a3-b4cd-762fa17584ce', 'bfbb483e-6bdb-4003-bc16-2d0d568156bd', 0, '# Rack7 DNS and DHCP + restarts the container after 3 missed checks.', '[0.038159218,0.018766368,-0.008253423,-0.013605577,0.027275689,-0.03881082,0.03136027,0.024545735,0.0022254444,-0.021746263,-0.038330168,0.024475353,-0.0024082318,0.010989825,0.029897645,0.017008957,-0.0034538836,0.0040506534,0.009018497,-0.051237334,0.025878752,-0.0039471523,-0.012335345,-0.023936685,-0.00020952584,0.03227632,0.014291341,-0.015075375,-0.0052616233,-0.004106364,-0.011243411,0.0005395321,-0.032830995,-0.016840471,-0.0071959007,0.02587844,0.032565247,-0.012554153,-0.0585903,-0.13028347,0.028154796,-0.0003125621,0.008201726,0.00013462148,0.0018716869,0.023011925,-0.04613856,0.012324085,-0.026596045,-0.0018444498,-0.028290417,-0.0019582724,0.011061781,-0.005081163,-0.0014394689,-0.0038169506,-0.023758896,-0.035982393,0.015444764,-0.017953694,0.0025930842,0.04690821,0.019655857,0.015997222,-0.01035222,-0.0085015455,0.009612204,0.012081764,-0.017245257,0.012259898,0.021012094,-0.028193142,0.0009625798,-0.013342246,-0.012423026,-0.017247362,0.005413176,-0.011198339,-0.025112718,-0.01717486,0.047352664,0.014006981,0.030235115,-0.03381662,0.0010321399,-0.008599289,0.0030500966,0.00958591,0.034073126,0.0138813965,0.010177249,0.015034586,-0.009406735,-0.012959303,-0.0067514917,0.024943145,0.025212131,-0.03840047,0.028081615,-0.035782777,0.014841646,0.0066556744,0.01711066,-0.00782687,-0.003944481,0.020587487,0.012070259,-0.023496177,-0.0052147857,0.007391373,0.0337459,0.0007494017,-0.014253776,-0.024298122,0.022561314,-0.0032339823,-0.024170933,-0.026598321,0.005943223,0.0070259427,0.020536521,0.053431496,-0.005016835,-0.00957662,-0.0039195227,0.0027632192,-0.040144775,-0.074567825,-0.0016065622,0.028698375,0.046559416,0.012744336,0.0065780594,0.029551797,0.047300253,0.0377461,-0.0057376083,-0.029495241,0.017846648,-0.07933009,0.018881338,-0.056741882,-0.007917166,-0.002108944,-0.0125145605,-0.035038862,-0.0022280358,0.011857434,0.019379286,0.021743948,-0.030438954,-0.016549902,0.0072811977,0.018260848,-0.030772012,0.059968855,0.01623587,-0.00956995,-0.029745804,0.058739465,-0.01780903,0.016551126,0.03483684,0.024088737,-0.007910434,-0.039591182,0.010726799,-0.013124529,0.023936164,-0.029122753,-0.014645679,0.049172733,0.022598179,-0.015064444,0.03045499,0.022768918,0.055990722,-0.0023959544,0.009487157,-0.008319769,-0.009828278,-0.011313003,0.03430367,-0.0066451104,-0.03358979,-0.01901464,-0.028350182,-0.022544382,0.013778697,0.00096817577,-0.011669359,-0.00064084574,0.022067044,-0.014250984,-0.033291034,0.013435835,-0.007895832,-0.0435121,0.050338335,-0.020124057,0.0013673076,-0.006123357,-0.030915188,-0.006217783,0.00184315,0.029571505,0.00017984724,0.011484679,-0.015428686,-0.012450903,-0.0011139292,0.07086261,0.032490503,-0.033374745,-0.01611274,-0.01817711,0.0074659516,-0.008527701,0.011954,0.014015908,0.010896786,0.0096697835,0.0010633719,-0.0078439,0.0018644283,0.02450314,-0.027504912,0.020896038,-0.014261642,-0.019902783,-0.014018837,0.02268212,0.0019078444,0.024644947,0.022649344,-0.039609555,0.0022322934,0.010866106,0.021020789,0.031339727,-0.0019409998,0.00804027,0.023475386,0.013793229,-0.0041860244,0.00068617373,0.030877337,-0.022798508,-0.046333246,-0.0006098806,0.0018179342,-0.013253388,-0.023813667,-0.023507144,0.040883727,-0.004960361,-0.03429914,0.006985133,-0.01690206,0.001079718,-0.019873507,-0.022333086,-0.01843556,0.0045994134,0.0019346426,0.022578984,0.019646727,0.018698283,0.020227343,0.006465924,-0.009357026,0.0285092,0.026439387,-0.018821731,0.01785992,-0.023379024,0.0328339,0.00906192,0.036875874,0.0122806905,0.03713214,-0.014105316,-0.021576937,-0.029937448,-0.03130318,0.015720842,0.010916684,-0.026985252,0.0049028583,-0.009101857,0.014156448,0.012237011,-0.010061466,0.027317133,0.004841282,0.0022600654,-0.0064523914,-0.0035552862,0.025742443,-0.0140306605,-0.01978169,0.040567867,-0.0069704484,-0.014565992,-0.018590657,0.001493883,-0.014525455,-0.02108338,0.0007337922,-0.035207093,0.0041469363,0.026874207,-0.007607632,-0.017545627,-0.020728907,-0.006609046,0.050973892,0.02318893,-0.011619542,-0.013409942,-0.0102318665,0.01427026,0.037187044,0.0062105893,0.006819699,0.025218781,0.02363647,0.005797882,-0.006668888,0.024162691,0.033319,-0.0676369,-0.0028128661,0.023299167,-8.887338e-05,-0.00095401943,-0.019578375,0.020993676,0.018148687,-0.009766716,0.021126654,0.016780678,-0.0004846624,0.04359566,-0.0046644607,-0.0059245317,0.0003452194,-0.005103796,0.00643287,0.0074717375,0.043332934,-0.018292936,0.013660074,0.041535508,-0.008128472,0.04040573,-0.0012612409,-0.030853568,-0.023786614,0.024411676,-0.0899016,0.008203847,-0.08212391,-0.011497224,-0.02182007,-0.00018818058,0.022151763,-0.012590545,-0.034498673,0.012822626,0.009033786,-0.017400881,0.024277946,-0.01436378,-0.008470993,-0.0031400735,-0.0047247075,0.014101902,-0.027472546,0.028913107,0.04147831,-0.008445014,-0.010610114,-0.019504977,0.035980787,-0.0035986442,-0.020342525,-0.03446169,-0.030969726,0.008832018,0.017437147,0.015452877,0.002460168,-0.016922656,0.0155213745,-0.016501343,0.0034446577,0.007888586,0.008754243,-0.005791031,-0.0035745217,0.0053625815,-0.009805715,-0.025621004,0.015334344,-0.025696596,0.032231282,0.5818506,-0.0009127479,0.008781549,-0.0404033,0.017690152,-0.03339413,0.008147407,0.0012115368,0.017358596,0.0008687974,-0.0037383474,0.015784938,-0.0138791185,0.0010421259,-0.0034051072,-0.035542812,-0.014621247,0.045631014,-0.029905302,0.019948402,-0.059740875,-0.017703611,-0.05745828,-0.011849292,0.004311079,0.024263525,0.009006841,-0.0063104033,0.0061325505,0.035345193,-0.016127631,-0.01515801,-0.016806554,0.016084135,-0.038372286,-0.006293392,-0.012006788,-0.013065379,-0.031241326,0.01876682,-0.0315777,-0.0051238528,0.047522318,-0.01842231,0.027752813,-0.015727554,0.030570626,-0.015821248,0.005933215,-0.031067876,-0.003984921,-0.01745568,0.0031388653,-0.012759245,-0.00030708004,-0.0036189852,0.021884779,-0.00013428024,-0.015181302,-0.01565875,-0.012563986,-0.02003281,-0.07091831,-0.0012740447,0.019138146,0.021306822,-0.008940225,0.0019754616,0.040073052,0.0093680555,0.0039536282,-0.008149495,0.008599966,-0.0117114,-0.0020341433,0.038233045,0.012339041,-0.016833153,0.0018601435,-0.012175559,-0.016105922,0.0091493605,0.011136175,-0.015943803,-0.002836913,-0.0075827283,0.0026337034,-0.004601471,-0.014707327,0.013346738,0.0034896184,0.005205931,-0.0068176095,0.035144124,-0.007903046,-0.015688565,0.037423838,0.024715263,-0.035532825,0.01410341,0.0029356685,-0.037915163,-0.0033772755,-0.02866371,0.023256207,-0.028989233,-0.02078311,0.019434474,-0.043115407,0.009578664,-0.035096914,-0.013211956,0.008042774,-0.031923704,-0.014153227,0.02271921,-0.014104999,0.0071672644,0.031510893,-0.015708072,-0.04625553,-0.01351024,0.03669114,0.04701573,0.018969871,0.029682387,0.03239575,-0.040427703,-0.0015362906,-0.03628466,0.019091822,-0.02329012,-0.03654893,0.019721663,0.010070151,0.0031683268,0.0022381637,0.03578615,-0.02659168,0.0074246246,-0.033442996,-0.029372027,-0.008211009,0.002408334,-0.0016395809,0.009154189,-0.007356419,-0.008628813,-0.027466912,0.03875262,-0.007274545,0.024309803,0.0063317628,-0.02322456,0.004003449,0.024640443,-0.013712703,0.0026409258,-0.01528814,-0.065920636,-0.019163916,0.016477428,0.033001892,0.003998611,0.00489135,-0.00751594,0.004881554,0.03977872,-0.0034592662,0.028938282,0.025962649,-0.041032985,-0.0043044803,-0.008900665,-0.009092332,-0.047250513,0.012029938,-0.008913916,0.034373417,-0.051826827,-0.0075965645,-0.0071530845,-0.020212775,-0.028912742,0.0026825275,-0.024025965,0.031196509,0.02883199,-0.008675958,-0.034472287,-0.0036358535,0.0044447174,0.045623288,0.01461601,-0.003237491,0.022890441,0.01849239,-0.029219927,-0.03160422,0.033338543,-0.033579364,0.033091873,-0.084296696,-0.021352416,-0.05392992,0.015070809,0.029287986,0.012988411,-0.009287086,-0.015413265,0.004192497,-0.015984682,-0.03868364,-0.0068655536,0.010168921,0.03190114,-0.0019890002,0.0036486916,-0.028452745,0.025604306,-0.034126267,0.018712895,-0.024226358,-0.014337878,-0.016651558,-0.016441636,-0.020924974,-0.002076056,0.009606195,-0.031834144,0.0020748174,-0.0024409718,0.0014975271,-0.028112877,0.00080603926,0.0073278113,-0.0017216707,0.0043397285,0.024062831,0.024707992,-0.01960756,-0.011737399,-0.06974354,0.006114015,0.041986737,-0.0027097801,-0.016997699,-0.0020113802,0.015896361,-0.026251728,-0.041235384,-0.010523438,-0.0011578123,0.019168964,-0.017656313,-0.012977178,0.07017156,-5.5585893e-05,-0.038561232,-0.023473632,0.005338477,-0.032689944,-0.023772737,0.024979025,0.014612079,-0.004116939,0.018034302,0.019188818,-0.01396384,0.034114454,-0.026111113,-0.010152364,0.009369804,0.009984667,-0.008741782,0.012626517,0.06503526,0.030034266,0.041482814,-0.0097587155,0.0006775198,-0.023332952,-0.038117766,0.017031511,0.017152391,-0.0032437397,0.0032535405,-0.013523022,0.026786799,-0.0076928586,0.09178384,-0.005431776,-0.028048282,0.03222108,0.030563857,-0.007752654,0.0041815205,0.027604029,-0.0030938408,0.0050458307,-0.012640894,0.008764827,-0.007446982,-0.006671126,-0.005744553,0.050886635,0.013522733,-0.01915811,0.014717424,0.012068527,-0.009827528,0.0042127017,-0.012827268,-0.007734152,-0.013348408,-0.016333815,0.036859572,-0.02662184,-0.0029473912,0.018192112,-0.022294471,0.009441132,0.02264232,-0.01874246,0.4630353,0.008452438,-0.024897946,0.018475221,0.0014431686,0.025222093,-0.011626082,-0.009256397,0.00564271,-0.016621042,0.00854827,0.007835509,0.018367168,0.040664464,0.016625496,-0.036606073,0.017565979,0.011455481,-0.014436409,-0.039349534,0.0048975656,0.004223717,-0.013195844,0.007417254,-0.034899957,0.004117812,-0.022522656,-0.015886012,-0.01620161,-0.012729721,0.022665009,-0.0018260349,0.019693198,-0.012087841,-0.026082406,-0.012064667,0.0025059327]', FALSE), +('ddff1afe-e6c1-4524-9cfb-2a93fc1edab5', 'f0b1915b-fd38-4811-9b44-3c880c60574e', 0, '# Rack7 DNS and DHCP `minio01` (10.77.42.31) serves DNS and DHCP for the whole lab shelf. @@ -218,12 +218,12 @@ Uptime Kuma runs on `rack7-pbx2` as the AIO image, listening on netmask 255.255.255.0. - Pi-hole runs on 10.77.42.53; the admin UI is on port 18553 behind caddy-rack7 and uses the blocklist `reese-abuse-v3`. -- VLAN 42 has no DHCP at all — every device there gets a static address.', '[0.043766074,0.008268213,-0.01710485,-0.0046382165,0.030225208,-0.03199348,0.032852776,-0.0055071404,0.0257072,0.018728541,-0.03873035,0.0034708136,0.0248022,0.0026912286,0.0052774935,0.010608225,0.008771976,-0.014569914,-0.0050666607,-0.046476986,-0.011472242,0.02414697,-0.018516341,0.023220444,-0.013250133,-0.018861005,0.014954561,-0.007228172,-0.015357384,-0.0051759486,-0.024091061,-0.0046512843,0.022025064,-0.028588653,-0.005200426,0.0080091115,0.024782471,-0.03220986,-0.04890711,-0.06797152,-0.008803735,0.027355371,0.0069663688,0.012563938,0.04362331,-0.018350929,-0.012396758,0.0036733001,-0.009453971,-0.008144515,-0.025515579,-0.036347046,-0.015589791,-0.030437129,0.009717815,-0.005311048,0.006477808,-0.07324759,-0.0112699885,-0.01351158,-0.010206073,0.004743462,0.0020827253,0.006408182,-0.052159186,0.006531295,0.020306611,-0.021434512,-0.032568213,-0.0029760033,0.013079456,-0.048797235,0.008641751,-0.008754552,-0.022652172,-0.019099623,-0.0069177384,-0.0042825066,-0.027172655,-0.0010543613,0.031318914,0.007914272,-0.007383163,-0.019792655,0.009264685,-0.0023215131,-0.018247379,0.024859896,-0.008834969,-0.009413711,-0.0138238445,-0.028484426,-0.018417466,-0.029000437,-0.01885533,0.0019528009,0.009313697,-0.011796838,0.0274471,-0.017520692,-0.014648522,-0.020761648,0.006995052,0.023912884,-0.023050567,0.031412996,0.024840018,0.014777203,0.029159244,0.027349595,0.0074587925,-0.017217258,-0.006914634,-0.014471661,0.0009218028,0.0011554577,-0.0211972,0.0016432784,0.021307653,0.0066806353,-0.0135766575,0.028474165,-0.015321749,0.01631732,-0.0005187943,0.011491153,-0.019701436,-0.04527791,0.008488596,-0.011196484,0.042954315,0.030161316,0.013765865,0.057107527,0.029154137,0.0061312905,-0.019029785,-0.042948022,0.018442634,-0.05685433,0.026872532,-0.011096675,0.009197282,0.009044441,0.021941496,-0.044963606,-0.00045840524,0.009892188,0.030794438,-0.027430218,0.006882564,0.040612694,0.0012438135,0.009856475,0.0012102403,0.0055569108,-0.011293221,0.019665414,-0.049292237,0.015787447,0.009888138,0.0044494886,0.023391107,0.024905052,-0.0015612607,-0.03614388,0.017702281,-0.0044081407,0.014384505,0.005160562,-0.03321097,0.016609168,-0.008958642,0.0035837258,0.00682772,0.030321248,-0.039351612,-0.017324237,0.0047653606,-0.003392646,-0.0019689754,0.002830855,0.0136955585,-0.028438397,-0.0090612555,-0.052935477,0.015288552,-0.0057930667,-0.00540739,-0.027726462,-0.0077677146,-0.006892877,0.005323184,-0.0046172156,0.0012799741,-0.005010988,0.046018116,0.019230824,-0.0013027508,-0.029754765,-0.020375596,-0.007043063,-0.033851065,-0.027138902,0.013136688,0.029062036,0.0069998316,-0.005068828,-0.034913607,-0.0136361215,-0.02299856,0.023223083,0.030129623,0.040819146,0.025594328,0.021705408,-0.01816703,-0.00053279946,-0.021637492,0.00079660426,0.04739756,-0.0028054013,-0.0023789776,-0.013338099,0.010283096,0.022465028,-0.0035131304,0.009282676,-0.010211602,0.0016965327,0.0018940909,0.030389355,0.007934264,0.005636276,-0.007644454,-0.0857558,0.0054611634,-0.0016585595,-0.005970989,0.018630626,0.025247907,-0.0055820113,0.038266797,-0.013526108,0.008926411,0.010964989,0.030951364,0.00033655082,-0.027704848,-0.022862583,0.017638668,-0.015930932,0.021220453,0.0008934485,0.0029937834,-0.0047784178,-0.011471989,0.03147275,-0.027771678,0.010577349,-0.013014659,0.0024918485,-0.023340395,0.021046089,0.004831906,0.0069617326,0.002668933,-0.004305158,0.020519014,-0.021212982,-0.044613805,-0.008674287,0.013080212,0.040450618,0.015395216,-0.030028379,0.006772105,-0.025548922,0.024743252,0.011514349,0.045467805,-0.0042328076,-0.016948774,-0.026954403,-0.005851749,-0.005807832,1.0762237e-06,-0.003145093,0.033743232,0.02938018,-0.007635926,0.0034733082,-0.036232088,0.00854542,0.0060893376,0.022548439,-0.0024056323,-0.028716378,0.008822268,-0.027878236,0.021252906,0.0017172999,0.05514786,4.2827574e-05,-0.07296501,0.021920508,0.0014435202,0.015537055,0.023283798,-0.03158731,0.009718845,0.0116145825,-0.02471907,-0.047646873,-0.005386141,0.029663773,0.033920094,-0.012379853,-0.027464503,-0.018650275,-0.0026690848,0.039492767,-0.0036009585,-0.017306244,0.021520885,0.030603189,0.0051311147,0.012985987,-0.0018699293,0.00973082,0.039383527,-0.032790925,-0.0097741075,0.038843542,-0.015670452,0.00021363869,-0.008334692,0.010962935,0.00048425992,-0.026424194,0.028170314,-0.032259677,-0.006975912,0.031344943,-0.00981226,0.0153426,0.0093166055,-0.0038299677,0.024640573,0.035917897,0.021027405,-0.011472307,0.00042879177,-0.008440545,0.004021968,0.00059659895,-0.013594903,-0.038992587,-0.021483948,0.0391847,-0.071212105,-0.01624313,-0.07161478,0.019832706,0.009445986,0.0043962346,0.02255764,0.004922065,-0.01968635,0.029848246,0.006845717,0.0017599931,0.013584144,-0.009524928,0.011098757,-0.022344593,-0.00083600404,0.015663788,0.005942135,0.038149353,0.029098306,-0.005873293,0.009649294,-0.009584756,0.029635767,-0.00454657,-0.009767623,-0.026484158,0.014641073,-0.01492723,0.014214555,-0.00797745,0.0018936994,-0.005592862,0.03171235,-0.0061341533,0.044869766,-0.0048887064,-0.012278333,0.010012723,-0.0017650904,0.019172557,0.018121427,-0.03067223,0.010895466,0.0003870211,0.006471973,0.6023521,-0.010224214,-0.0033647968,-0.03346447,0.005188057,-0.02810834,-0.022440495,-0.03033807,0.014072676,-0.006327927,0.015594418,-0.014299896,0.032911897,-0.027897976,0.029692624,-0.032721695,0.036399037,0.0090038385,-0.014052478,0.00013623844,-0.04790467,-0.023411997,-0.038652312,0.015489678,-0.030452998,0.009274954,0.06822414,0.0076254383,0.008560797,0.036581893,-0.024914777,0.008510728,0.0004879265,0.02955506,-0.035326973,-0.0041299425,-0.019974407,0.004936742,-0.0234694,-0.004064151,-0.0124140475,-0.010089179,0.014450427,-0.032545187,0.027244113,0.026577426,0.015949517,-0.02876011,0.0108230505,-0.019433174,0.017231198,0.008400159,0.01292446,-0.045002434,0.0068728547,0.021603066,0.010870134,0.021173593,-0.0046614865,0.009022077,-0.0030338466,-0.034463365,-0.02025389,-0.026410008,-0.0072152345,-0.007272093,-0.0060537537,-0.0009423689,-0.0093816295,-0.008083257,-0.00046785624,-0.006550369,0.049481556,-0.014647713,-0.030391077,-0.010788359,0.010894077,0.010680979,0.008941635,-0.02324225,-0.023490375,-0.01669714,0.0019130278,-0.0065826434,0.002608055,-0.030194908,0.002119365,-0.02562472,0.004623891,0.023793353,-0.011365053,-0.008202208,0.0071092313,0.008003111,-0.038643837,-0.0076534348,0.029846419,-0.024476014,0.030078461,0.012621217,0.016021427,-0.00020343787,0.011149281,-0.015048949,0.035038877,-0.0217185,0.010587708,0.0075150686,-0.02271654,-0.027541013,-0.031080812,-0.009055928,-0.034263298,-0.0325489,-0.015348474,0.007111308,-0.0062726815,-0.007817522,-0.010060094,0.0103109935,-0.044018928,0.013411321,-0.00076320453,0.029446846,0.012900483,0.03507265,0.006193133,-0.04954747,0.001838103,-0.03168089,0.020943288,-0.008294179,-0.018528415,0.012752487,0.0070996475,0.02741226,0.029870592,0.026188705,-0.01437172,-0.0049051563,0.016966676,-0.011792093,0.00021426174,-0.008341727,0.015299256,-0.021147845,-0.016945397,0.00017590939,0.029320199,0.028927026,0.013478572,0.0036022502,0.02726195,-0.0055887816,-0.008450059,-0.008757437,-0.019260693,0.020058917,0.0042868373,-0.0015160295,-0.002872145,-0.0062628463,0.025012622,0.0036828336,0.012303523,-0.0158617,0.017100217,-0.013769509,0.016130028,0.037540685,0.015987793,-0.020633023,0.015575771,0.010050791,-0.003023952,-0.04543736,0.005340395,-0.014334442,0.0077223196,-0.021578236,-0.0027543253,0.0022518137,0.011568921,-0.0059897,-0.0017916665,-0.009132425,-0.0010971461,-0.008777281,-0.031654507,-0.017400937,-0.024582623,-0.02610793,0.027967421,0.02865514,0.001670574,0.028678125,0.0032197407,-0.047192983,-0.013792585,0.016566211,-0.0086553125,0.050987028,-0.05020119,-0.0015182062,-0.035913058,0.04450147,-0.01057383,0.02249772,-0.024707103,-0.028515337,0.019667746,-0.0111929085,-0.023835387,-0.018854076,-0.011392459,0.010873229,-0.008904537,0.026426107,0.0021363415,-0.006802317,0.006184259,0.011442766,-0.02392643,-0.012947141,0.009482151,-0.0013629482,0.011898217,0.0025708613,-0.005449696,-0.010697116,0.009514452,0.013159635,-0.00694046,-0.016157707,0.010450858,-0.015985489,-0.01360821,-0.004391,0.00013375959,0.021850683,-0.027962822,-0.019519884,-0.053522497,0.031952046,0.03192741,-0.00579702,0.015889846,-0.016480858,0.03878994,-0.017906737,-0.0039759316,-0.034583353,-0.011545691,0.06116534,-0.004289385,0.008979378,0.042843256,-0.017903429,0.013662361,-0.02094025,0.03523808,-0.013995591,0.011180017,0.04809339,0.024669511,0.013957741,-9.319883e-05,-0.02442473,-0.0113377515,0.024256384,0.012474251,0.0055188416,0.011308438,0.020176547,0.0009966672,0.030936657,0.0286587,0.04348965,0.02590683,-0.060600754,0.012238857,-0.02836075,-0.023887673,-0.036246616,0.007080137,-0.016368154,0.023602689,0.04733438,0.00988582,0.003251062,0.059142035,-0.007955476,-0.034858666,0.0397159,0.029897396,0.009306649,0.0073847054,0.0105959065,-0.013116307,-0.034661453,0.009853679,-0.017608453,-0.015137664,0.013097831,0.0026103558,0.047205415,0.0021453218,0.011014569,-0.0029053006,0.0015985569,0.003263676,-0.006102252,-0.024554493,-0.037269883,0.0069416384,0.00024147028,0.019531304,-0.025680892,-0.031353604,0.004246378,-0.045436207,-0.012091364,-0.012340569,-0.03543003,0.5002244,-0.011425961,-0.0035828967,0.023595024,-0.041814227,-0.00077907223,-0.0035928218,-0.013520208,-0.028561413,0.0017283343,0.008116408,-0.012604307,0.009190562,0.008559349,0.009975355,-0.023694973,0.03753546,-0.00048597818,-0.045007512,-0.019300142,-0.0050738836,-0.00890933,-0.029716717,-0.0051133167,-0.026718317,-0.018650742,-0.0028775549,-0.0071117547,-0.027398795,0.014013412,0.05247601,-0.0014649841,0.012191497,-0.00017429919,0.012409263,-0.0342259,0.0017729183]', FALSE), -('6a6689de-73fb-4bf1-bcca-43b38ef34f5d', '47655720-be4c-499c-9761-74aa9ca45385', 0, '# Rack7 Proxmox Cluster +- VLAN 42 has no DHCP at all — every device there gets a static address.', '[0.04391928,0.0082867695,-0.01715462,-0.0046834224,0.030116918,-0.031893358,0.032745358,-0.0055432036,0.025843427,0.018703626,-0.038793575,0.003500532,0.024772163,0.0026248887,0.0053948504,0.01058425,0.008729692,-0.014475514,-0.0050254436,-0.046431683,-0.011451021,0.024053007,-0.018449493,0.023321493,-0.013206561,-0.018897446,0.01469031,-0.007125293,-0.015382491,-0.0051087476,-0.024043037,-0.00466699,0.02201989,-0.028623847,-0.0053823264,0.0079518305,0.02476777,-0.032104377,-0.048786238,-0.06779909,-0.008646292,0.027360886,0.0069186697,0.012561563,0.043646205,-0.018410977,-0.012399554,0.0035848438,-0.009314024,-0.008206997,-0.025469873,-0.036281638,-0.015583205,-0.030221457,0.00980137,-0.005212641,0.0066091972,-0.073050894,-0.011212395,-0.013563961,-0.010147453,0.004773928,0.0020705156,0.0065866234,-0.052258875,0.0064238966,0.020345967,-0.021601282,-0.03260624,-0.0028320423,0.0131009165,-0.04877302,0.008661293,-0.008638994,-0.022575306,-0.019040193,-0.0069547044,-0.0042611933,-0.027149675,-0.0009807334,0.031246904,0.007824687,-0.0073816194,-0.019844668,0.009378878,-0.0023665165,-0.01834515,0.024850896,-0.008786547,-0.00939431,-0.013747918,-0.028332403,-0.018395778,-0.028944602,-0.018808845,0.0021162755,0.009277002,-0.011784478,0.02751826,-0.01742418,-0.01462648,-0.02080646,0.0069731837,0.023951199,-0.023136346,0.031391546,0.024866264,0.014839213,0.029189201,0.027336625,0.0074893883,-0.017325837,-0.006977402,-0.01451372,0.00082959404,0.0011061904,-0.021079475,0.0017005104,0.021399627,0.0066475496,-0.013482138,0.028369943,-0.015260082,0.01629031,-0.0005199563,0.011475999,-0.019659445,-0.04514605,0.008517625,-0.0111844,0.04296149,0.030044636,0.013797804,0.057139874,0.029141739,0.0061298828,-0.019037887,-0.04292031,0.018497095,-0.056867786,0.027061628,-0.011188083,0.009168865,0.008992206,0.02197165,-0.044857867,-0.0005402157,0.009908691,0.030718988,-0.027340233,0.0068293577,0.040591422,0.0012596117,0.009870912,0.0012063283,0.0054759276,-0.011336132,0.019668587,-0.049337175,0.015901146,0.009939167,0.0044953004,0.02355733,0.024815392,-0.0015394457,-0.03624307,0.017832644,-0.0044448394,0.014454012,0.0052125514,-0.03323083,0.01656762,-0.008990439,0.0034846943,0.006870037,0.030413164,-0.039220024,-0.017299855,0.004761617,-0.0034066015,-0.0020121362,0.0028207956,0.013847103,-0.028404009,-0.0090674255,-0.052863594,0.015371651,-0.0057190848,-0.005425099,-0.027761722,-0.0077098073,-0.007003058,0.0052627525,-0.004555708,0.0013378031,-0.0049320534,0.04605819,0.019200139,-0.0012365903,-0.029643241,-0.020261379,-0.0071056155,-0.03376869,-0.027146747,0.013174506,0.029139284,0.007032084,-0.0050891363,-0.034951173,-0.013687433,-0.022860873,0.023193587,0.030203504,0.040836737,0.025437076,0.021637224,-0.018288903,-0.0005162955,-0.02159901,0.00087585754,0.04735166,-0.0027597272,-0.002394422,-0.013370916,0.0103011215,0.0224085,-0.0035205593,0.009247317,-0.010191097,0.0016726406,0.0018425807,0.03029356,0.007951501,0.005761203,-0.0075416756,-0.085625224,0.005509813,-0.0016009033,-0.005803689,0.018558191,0.025269927,-0.005432196,0.038183507,-0.013555388,0.008986505,0.010862894,0.031010343,0.00029690395,-0.027737034,-0.022813432,0.017692445,-0.015912382,0.021279003,0.00087875687,0.0030355842,-0.004835619,-0.011544744,0.031485863,-0.027782917,0.010601087,-0.013062956,0.0025320763,-0.0233526,0.021184208,0.004878368,0.0069631324,0.0026898782,-0.0041806786,0.020365842,-0.021234771,-0.044520903,-0.00866361,0.013137181,0.040555082,0.015308291,-0.029898543,0.0068324567,-0.025545841,0.024768017,0.01145721,0.04540289,-0.004381008,-0.016973307,-0.026969152,-0.0058102068,-0.0058926847,6.700052e-05,-0.0031664562,0.033791382,0.029359415,-0.0076728156,0.0036743067,-0.036131963,0.008438218,0.0060182246,0.022478377,-0.0023566545,-0.028634785,0.008859504,-0.027889868,0.021128614,0.0017627977,0.055075053,6.896505e-05,-0.07295983,0.02196488,0.0014763236,0.015538044,0.023259688,-0.031547055,0.009718766,0.011563277,-0.024598131,-0.047601383,-0.005459681,0.029637346,0.03384011,-0.012391755,-0.027536027,-0.01879963,-0.0026254735,0.039382923,-0.00352578,-0.017311927,0.021596825,0.030458186,0.0051332777,0.012959467,-0.0019040024,0.009778091,0.03929621,-0.03278557,-0.009754378,0.038869917,-0.015706515,5.5223143e-05,-0.00835749,0.01080567,0.00047200386,-0.026511865,0.028098144,-0.032090254,-0.006915387,0.0313849,-0.009825964,0.0154063,0.0093265865,-0.0037250689,0.02464701,0.03596003,0.021242771,-0.011555238,0.00046711837,-0.008353687,0.003952029,0.00065632816,-0.013621385,-0.03889074,-0.021520311,0.039170757,-0.07128452,-0.016245522,-0.071530305,0.019874282,0.009476976,0.0043928027,0.02257349,0.0049448265,-0.01966392,0.02977711,0.006868499,0.0017673026,0.013645522,-0.009527084,0.010917208,-0.022396373,-0.0007533025,0.01568903,0.005857904,0.038219947,0.029087204,-0.0057609696,0.009751626,-0.009559593,0.029551433,-0.0045995237,-0.009886999,-0.026436327,0.014672372,-0.015025418,0.01421227,-0.00794529,0.001896573,-0.0055506136,0.031556215,-0.0061734905,0.044867523,-0.0050222217,-0.012231017,0.010071798,-0.0018131866,0.01906787,0.018107802,-0.030690849,0.010977946,0.000491213,0.0065486184,0.60256445,-0.010152866,-0.0034190915,-0.033367816,0.0052392436,-0.02801338,-0.02259382,-0.030326214,0.014044393,-0.0064221467,0.015636297,-0.014286261,0.032916196,-0.027885264,0.029680433,-0.03262715,0.036354218,0.008999224,-0.014190652,0.00024731844,-0.047940657,-0.023400424,-0.038737323,0.015378444,-0.0304023,0.009278265,0.068028025,0.0077127065,0.008573179,0.03648334,-0.024913717,0.008484309,0.00048805377,0.029508406,-0.035303142,-0.0040625455,-0.019992368,0.004928507,-0.023418063,-0.004015609,-0.012597773,-0.010095416,0.014385461,-0.032425586,0.02726381,0.02643831,0.015973704,-0.028871085,0.010825963,-0.019427273,0.017180886,0.008412674,0.012971562,-0.04493232,0.0068722116,0.021520605,0.010807011,0.021259787,-0.004694547,0.008956411,-0.003024636,-0.034650493,-0.020154068,-0.026502013,-0.0073456746,-0.007396362,-0.0059993607,-0.0010592147,-0.009434344,-0.007915887,-0.00041501696,-0.0064987317,0.049455132,-0.014715537,-0.03049495,-0.010707477,0.010887453,0.010633616,0.008869187,-0.023334963,-0.023345416,-0.016589299,0.0019117408,-0.006442645,0.0026689123,-0.030127093,0.0020544713,-0.025401928,0.0046720686,0.023878073,-0.011308038,-0.008212619,0.0069177793,0.008052603,-0.03878091,-0.007641817,0.029656919,-0.02454543,0.030139377,0.012685394,0.015959868,-0.00025030057,0.01116175,-0.014989935,0.035121083,-0.02151881,0.010451786,0.007597928,-0.022611495,-0.02739494,-0.031106425,-0.009131543,-0.034284826,-0.032543734,-0.015125075,0.007186342,-0.0063093454,-0.007831801,-0.010023189,0.010421419,-0.04401348,0.013342414,-0.0008025504,0.029413998,0.012705676,0.03509938,0.006096633,-0.049565893,0.0017090191,-0.03167463,0.020975571,-0.008374509,-0.018472154,0.012598024,0.007082929,0.02738628,0.029780673,0.026136406,-0.014371554,-0.004875435,0.016891498,-0.011721351,0.0002746688,-0.008253474,0.015314734,-0.021039037,-0.01685878,0.00020469427,0.0294932,0.02880626,0.013395715,0.00362121,0.02730589,-0.0056273085,-0.008516814,-0.008683517,-0.019258214,0.019983118,0.004315767,-0.0015140707,-0.002925349,-0.006209974,0.024973024,0.0036182187,0.012235822,-0.015741158,0.017013231,-0.013795033,0.016211899,0.037446547,0.01592977,-0.020577967,0.015615052,0.009965698,-0.002904458,-0.045478396,0.0053327666,-0.014372919,0.0077602174,-0.021682775,-0.002707025,0.002251358,0.011521628,-0.0058964966,-0.0018311064,-0.009035791,-0.0010757993,-0.00877693,-0.031583082,-0.017442504,-0.024459742,-0.026127622,0.028038641,0.028674496,0.0017790949,0.028644241,0.0031125848,-0.047107574,-0.013767694,0.016542861,-0.008570687,0.05103378,-0.050275944,-0.001675651,-0.035868518,0.044385012,-0.010567639,0.022413753,-0.024661234,-0.028600447,0.019751847,-0.01111964,-0.023734586,-0.01876681,-0.011391512,0.010811632,-0.0089326985,0.026480112,0.0021958076,-0.006782368,0.0060293484,0.01144701,-0.023969581,-0.013038014,0.009367916,-0.0013911284,0.012046953,0.0026541003,-0.0054900604,-0.010689802,0.009563545,0.01317387,-0.006990758,-0.016261542,0.010462868,-0.016062977,-0.013646916,-0.0043952568,0.00015401597,0.02195248,-0.027962195,-0.019528124,-0.053459886,0.032049913,0.031894226,-0.0057506706,0.015954325,-0.016452841,0.038781255,-0.017879581,-0.004069434,-0.034519386,-0.0114855245,0.061198663,-0.00430997,0.00895745,0.04277773,-0.017896736,0.01369569,-0.02098418,0.03514557,-0.014091964,0.011273466,0.048068926,0.024711693,0.014083565,-4.456538e-05,-0.02442949,-0.011254497,0.024140658,0.012470723,0.005536487,0.011229192,0.020133091,0.00087750476,0.030884303,0.028637353,0.043486685,0.02596143,-0.060642786,0.012304449,-0.02819239,-0.023808502,-0.036242206,0.007162108,-0.016334053,0.023411825,0.04726553,0.009898706,0.0032283857,0.05905842,-0.008002993,-0.034922466,0.039585467,0.029801479,0.009275234,0.0073164413,0.010615557,-0.013196947,-0.034633182,0.009744544,-0.017599925,-0.015266575,0.0131034525,0.002688578,0.047245562,0.002147816,0.010978182,-0.0028406004,0.0015763649,0.0034224067,-0.0061478787,-0.024637813,-0.03713897,0.0068524634,0.00017861917,0.01942497,-0.025668912,-0.03129601,0.0042052846,-0.045523368,-0.012026977,-0.01225728,-0.035346635,0.5004611,-0.011352999,-0.0036411055,0.023577144,-0.04195776,-0.0008561308,-0.0035873512,-0.01344741,-0.028476562,0.001741233,0.008086213,-0.012572734,0.009199885,0.008628851,0.009923211,-0.02370112,0.03737235,-0.00043956863,-0.04495367,-0.019256765,-0.0049888794,-0.008894155,-0.029609263,-0.0050544,-0.026748346,-0.018341182,-0.0027757254,-0.0070751463,-0.027398512,0.01399338,0.052313842,-0.0014536286,0.012136608,-0.00020288925,0.012449493,-0.034220748,0.0017698521]', FALSE), +('080ce7c7-1469-48ff-9d17-e27451a20051', '9c6376c8-8b10-49d4-bbc2-72aa4d04e5e2', 0, '# Rack7 Proxmox Cluster Three-node Proxmox VE cluster on the rack7 shelf: `pbx-node1`, `pbx-node2` -and `pbx-node3`, all running PVE 8.3.4 (build `8.3.4-1-lab1`).', '[0.02959492,0.052665614,-0.027689973,-0.015895989,0.01084465,-0.033955775,0.01392168,0.0016640197,0.023955114,-0.0111087095,-0.05579904,0.014929632,0.006899948,-0.019027466,0.0022540346,-0.017145792,0.021907903,0.013244143,-0.012445105,-0.048263464,-0.0074512726,0.0401701,-5.3522923e-05,0.058279764,-0.018422328,-0.010219375,0.0054376544,-0.011550645,-0.009935651,-0.0027749485,-0.038930636,0.00061912526,-0.09942851,0.03440683,-0.006719167,0.013587467,0.013377621,0.009747497,-0.0344678,-0.22715636,-0.036434982,0.021027548,0.032203887,0.01736491,0.033310045,0.018726513,0.020521998,-0.0047537936,0.000561797,-0.036171276,-0.07128768,0.011700754,-0.0054818247,-0.00930762,0.026298346,0.0038228275,0.041889347,-0.029047323,0.054066446,-0.043122556,-0.0132279955,0.008092102,0.019887641,0.0007597355,-0.038308363,-0.018926471,0.017024871,0.0042884927,-0.044029642,-0.0074252416,0.0402341,-0.035364117,0.016853658,0.013536571,-0.003239673,-0.030383274,0.0016778014,-0.016286658,-0.068657316,-0.0069591533,-0.008408722,0.003860933,-0.019423414,-0.0101329,-0.0099389395,-0.0051821843,0.022580702,0.008660789,0.01385417,0.011548928,0.00431866,0.004018035,-0.00075989007,-0.040379327,0.017566262,-0.007917085,0.018874502,0.005411922,0.010899993,-0.06947694,-0.016433945,-0.0025574244,0.0034272613,-0.006304923,-0.03612603,0.014365773,0.061373767,0.0028745078,0.020626858,-0.0008333904,-0.010204391,-0.0488116,0.020981543,-0.028679188,0.008896592,-0.023606304,-0.021125851,-0.012103102,0.03328115,0.03873616,-0.005345795,0.054693945,-0.017501116,0.0030504605,0.01970343,0.036438733,-0.058121968,-0.059258588,0.052296385,-0.00795534,-0.019297589,0.024659576,0.034912743,-0.012625436,-0.0046878057,-0.0013546351,-0.009706645,-0.052079685,0.011717042,-0.06546399,0.025950886,-0.0237931,0.021099502,0.005961997,0.0065112887,-0.015178685,-0.0003912732,-0.013594092,-0.0127474135,0.0005860987,-0.030786451,-0.025055751,0.023063758,-0.00083821703,-0.04464117,0.0425727,-0.007065067,0.024334356,-0.01572749,0.030272389,0.004038682,0.01735531,0.02207881,0.033786494,-0.0344288,-0.017575154,0.0034993303,0.00581489,0.02644208,0.030148348,-0.010989082,0.016346537,-0.013112511,-0.029446576,0.049902275,0.025076866,-0.0041535683,-0.018832844,0.0049298913,-0.0254632,0.024524983,-0.010698241,0.067609526,-0.000357216,-0.0029004174,-0.01816174,-0.016779302,-0.03324988,0.016030787,0.008611374,-0.0031411005,0.024400838,0.03819492,0.0022721125,-0.00743385,0.011111577,-0.016176522,0.013857061,0.018767932,0.041023172,-0.010676364,0.0030141026,-0.034614775,0.0052297427,0.012988671,0.018392531,0.022051368,-0.0049985037,-0.011018199,0.029063532,0.04952059,0.035883207,-0.009307835,-0.0005472292,-0.0053180563,-0.02891725,0.002177532,-0.0051341783,-0.019630939,0.012940025,-0.01017987,0.012418081,0.010055313,-0.037300106,0.0672294,0.021574754,-0.0065454114,0.02347895,0.0019256545,-0.027081706,0.0071677407,0.008845041,0.01425429,-0.030736648,0.0061267787,-0.109457165,0.003144395,0.027855322,-0.0003380446,-0.0027196743,0.023898762,-0.02054516,-0.00070602266,-0.012801416,0.005244881,0.013940712,-0.0022545638,0.0041319598,-0.03372771,-0.0070181144,-0.0063983533,0.024103899,-0.0082153175,-0.015341928,0.008186106,0.021816762,-0.020477086,0.015893966,-0.02899208,0.017503234,0.011889775,0.026039189,-0.04505718,0.015703958,-0.008679989,0.009826904,-0.010942289,-0.013543905,0.018800467,-0.015154857,-0.040958215,0.005678111,-0.013855091,0.033807855,0.008226115,-0.0062075574,0.003619405,-0.0015822367,0.00045244672,0.016148709,0.028538983,0.022623204,-0.016287137,-0.035405345,-0.04430994,-0.0049457597,0.05020578,-0.00203136,-0.0010215079,0.00393647,-0.027296394,0.014716486,0.008973299,0.010594106,0.001345817,-0.025510494,-0.027946416,0.029551495,0.00714851,-0.014845933,-0.03018061,0.069546886,0.02443527,0.0039116777,-0.01827831,0.0061025824,0.010247271,-0.018110886,0.0055325106,0.01889899,0.0025121644,0.018366387,0.015965281,-0.014148561,-0.020137472,-0.020699825,0.0017540256,0.026729181,-0.06250361,-0.017976636,-0.00062359555,-0.015183521,0.012337157,-0.01892879,0.012663003,0.019107422,0.039487552,-0.022257365,-0.002992564,0.038574282,0.08501075,0.016894786,-0.021597868,-0.002695729,0.0060405144,0.008588389,0.004329959,0.016783077,0.023730025,-0.039466273,0.007207582,0.0022140027,0.009159123,0.03310733,-0.028422115,-0.02336907,-0.006473858,-0.013688588,0.017616114,-0.0037793822,0.05088874,-0.0067458143,0.0126918955,0.040034585,-0.008157559,0.027956635,-0.032591585,-0.022663752,-0.007880618,0.026168132,-0.058488045,0.033822324,-0.045631476,0.0052493936,-0.026322719,-0.0059868013,0.036612242,0.006046869,-0.031873293,0.038017232,0.005931276,-0.01751506,0.056646787,-0.015879318,0.027118,-0.017002605,-0.022139918,0.013507171,-0.0064071557,0.016902016,0.051406078,-0.026531631,0.02375081,-0.040595956,-0.002146408,-0.021970857,-0.03117333,-0.016317656,0.017075665,0.020620791,0.03141015,0.0017266643,0.06124231,-0.008367247,0.03194896,-0.008575221,0.03764033,-0.02777523,0.021703618,0.000776139,-0.018620888,-0.024780763,-0.034142334,-0.037531722,0.0059748404,0.030519677,0.0002590394,0.5420985,-0.0041886177,-0.012575782,0.0017228617,0.022789681,-0.026324436,0.009850362,-0.018750548,-0.021536004,-0.010470955,0.008206524,0.032663215,-0.04300769,0.03569871,0.010810833,-0.034573764,0.023726083,0.0036595687,-0.022681318,-0.028903648,-0.055151273,-0.026015604,-0.057181384,0.0032018656,0.032683216,0.03705544,0.011907045,-0.015617775,-0.004873172,0.029097961,-0.040807597,-0.012468881,0.0050722375,0.012693006,-0.033212412,-0.02266313,-0.0045285556,0.04121808,0.006311321,-0.00036054687,-0.049213607,-0.010215187,0.026799,-0.034043375,0.035136394,0.013242495,-0.009304019,0.006593182,0.026199494,-0.0011599875,-0.00078610005,0.014437919,-0.005292977,-0.006262644,0.014906078,-0.029722158,0.04560046,0.030372597,-0.013297803,-0.0035290024,-0.034762446,-0.0013272244,-0.036557857,-0.003218876,0.025148604,0.00016925078,-0.025012951,-0.003690402,-0.016920125,0.017465338,-0.014413835,0.012919873,0.0015755635,-0.038190693,-0.01943365,-0.036474645,0.031608116,-0.0017229479,-0.0039506448,-0.010598116,0.032403342,-0.0155339455,-0.016968034,-0.012837661,0.010811975,-0.01842548,-0.0047261342,0.021167243,0.031717148,0.06495131,-0.0015151148,-0.011179547,-0.02814814,0.024872724,-0.022706361,-0.007597859,0.05471965,0.0035708935,0.0099638635,0.007777582,0.026727729,0.008681804,0.0062056845,-0.025884591,0.04528559,0.04118475,-0.0094370255,0.057420854,-0.011219578,-0.014829867,-0.024661647,0.0100655565,-0.03677016,-0.044867527,-0.031959012,0.057659328,-0.013346576,0.0035054968,0.02485926,0.011418758,-0.02894271,-0.012367346,0.029047225,0.0742907,0.010925572,-0.016093625,-0.021754745,-0.085516535,-0.023153502,-0.028499516,0.0061683194,-0.03118903,-0.024414107,0.021739034,-0.03018322,0.05365019,0.0009348341,0.04784476,-0.06677067,0.007968584,-0.020590039,-0.040821098,0.013771893,-0.01339041,0.007714207,0.00542817,0.011162793,-0.015952498,-0.026567154,0.019165553,0.002627913,0.032931644,0.0354739,-0.029528864,-0.013432363,-0.0011630316,-0.010039643,-0.02445242,-0.056752723,-0.0045156684,-0.014612949,-0.0011321902,0.022507658,-0.02061075,-0.006181064,0.008745419,-0.018792495,0.037782032,-0.0031051524,-0.010621178,0.01994612,-0.022592684,-0.022680672,0.06927571,0.020703038,0.0044111726,-0.0043760617,-0.044632025,0.015157088,-0.06195339,-0.008842543,-0.018867865,0.021008935,0.0025054205,0.0078002205,-0.013465123,0.012629419,0.0017276384,-0.0019110793,-0.064509414,-0.022281049,0.025093418,0.013158779,-0.013262503,0.0151666785,0.011118459,0.0075154635,0.016863192,-0.012695567,0.008905953,-0.02384018,0.006222177,-0.032446843,-0.031713124,-0.022071827,0.028372617,0.020090465,0.026912576,-0.024315763,-0.07264672,0.006249847,-0.0095610265,-0.050477725,0.013086141,-0.032619312,0.017133627,-0.03289857,-0.030736709,-0.006683348,-0.005370269,-0.028998714,0.042592756,-0.04550501,0.0092358645,0.0068071564,-0.011409912,0.02945301,-0.0036628272,-0.011344637,0.02609595,0.03525425,0.024392204,0.0041142027,-0.0062578963,-0.020944884,0.009920121,-0.011860337,0.011996357,-0.010111607,-0.012217719,0.009073544,-0.054073136,0.0041372464,0.013663416,0.019865418,0.005859765,-0.019472564,-0.003999708,0.018104834,-0.036634997,-0.0060359184,-0.0016364753,-0.033230748,0.020360421,0.027684454,-0.03872953,0.05202163,0.00116856,-0.04181312,-0.0071512624,0.010086018,-0.09270486,-0.028955128,0.02582139,0.015193329,-0.01277016,0.023545172,-0.030224925,0.056952756,0.018301692,-9.635395e-05,0.0056914557,0.008994552,0.03176457,0.008327338,-0.013894567,0.012229044,0.04367849,0.034499492,-0.010195178,0.010681156,0.0058232667,-0.015927223,-0.0018348475,0.0077078836,-0.029281126,0.03668036,-0.0016051625,-0.012979936,-0.009537656,0.0583477,-0.02944343,0.0022865382,0.06404831,-0.0047271466,-0.03751758,0.004126095,-0.005039934,0.0012283977,-0.008822844,0.00018210888,-0.0444667,0.00055294257,-0.019018607,-0.029476723,0.043919366,0.0029221063,0.0046266,0.0045946985,0.027524902,-0.044515792,0.013032205,-0.03384059,-0.008122727,0.0076551656,-0.009019746,0.014238658,-0.010411267,-0.0029468962,-0.0016432588,-0.040648825,-0.033798058,-0.026593832,-0.0139502855,0.35628334,0.004263505,-0.010220954,0.0023570945,-0.007852889,0.007845867,-0.005205491,0.039407805,-0.0018113864,0.008949933,0.028941033,-0.002927273,0.0046690437,0.02877363,0.026652496,-0.0042118626,0.041311357,-0.00022133415,-0.001702418,-0.008600121,-0.011884581,0.012793326,-0.058116797,-0.008327064,-0.034245804,-0.03094398,-0.013359105,-0.010531341,-0.025688445,0.022948004,0.024490466,0.0075384197,-0.023993822,-0.0238222,0.02097102,-0.014593064,-0.015531934]', FALSE), -('e945916b-49b9-46e4-8355-9246b7aacd74', '47655720-be4c-499c-9761-74aa9ca45385', 1, '## Virtual interfaces +and `pbx-node3`, all running PVE 8.3.4 (build `8.3.4-1-lab1`).', '[0.029987032,0.052543923,-0.027798222,-0.015965158,0.010815871,-0.033928037,0.013944584,0.0014929341,0.024133565,-0.011102778,-0.055839792,0.01512647,0.006774336,-0.018977199,0.0024267114,-0.017300153,0.02198698,0.013408201,-0.012332967,-0.048355788,-0.007367199,0.04030113,7.136255e-05,0.05837086,-0.018500526,-0.010159976,0.00552001,-0.011567445,-0.009714936,-0.002799106,-0.03904441,0.00074404816,-0.09977069,0.034763448,-0.006808203,0.013808571,0.013360743,0.009662321,-0.034484275,-0.22693259,-0.036257807,0.021168383,0.032211106,0.017238097,0.033145968,0.01870976,0.020803388,-0.004954961,0.00042541398,-0.036345843,-0.07154779,0.011670496,-0.0054196347,-0.00914051,0.026170833,0.0037609688,0.041900236,-0.029129539,0.05436975,-0.042995665,-0.013300863,0.008141238,0.019820709,0.00072102086,-0.038472075,-0.018957715,0.017049164,0.005192495,-0.043978367,-0.0077148783,0.04019295,-0.035098318,0.016811203,0.013839549,-0.0030761007,-0.030695243,0.0014151636,-0.016234197,-0.068815075,-0.0068846894,-0.0085658515,0.0037551771,-0.019290406,-0.010177004,-0.009982743,-0.005134517,0.022620581,0.008703505,0.013927676,0.011474532,0.0044436036,0.0040684612,-0.00044464148,-0.04032088,0.017506493,-0.007738403,0.019029655,0.0053671696,0.010974065,-0.06955181,-0.016596675,-0.0024469264,0.0035441534,-0.0062573566,-0.03614369,0.014336981,0.06135105,0.0027792999,0.020536764,-0.00072986266,-0.010064159,-0.048753627,0.020900186,-0.028559044,0.009050378,-0.0235374,-0.021029567,-0.0121063525,0.03326012,0.039096694,-0.0052559343,0.054535992,-0.017239204,0.0029564146,0.019604752,0.03637978,-0.058109716,-0.059358615,0.052271955,-0.00798876,-0.019254044,0.024610909,0.03492338,-0.012809249,-0.0042968257,-0.0014624232,-0.009920806,-0.052229818,0.011611285,-0.06527087,0.026175406,-0.023966271,0.021090103,0.006054428,0.006492245,-0.015032189,-0.0005498197,-0.013513458,-0.013034269,0.0006550851,-0.03081427,-0.024880763,0.023303706,-0.000815861,-0.044431277,0.042749032,-0.006969236,0.02442107,-0.015668351,0.030317826,0.003869884,0.01734996,0.022225738,0.03377275,-0.034451846,-0.01755077,0.0034467191,0.0058769602,0.026252722,0.030151105,-0.011093292,0.01619609,-0.013232895,-0.029190136,0.049884506,0.025221094,-0.003951705,-0.018742217,0.005163985,-0.02527301,0.02436171,-0.01061151,0.067521624,-0.00019543852,-0.00287811,-0.017977681,-0.016632747,-0.033190325,0.016070906,0.0084391255,-0.003128078,0.02440857,0.038149208,0.0019769792,-0.0074693244,0.011201686,-0.016332323,0.01384695,0.018289339,0.041106742,-0.010829084,0.0030677184,-0.03459263,0.005166169,0.013083742,0.018463742,0.02221132,-0.005077158,-0.011062713,0.029075185,0.0492259,0.035846263,-0.009321476,-0.00047979754,-0.0052518514,-0.028773258,0.002054932,-0.0052780025,-0.019482018,0.012817029,-0.010225492,0.012379554,0.010076099,-0.037369482,0.06753641,0.021660263,-0.0065378896,0.023646848,0.0019233885,-0.027263282,0.0071689757,0.008811457,0.014295795,-0.030719379,0.0061504547,-0.10955436,0.0031083447,0.027803853,-0.00028229816,-0.0028661883,0.023929922,-0.020444365,-0.00082295615,-0.012950111,0.0051368293,0.01403823,-0.0020166168,0.0042065093,-0.033641443,-0.0068512503,-0.0063894265,0.023911553,-0.008229972,-0.015317819,0.008121538,0.021751553,-0.02056429,0.015589073,-0.028987091,0.017435228,0.011794181,0.025948389,-0.04507082,0.015879111,-0.008839237,0.009934571,-0.011004236,-0.013502637,0.018765315,-0.015408905,-0.041000444,0.005745293,-0.014037046,0.03370938,0.008151254,-0.0062371558,0.0037704415,-0.0016661132,0.00030430403,0.016157344,0.028420405,0.022633223,-0.016403664,-0.0351954,-0.04442497,-0.0050214087,0.050135,-0.0022356554,-0.0010670907,0.0038279714,-0.027421605,0.014787712,0.008963196,0.010584928,0.0014043303,-0.02530616,-0.028059224,0.02958785,0.0071419557,-0.014780792,-0.030237474,0.0695838,0.024454728,0.0038430116,-0.018183077,0.005780621,0.010073386,-0.018137995,0.0054370747,0.01884186,0.0022208109,0.018476903,0.015973052,-0.014074068,-0.020188736,-0.020615317,0.0016339789,0.02671945,-0.062595345,-0.018000403,-0.00075461983,-0.015373583,0.012256202,-0.019037757,0.012746969,0.01938665,0.039424513,-0.022157224,-0.0027215593,0.038598962,0.08505512,0.01708687,-0.02179607,-0.0026447736,0.0060861376,0.008554471,0.0043251114,0.01686807,0.023678267,-0.039437514,0.0069899494,0.002173018,0.009304136,0.032812253,-0.028310305,-0.023426259,-0.0062134797,-0.013695567,0.017816644,-0.0036593631,0.051038977,-0.00692082,0.01263049,0.040138677,-0.008355173,0.028052352,-0.03244117,-0.022914441,-0.008159354,0.026259303,-0.05826219,0.03383866,-0.04591919,0.00515456,-0.026209038,-0.005876844,0.036648825,0.0061194566,-0.0320926,0.038097195,0.0061090947,-0.017510423,0.056760956,-0.015847018,0.026985573,-0.017038448,-0.02217699,0.013588326,-0.0062193456,0.016890261,0.051436547,-0.026477158,0.023459697,-0.04046322,-0.0020280338,-0.022037184,-0.03116603,-0.016316194,0.017099181,0.020624341,0.031272598,0.001971664,0.061297983,-0.008316024,0.03166107,-0.008687762,0.037389487,-0.027533056,0.021600729,0.00078135554,-0.018850163,-0.024699349,-0.034081385,-0.037556957,0.0060014883,0.030564237,0.00023958273,0.54223007,-0.0039061822,-0.012682403,0.0017688036,0.022723962,-0.026405372,0.009666364,-0.018701747,-0.021484721,-0.0104568,0.008213848,0.03252151,-0.04295087,0.03573225,0.010896155,-0.034636986,0.023787988,0.0036318433,-0.022855965,-0.02898556,-0.05526505,-0.026055304,-0.057304602,0.003204481,0.032494612,0.03701337,0.011878789,-0.015507153,-0.0049823537,0.029153062,-0.040681474,-0.0124154035,0.0049258717,0.012703586,-0.033156283,-0.022613822,-0.0045680767,0.0413224,0.0063863155,-0.00029312816,-0.04884424,-0.010131711,0.026738849,-0.03419117,0.034845144,0.0132164275,-0.009291582,0.0066192853,0.026055492,-0.0010370443,-0.0006217372,0.014453897,-0.0052927705,-0.0062480452,0.014861346,-0.029598996,0.045426793,0.030571785,-0.013285962,-0.0034965149,-0.034832947,-0.00115386,-0.03643952,-0.0033080713,0.025246866,0.0002977325,-0.024913723,-0.0037442965,-0.016830996,0.017562786,-0.014298721,0.0131154135,0.001584834,-0.03821839,-0.019327281,-0.0365683,0.03156151,-0.0016935538,-0.003927478,-0.010682913,0.032560702,-0.01532061,-0.016799599,-0.012792017,0.011044706,-0.018357908,-0.004594969,0.021348119,0.031809874,0.065072164,-0.0013735206,-0.011126473,-0.028081981,0.02465062,-0.022801302,-0.007583891,0.054778054,0.0037766222,0.009936247,0.007860089,0.026682455,0.008615721,0.0063316096,-0.025927193,0.045301255,0.041975144,-0.009266082,0.05727615,-0.011340744,-0.014762037,-0.024873372,0.010222677,-0.037065335,-0.044756703,-0.032066446,0.057557188,-0.01322773,0.00349711,0.024734527,0.011462438,-0.028642604,-0.012245687,0.029259335,0.074221805,0.010927475,-0.016157955,-0.021741856,-0.08544509,-0.02297971,-0.028532276,0.0063282084,-0.03131322,-0.02423218,0.021866431,-0.030418653,0.053844012,0.0006453113,0.04800034,-0.06689527,0.007924424,-0.020403642,-0.040750217,0.013663008,-0.013367688,0.007665,0.005379293,0.011301854,-0.01603679,-0.026811343,0.019168919,0.002685191,0.032962378,0.035389535,-0.029762048,-0.0135013545,-0.001166368,-0.009773031,-0.024355685,-0.056692235,-0.0046519865,-0.0146512715,-0.0011793163,0.022501882,-0.020688344,-0.0060640187,0.008576151,-0.018939396,0.037685033,-0.0033922475,-0.010410791,0.020016303,-0.022936191,-0.022712875,0.06918842,0.020679297,0.003932458,-0.004201552,-0.044818986,0.015276933,-0.061729245,-0.008893989,-0.018875828,0.020769212,0.0025661585,0.0076658074,-0.013461055,0.012790894,0.0018860893,-0.0017378051,-0.06449917,-0.022269959,0.025106078,0.0131573025,-0.013060648,0.015290322,0.010990643,0.0075318166,0.017012428,-0.012642133,0.008995866,-0.023954827,0.006104217,-0.032104373,-0.031737216,-0.021858888,0.028239172,0.020021828,0.026679365,-0.024301175,-0.07302802,0.0061352435,-0.009663087,-0.050690796,0.013107201,-0.032392845,0.017130598,-0.032737095,-0.030626897,-0.0064404164,-0.005459411,-0.028901597,0.042482693,-0.045586582,0.009314917,0.0069771823,-0.01166771,0.02943794,-0.0035632146,-0.01146159,0.02610924,0.03511714,0.024407454,0.004226497,-0.0063520935,-0.020971427,0.009820728,-0.011981999,0.012018014,-0.01040599,-0.01231752,0.009046052,-0.054087,0.0043025766,0.013659567,0.019883761,0.005548834,-0.019363755,-0.0037853862,0.018062312,-0.036591694,-0.0059601013,-0.0017839243,-0.033091515,0.020320067,0.027586777,-0.03876859,0.052477777,0.0012813634,-0.041737862,-0.0070488662,0.009881692,-0.09269112,-0.028886538,0.025838362,0.015073837,-0.012520016,0.023593007,-0.029978348,0.056708075,0.018429091,-6.074947e-05,0.005653533,0.00894598,0.031697962,0.008452243,-0.013922922,0.012216576,0.04379857,0.034426287,-0.010120697,0.010560977,0.005655162,-0.01577457,-0.0019602303,0.007665857,-0.029340252,0.036586378,-0.0015170656,-0.012976158,-0.009688808,0.058448475,-0.029762577,0.0021481218,0.0638818,-0.004736841,-0.037571758,0.0040626894,-0.0049888496,0.0011974088,-0.00897734,0.00011624728,-0.044286046,0.0005784429,-0.018560603,-0.029465048,0.0440632,0.0031717566,0.004532111,0.0044306777,0.027452119,-0.044556946,0.012989454,-0.033803962,-0.008218519,0.0077747316,-0.009165199,0.014198242,-0.010366903,-0.0030667891,-0.001644669,-0.040691584,-0.033983383,-0.026372751,-0.013999144,0.3559142,0.004180649,-0.010120317,0.0020963463,-0.007914272,0.007809722,-0.0050083785,0.039297573,-0.0015453962,0.008936172,0.028969193,-0.003030596,0.004649884,0.02882555,0.026662132,-0.0042181965,0.041212417,-0.0002811181,-0.001529782,-0.008613526,-0.011858605,0.012772553,-0.058042187,-0.008356708,-0.034337677,-0.030870557,-0.013101106,-0.010550729,-0.025606198,0.02293176,0.024449455,0.0073754,-0.023942262,-0.02381696,0.021214226,-0.014776728,-0.015597505]', FALSE), +('9ecfb524-18b7-4c7f-a34b-15ab5bfe24fb', '9c6376c8-8b10-49d4-bbc2-72aa4d04e5e2', 1, '## Virtual interfaces | vmbr | purpose | config | |-------|---------------------|----------------------------------------------------| @@ -235,15 +235,20 @@ and `pbx-node3`, all running PVE 8.3.4 (build `8.3.4-1-lab1`).', '[0.02959492,0. - Corosync heartbeat rides vmbr0; keep the management iface out of the lab-iot VLAN. - Node roles: pbx-node1 = hypervisor + gateway, pbx-node2 = AIO - containers, pbx-node3 = GPU box.', '[0.04397578,0.0350588,-0.0064939163,-0.040404823,0.019003542,0.0031114612,0.04952937,0.014077573,0.003500468,0.02087035,-0.006495672,0.008165121,0.0036874576,-0.00095012446,-0.005510349,-0.021469569,0.013855447,0.003975463,0.0002369951,-0.010918173,-0.009453691,0.044356063,0.00330042,0.044622637,-0.024943097,0.013251113,0.018413337,0.018765368,0.0029221422,0.010764289,0.00038576368,0.0034379503,-0.01896997,0.026426293,-0.004766535,0.020981096,0.032068815,0.0030956767,-0.03871559,-0.14160123,-0.018285327,0.03468316,-0.034725063,-0.0023876096,0.0271906,0.01527618,-0.013847641,0.0137090385,-0.02341814,-0.015274811,-0.02995892,-0.01745206,-0.0075304694,0.0005099681,-0.004368885,-0.0026747447,-0.011315335,-0.060594346,0.00028485322,0.023092996,-0.014356769,0.0026118152,0.043950327,0.010198878,-0.031008923,0.010271307,0.025173163,0.0059064254,-0.036660604,-0.013512473,0.019457875,-0.03012046,-0.028373193,0.029239228,-0.00624988,-0.021529041,-0.00051976746,0.02722376,-0.05819976,-0.011723776,0.029318381,-1.583065e-05,0.024853753,-0.020588838,0.036672246,-0.0068900804,-0.0451983,0.02820572,-0.0028160003,-0.010213335,0.021121362,-0.00071767194,0.003399283,-0.041253515,-0.008452648,-0.010230649,-0.0024412607,-0.01239655,0.019990472,-0.033189557,0.008909427,-0.013325515,0.0023050932,-0.00324716,-0.008738513,0.021850515,0.029251074,0.018688451,-0.0051199063,0.040146116,-0.0114963185,-0.035707656,-0.009927832,-0.02250247,0.022575842,0.021941368,-0.019404367,0.0075182915,0.005237627,0.007138794,-0.009937721,-0.0060750446,0.0013464489,0.01014572,-0.0099516725,0.006804074,-0.034751408,-0.029820431,0.025862826,-0.01652697,-0.0009979322,0.037354793,0.013482836,0.032709677,0.011004824,-0.0012136506,-0.026367465,-0.02496901,0.0177057,-0.0635973,0.019124135,-0.02895204,0.034510333,-0.0069070836,0.0132018225,-0.017069241,-0.0072439625,0.012832277,-0.011636452,-0.009289343,-0.018834058,0.024963323,0.019352593,0.002798565,0.008555706,0.01601996,0.011722171,-0.008566825,-0.041339092,0.02576026,-0.019233063,0.004727417,0.037112024,0.012853204,-0.014141482,0.0013290915,0.022590838,7.372844e-05,0.013924394,-0.0012216938,-0.002661285,-0.002849852,-0.008283024,-0.01304431,0.03394685,0.044723045,0.028666357,-0.023387976,0.022737771,-0.030036766,0.008801296,-0.0144882435,0.013932439,-0.030158967,-0.00071268185,-0.027876485,0.019017944,-0.0010264842,-0.030816292,-0.00044869425,-0.009504163,0.010259222,-0.0069892723,-0.035996575,-0.015377237,9.592133e-06,0.0031818207,0.039278027,-0.024949322,0.023509674,-0.018555688,-0.0061358917,-0.0076345564,0.0021423008,-0.0052276696,0.010143035,-0.010041755,-0.016642528,-0.008140879,-0.010058661,0.030931268,0.015190228,0.002147112,-0.019686716,0.0020539286,0.01313873,-0.0011031099,0.013810212,-0.012384053,-0.016016904,0.023287855,0.0012423979,0.0014747701,-0.026843602,0.042145446,-0.005889781,-0.00866804,0.010435831,-0.012971349,-0.028796805,-0.003656082,-0.007252661,0.008590434,0.012552112,-0.020505795,-0.08663771,0.0040064203,-0.0007010737,0.016701736,0.003038237,0.023243448,0.010831017,0.014542363,-0.021786924,-0.0003379805,0.008625987,-0.014395526,-0.0026668967,-0.028828483,0.0138920415,-0.0134065,0.024773441,-0.009265495,-0.033964586,0.028043587,-0.0056980555,0.0011654281,0.0045355386,-0.0061687385,-0.002582752,0.007888905,0.0068687326,-0.017009389,-0.013979984,0.0024131935,-0.012156992,0.0103281755,-0.0063754786,0.011932748,-0.043394115,-0.040636137,-0.021847034,-0.0032077914,0.020686535,-0.0018250553,-0.026235085,0.014754938,-0.032489937,0.020426255,0.029299757,0.036579933,-0.0053862254,0.0034220768,-0.02245771,-0.039769243,-0.040984582,-0.031278875,-0.017896699,0.00048630888,0.00045758672,-0.017765716,0.029689126,0.0035045813,-0.005746695,0.02374197,0.011104333,-0.0033030938,0.0058287573,0.011902317,-0.030939557,-0.014673479,-0.027525995,0.0129911415,-0.00043921117,0.030595018,-0.001382385,0.016604956,-0.009620439,-0.0110657755,-0.03365259,-0.011677661,0.041178152,-0.010259174,0.0012228484,-0.0262033,0.004755367,0.026359262,0.005672946,-0.010226674,0.011856839,0.0096966745,0.015890034,0.0032386954,-0.048620004,0.005178078,-0.004230107,-0.013415242,-0.0082910955,0.0037585113,0.0061927126,0.038943395,-0.039698374,0.0057248073,0.0118590705,0.025057182,0.010234481,0.004087604,0.006624201,0.014994123,-0.06690101,-0.019477703,0.022460362,0.002454288,0.024244836,-0.032994173,0.008309081,0.008490978,-0.021033194,0.001622791,0.03726506,0.041999206,0.0014126463,0.025231615,0.002694246,0.0067054415,0.030221561,0.006568592,-0.027906302,-0.038167167,0.01687716,-0.008022722,0.022752315,-0.054433398,0.03323796,-0.015120089,0.02715123,0.04409114,0.001483584,-0.034325458,0.032618955,-0.01356619,0.022147663,0.019003777,-0.01420104,0.0014481251,-0.005839854,-0.02701445,0.019352132,-0.0098347375,-0.0043347757,0.03101393,-0.011081696,-0.008433992,-0.04421154,0.039046884,0.0023997405,-0.008858902,-0.013695325,-0.014267463,0.0021935,-0.004386832,-0.0013517089,0.029569918,-0.017797122,0.018482173,-0.0034735508,-0.0009858134,0.014042464,-0.023441087,-0.003434466,0.00093972456,0.00252916,-0.0048437086,0.005988409,0.00288121,0.008695307,-0.024791332,0.57010275,0.009944652,-0.015202783,-0.0073888362,-0.0051720934,-0.006540457,-0.0051750885,-0.018336352,0.017912947,-0.02563706,-0.020459523,0.011867122,0.002898238,-0.022903582,0.014754155,-0.013030549,0.034171917,0.0062941816,0.0047106263,-0.0005240775,-0.06570924,-0.012444672,-0.048212737,0.013466796,-0.025972169,0.010368355,0.01828419,0.015717812,-0.020395782,0.04323656,-0.02590938,-0.029821405,0.00264607,0.0237397,0.018161777,0.00013166347,-0.0070264377,0.039840236,0.017389068,-0.00065271894,-0.05369959,-0.011011749,-0.011108015,-0.0006024083,0.012540373,0.03910973,0.030060668,0.007988677,0.012601377,-0.005034047,0.014880115,0.0046679154,0.036817826,0.010077229,-0.023546418,0.009719596,0.008762973,0.029604962,0.0046397983,-0.010927649,0.0018577813,-0.012787615,-0.025977274,0.0019782986,0.054991283,-0.008565113,0.0076569095,-0.03514202,0.0032052614,-0.008196711,0.0026488106,0.013350231,0.01648974,0.004976865,-0.015777957,-0.03615822,0.013643743,-0.009382897,0.0013254602,-0.018140154,0.017621314,-0.0039165155,0.006336467,-0.013298998,0.00975438,-0.04641774,-0.010891187,0.0031965503,0.03237206,0.013103252,-0.030186106,-0.007060468,-0.001907255,-0.00017462761,-0.018704657,-0.014897423,0.016089233,-0.00044742614,0.030879773,0.05499397,0.0050986386,0.036266986,0.020066544,-0.03711551,0.025011845,0.07715245,-0.0069430084,-0.010307601,0.0077697295,-0.020627612,-0.050132602,-0.02767404,-0.052507732,-0.0032054982,-0.00020518544,0.021117307,-0.0001259387,-0.018254451,0.002893564,-0.019010637,-0.013706428,-0.018835226,0.023016162,0.05189061,0.008814647,0.034301646,0.014756927,-0.028442238,-0.0053670565,-0.034186874,-0.02541142,-0.023262033,-0.016198004,0.038900178,-0.003624874,0.049959946,0.0028746896,0.034590993,0.0021237873,0.0014721517,-0.0013829526,-0.023854068,0.0060082497,0.004865707,-0.0017177681,-0.017518613,-0.025934134,-0.01991703,0.045306824,-0.020238625,0.039522305,0.012563713,0.022197338,-0.029407995,-0.007898029,-0.009686351,-0.039551858,0.008037207,-0.018969,-0.016922195,0.02963765,0.010817027,0.011180812,0.0076768547,0.03345983,-0.025074095,-0.0054137837,-0.023184847,-0.02305496,-0.011130367,0.026779147,-0.005712372,0.0047147204,0.050600864,0.033242792,-0.015940158,0.016568338,-0.004044663,-0.0057661003,-0.042961773,-0.0098409755,5.6445835e-05,-0.014836312,-0.03026877,-0.010471568,-0.013533323,0.011228019,0.013494739,-0.041276127,-0.037937805,-0.005280325,-0.01099094,0.027039798,0.026298542,0.009828369,0.03314647,0.009352263,-0.012364822,0.0023148037,0.011532219,0.00026569996,0.035738166,-0.05406866,0.004589131,-0.03000763,0.027482735,-0.025751766,-0.007320674,-0.006268627,-0.05285134,0.0048910375,0.0026509643,-0.022010386,-0.016226633,-0.019546824,0.013539559,-0.007769181,-0.0139101865,-0.0067463983,-0.011751725,0.0046246573,-0.0013558463,-0.016938027,-0.019302836,-0.0042285305,-0.010860402,-0.006180652,0.004777605,0.0073780906,0.0018825687,-0.016818061,0.010235464,-0.006219952,-0.001970601,0.012730763,-0.030338857,0.014186867,-0.00480519,-0.020769792,0.011614557,0.009404562,-0.04950122,-0.07054562,-0.013889513,0.038268298,0.012446699,0.00550814,-0.006179563,0.03538921,-0.043006606,0.0024389168,-0.008447086,-0.029366339,-0.050875384,0.007304331,0.0038795327,0.043985974,0.010957261,0.015540557,-0.014418079,0.029248392,-0.039195735,-0.0025757977,0.033846103,0.028156767,0.0067163175,0.0045959926,-0.010448259,0.008463804,0.027015245,0.020947974,-0.0113210045,0.020560168,0.020400554,0.02841793,-0.021473914,0.02942241,0.034906037,0.03554701,-0.01947642,0.005691106,-0.034411497,-0.0057069487,-0.017776543,0.017973589,-0.028846076,0.011981209,0.028059296,-0.007690911,-0.008754663,0.10626493,-0.01350255,-0.039706197,0.02280699,0.03711568,-0.0059706266,0.01984745,-0.03399647,0.014173307,-0.0545683,0.022182468,-0.0065274546,0.0012501555,0.007464475,-0.03878635,0.03372287,0.0046481155,0.00781294,-0.007867271,-0.0035383997,-0.040967908,-0.027602222,-0.008067258,-0.010966945,0.010517322,0.00021638037,0.019025356,-0.00096760126,0.0073702713,0.02692087,-0.05236508,0.0017474268,-0.025389612,-0.044041414,0.5078585,0.015908917,0.009722361,-0.014049211,-0.02227187,0.014095489,-0.00870073,-0.011968146,-0.019289296,0.008555507,0.01939657,-0.02447589,0.0027613258,0.036234554,0.0018479085,-0.023729602,0.0063400078,0.009793128,-0.00090129714,-0.019700555,-0.012860261,0.006072897,0.017072408,-0.024905847,-0.05564787,-0.05579226,0.025382712,-0.024534263,-0.013011457,0.01229793,0.06848787,-0.0399684,-0.014310226,-0.010636556,0.0047574043,-0.032488443,-0.004674627]', FALSE); + containers, pbx-node3 = GPU box.', '[0.04379213,0.03501298,-0.0065856883,-0.040455338,0.019144287,0.0031653456,0.049371723,0.014008141,0.0035360309,0.020883977,-0.0066993064,0.008259171,0.003655189,-0.0009361683,-0.005422788,-0.021490546,0.013938179,0.003993111,0.00023068368,-0.010724461,-0.00941882,0.044496834,0.0031686113,0.044502754,-0.02496893,0.01343974,0.018319948,0.018906116,0.0030085447,0.010783817,0.0002926786,0.0035512734,-0.018945007,0.026469942,-0.004859327,0.02089452,0.031974286,0.002981376,-0.03879196,-0.1412062,-0.01822943,0.034810595,-0.034524072,-0.0024823574,0.027140409,0.015269189,-0.01392473,0.013778676,-0.023475925,-0.015315371,-0.029850645,-0.017265234,-0.0074432553,0.0004924784,-0.004377727,-0.0026342624,-0.011429763,-0.06083045,0.00020836275,0.023136996,-0.01426513,0.0027316762,0.043847706,0.01015943,-0.0310123,0.010160243,0.025124425,0.0057027424,-0.03664007,-0.013423894,0.019517068,-0.030190408,-0.028278269,0.02919869,-0.0062017343,-0.021588046,-0.000668368,0.027395738,-0.058086596,-0.011541748,0.029408168,-7.0651906e-05,0.024839414,-0.020406859,0.036559902,-0.006871202,-0.045155246,0.02834824,-0.0028095914,-0.010213391,0.021177912,-0.00068488246,0.0032459442,-0.041337926,-0.008590534,-0.010235275,-0.0022655781,-0.012538461,0.020057894,-0.033237424,0.008875672,-0.01330912,0.0024741162,-0.0029974112,-0.008799025,0.02187093,0.029330503,0.018582968,-0.0052849534,0.040165383,-0.011422391,-0.03575422,-0.009859835,-0.022444742,0.02260422,0.022021495,-0.0193531,0.007444546,0.0052397666,0.0072807097,-0.010007451,-0.0059236926,0.0013135929,0.010033785,-0.010045815,0.0068076365,-0.034750115,-0.029862287,0.025734058,-0.016580664,-0.0011171376,0.03738946,0.013359834,0.032694347,0.010978272,-0.0012477444,-0.026446478,-0.024949975,0.017683182,-0.063670225,0.019064514,-0.028962377,0.03455023,-0.0069099106,0.0132773025,-0.0170604,-0.0071867765,0.012852788,-0.011487926,-0.009331549,-0.018855246,0.02507654,0.01936159,0.0027540398,0.008675525,0.01599103,0.011648455,-0.008560862,-0.041268565,0.025572928,-0.01941058,0.0047143586,0.03707331,0.012850661,-0.014085253,0.0014915221,0.022508712,0.00020514664,0.013829457,-0.0011616864,-0.002618045,-0.0028404223,-0.008360428,-0.012867311,0.033970397,0.044674333,0.028488856,-0.023220152,0.022778708,-0.030084057,0.008763749,-0.014595983,0.013862054,-0.030040728,-0.0007178465,-0.027748732,0.01916165,-0.0010018646,-0.03087187,-0.00042460425,-0.009327124,0.010396919,-0.006950171,-0.035947848,-0.015513351,8.670581e-05,0.0032229633,0.039361138,-0.024903156,0.02343818,-0.018668016,-0.0062270486,-0.0077518397,0.0021912367,-0.00521828,0.0100869285,-0.010034429,-0.016621705,-0.008076162,-0.009998651,0.03092382,0.015158909,0.0024223179,-0.019686952,0.0021120051,0.013148371,-0.0011729518,0.013858669,-0.012239153,-0.015930397,0.023316052,0.001356248,0.001543668,-0.02672928,0.042172033,-0.0058260867,-0.008864326,0.010556205,-0.012998905,-0.028969076,-0.003521051,-0.007070315,0.008671028,0.012674299,-0.020689052,-0.08674181,0.0040869233,-0.0006280201,0.016764117,0.002846384,0.023279885,0.010824454,0.014433691,-0.021911912,-0.000333084,0.008630282,-0.014439646,-0.002780469,-0.02879455,0.013876583,-0.013227123,0.024842953,-0.009203866,-0.0339169,0.028297463,-0.0057136803,0.0010494959,0.0045704017,-0.0061390777,-0.0026918615,0.0078200875,0.006858635,-0.017035492,-0.013972849,0.0023647477,-0.01219599,0.010213316,-0.006435483,0.011853488,-0.043536622,-0.04057007,-0.021895008,-0.003158357,0.020656588,-0.001832057,-0.026174795,0.014815984,-0.03260371,0.020550646,0.029402861,0.036563423,-0.0054183067,0.0034469094,-0.022467092,-0.039554477,-0.041116796,-0.031148292,-0.017950846,0.00061176345,0.00050156284,-0.017565016,0.029610991,0.0034652115,-0.0056127254,0.02374935,0.011056626,-0.003398782,0.0058696982,0.011848088,-0.03085047,-0.014649028,-0.027476028,0.013001624,-0.000506848,0.030297577,-0.0015266892,0.016665531,-0.009705839,-0.0110952705,-0.033694226,-0.011614517,0.0411932,-0.010291534,0.0011672461,-0.026223505,0.0047598593,0.026455468,0.005556214,-0.010057288,0.011834407,0.009602498,0.015895864,0.0032555324,-0.048704363,0.0050954423,-0.004295015,-0.013454379,-0.008308784,0.0037682357,0.0062307306,0.038760677,-0.0395543,0.0057345405,0.011986152,0.025123632,0.010327511,0.004141566,0.0067102783,0.014979583,-0.066764995,-0.01947803,0.022481797,0.0026853222,0.024142664,-0.033053074,0.008277735,0.008476142,-0.02101638,0.0016127047,0.03729125,0.041898753,0.0014083255,0.025183877,0.00253944,0.0066428,0.030267932,0.006663456,-0.027864676,-0.038321238,0.016828816,-0.007894763,0.022862379,-0.054392353,0.033211038,-0.015096242,0.027071865,0.043991007,0.0014719276,-0.034321927,0.03271428,-0.013422095,0.02207196,0.019116672,-0.014116965,0.0014466635,-0.0058148378,-0.026986381,0.019271005,-0.009923836,-0.0044350643,0.031197982,-0.011011027,-0.008511121,-0.04408324,0.039028645,0.0024180769,-0.008806516,-0.013842091,-0.0143471025,0.002213573,-0.0043138736,-0.0013469612,0.029537914,-0.017835086,0.01844299,-0.0036916584,-0.0010589635,0.013946948,-0.023492089,-0.0034204333,0.000988939,0.002448038,-0.0051480276,0.0060663964,0.003060899,0.008633821,-0.024834013,0.57027656,0.00980358,-0.015243503,-0.007173334,-0.0052363183,-0.006617721,-0.005115083,-0.018410284,0.017962208,-0.025576744,-0.020433849,0.011911453,0.0028242287,-0.022893418,0.014732451,-0.013148625,0.03422923,0.006179198,0.0047555813,-0.00051747914,-0.06568946,-0.012360446,-0.048226267,0.013537863,-0.025839709,0.01044208,0.018203737,0.015617531,-0.020249298,0.04324622,-0.026105048,-0.029854134,0.0026845946,0.023824789,0.018075023,5.5929417e-05,-0.006962641,0.039979763,0.01756864,-0.0005311395,-0.053705897,-0.011029943,-0.010979803,-0.00048955216,0.012617642,0.039010365,0.029959891,0.007851104,0.012496631,-0.005100538,0.014840805,0.004735035,0.036708176,0.010018553,-0.023658251,0.009708119,0.008733291,0.02955751,0.0045516314,-0.010953517,0.002030825,-0.012794463,-0.025851892,0.0019724567,0.054930665,-0.008499174,0.007662676,-0.035145853,0.0032733176,-0.00804578,0.002650893,0.013410902,0.01663278,0.0050018965,-0.015516526,-0.03627866,0.01367645,-0.00936899,0.0013786881,-0.018066434,0.017440634,-0.0038902643,0.0063625677,-0.013190507,0.009728179,-0.04633301,-0.010772461,0.0031263328,0.032257814,0.01315563,-0.03032327,-0.0070955497,-0.0017249569,-0.00031159437,-0.018745784,-0.014896287,0.016103188,-0.0003736911,0.030777212,0.0549341,0.0052209166,0.036144767,0.020178705,-0.037092004,0.024938973,0.07698915,-0.006863723,-0.010258587,0.0077119623,-0.020485375,-0.050156087,-0.027690459,-0.052628983,-0.003343615,-9.608063e-05,0.021109257,-0.0002340067,-0.018252473,0.0028563072,-0.019066572,-0.0136122145,-0.018928481,0.022802545,0.05189452,0.008850422,0.03425909,0.014866279,-0.02800831,-0.0052953665,-0.03413678,-0.025344381,-0.023453344,-0.016047068,0.038953308,-0.0035431932,0.049938057,0.0029829782,0.03453705,0.0021924193,0.0012427717,-0.0014564211,-0.023773175,0.0057894546,0.0048425775,-0.00186618,-0.017551186,-0.025960177,-0.019830283,0.045275945,-0.020150535,0.03972674,0.012458671,0.02220919,-0.029349,-0.007982964,-0.0096291145,-0.039562557,0.007965385,-0.018946506,-0.017000139,0.029722357,0.010834135,0.011166967,0.0074313227,0.033546824,-0.025252054,-0.005461726,-0.02309702,-0.02324808,-0.011294983,0.026802847,-0.005649547,0.004693953,0.050584625,0.03313422,-0.015927136,0.016610887,-0.0041778153,-0.005841169,-0.04317394,-0.009828865,0.0001106376,-0.014920407,-0.030102,-0.010524197,-0.013512144,0.011156453,0.013434835,-0.041355222,-0.037849996,-0.005174598,-0.0111579355,0.027163042,0.026417376,0.00984155,0.033150703,0.009464413,-0.012467696,0.0022953167,0.011548634,0.000319596,0.035660528,-0.054150917,0.004760468,-0.029876811,0.02735747,-0.025899623,-0.007400819,-0.0061735814,-0.052855812,0.004915342,0.002612127,-0.02205326,-0.01618279,-0.01944186,0.013654972,-0.0077800658,-0.013904228,-0.006711603,-0.011957314,0.0046983543,-0.0014191373,-0.017009627,-0.019230314,-0.00425306,-0.010874771,-0.0062245033,0.0046814303,0.007042017,0.0018860972,-0.016848736,0.0103001045,-0.0061612977,-0.001883326,0.012752311,-0.029988484,0.014198018,-0.0049601523,-0.020791192,0.011667189,0.0094244275,-0.04951991,-0.07058656,-0.013895339,0.038372394,0.01247889,0.0054754876,-0.0061841127,0.035288706,-0.043069664,0.0025166138,-0.00855501,-0.029362565,-0.05072312,0.0073734457,0.003923896,0.044213697,0.010952408,0.01561924,-0.0143737355,0.02916898,-0.03931093,-0.0025103437,0.033888087,0.028243026,0.0069115045,0.0044684196,-0.010357072,0.008624773,0.027156124,0.021029381,-0.011557271,0.020610396,0.020352446,0.02847563,-0.021394065,0.02932848,0.034850758,0.035541806,-0.01941119,0.005706606,-0.03462182,-0.005762227,-0.017701618,0.0179555,-0.028882135,0.011971977,0.028161239,-0.0077785035,-0.008719721,0.10623805,-0.013715279,-0.039619375,0.022855837,0.03702485,-0.0061249775,0.019741403,-0.034066156,0.014132196,-0.054476403,0.022166265,-0.0066392794,0.0013510416,0.0074538826,-0.03877242,0.03350215,0.00466412,0.0076304604,-0.007808051,-0.003437277,-0.04083476,-0.027611654,-0.0080451,-0.0110199265,0.010460263,0.00039995235,0.018876152,-0.0010273822,0.0074551543,0.026777772,-0.05223302,0.0018031002,-0.025357803,-0.043943945,0.50795287,0.015812257,0.009698299,-0.014034333,-0.022297703,0.01404605,-0.008702107,-0.0119778775,-0.019313032,0.008545298,0.019436622,-0.024442995,0.0027988954,0.03631829,0.0017583496,-0.023791604,0.006108651,0.009755444,-0.00081292377,-0.01960602,-0.0127541665,0.0060060713,0.017119,-0.025016971,-0.055730104,-0.05568396,0.025258422,-0.02452944,-0.012896356,0.012190299,0.0685148,-0.039780017,-0.014252987,-0.010572552,0.004700601,-0.03244779,-0.0047204024]', FALSE); -INSERT INTO public.git_sources (id, url, kind, path, added_at) VALUES ('0dbf92c6-0beb-4c51-8fac-83aad72d31ff', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/deployments', 'local', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/deployments', '2026-09-04 16:57:47.725349+00:00'), -('385d0324-7c65-49b5-888f-af0aa06237da', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/homelab', 'local', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/homelab', '2026-09-04 16:57:47.725349+00:00'); +INSERT INTO public.git_sources (id, url, kind, path, added_at) VALUES ('04dd61f3-1020-4b9a-9463-f9a14ac75d27', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/deployments', 'local', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/deployments', '2026-09-11 04:16:38.361454+00:00'), +('cd22ec19-fdb1-4c8b-a9b7-8a3a7131f355', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/homelab', 'local', '/var/home/ducoterra/Projects/Personal/brain_of_reese/tests/fixtures/agent_kb/homelab', '2026-09-11 04:16:38.361454+00:00'); -INSERT INTO public.kb_overview (id, content, updated_at) VALUES (1, 'deployments: the lab Ansible inventory (host addresses and roles), the Obsidian BOR quadlet service definition, and the GitLab Runner CI setup. homelab: the rack7 Proxmox cluster networking (bridges, VLANs, DNS/DHCP), container notes (Uptime Kuma, Qwen 3.8 on llama.cpp), and the nightly restic backup configuration.', '2026-09-04 16:57:48.340325+00:00'); +INSERT INTO public.kb_overview (id, content, updated_at) VALUES (1, 'deployments: the lab Ansible inventory (host addresses and roles), the Obsidian BOR quadlet service definition, and the GitLab Runner CI setup. homelab: the rack7 Proxmox cluster networking (bridges, VLANs, DNS/DHCP), container notes (Uptime Kuma, Qwen 3.8 on llama.cpp), and the nightly restic backup configuration.', '2026-09-11 04:16:38.958679+00:00'); -INSERT INTO public.sources_meta (id, version, updated_at) VALUES (1, 1, '2026-09-04 16:57:48.340325+00:00'); +INSERT INTO public.sources_meta (id, version, updated_at) VALUES (1, 1, '2026-09-11 04:16:38.958679+00:00'); + +INSERT INTO public.folder_summaries (source, folder_path, summary, updated_at) VALUES ('deployments', '', 'The deployments folder contains three documents: a Lab Ansible Inventory, a GitLab Runner configuration for lab-ci, and a Mimir service for BOR Vault Sync Unit. These documents likely cover the setup and configuration for different deployment tasks. The information is organized by file path to help locate specific deployment details.', '2026-09-11 04:16:41.589015+00:00'), +('homelab', '', 'The folder contains documentation for setting up and managing a homelab environment. It covers Restic backups for Rack7, running Qwen 3.8 on llama.cpp, configuring Uptime Kuma, and details on Rack7 DNS and DHCP, as well as bridging Rack7 to a Proxmox cluster.', '2026-09-11 04:16:42.811028+00:00'), +('homelab', 'containers', 'This folder contains documentation for running Qwen 3.8 on llama.cpp and Uptime Kuma as a containerized service. The documents provide details on how to set up and manage these containers.', '2026-09-11 04:16:43.733422+00:00'), +('homelab', 'networking', 'The folder contains notes on Rack7 DNS and DHCP configuration, and documentation about setting up a Rack7 Proxmox cluster using VELA bridges.', '2026-09-11 04:16:44.659273+00:00'); diff --git a/tests/integration/test_agent_tools.py b/tests/integration/test_agent_tools.py index 16826bc..af0af61 100644 --- a/tests/integration/test_agent_tools.py +++ b/tests/integration/test_agent_tools.py @@ -1,19 +1,28 @@ """Integration: the agent DB accessors against real Postgres (phase 37; -the harness-aligned ``ls``/``read``/``grep`` surface, phase 70). +the harness-aligned ``ls``/``read``/``grep`` surface, phase 70; the +drill-down tree ``ls``, phase 94). -``list_catalog`` must order rows by ``(source, path)`` — the same order -as ``GET /api/docs`` — ``list_source_names`` must resolve the -registered source names (the scoped ``ls`` join), and ``find_document`` +``_source_document_rows`` must order a source's rows by ``path`` (the +file lines' catalog order), ``list_source_names`` must resolve the +registered source names (the registry join), and ``find_document`` must resolve a hit to the full document row (content included, for the never-truncated read) and return ``None`` for unknown pairs. Phase 70: the ``ls``/``read``/``grep`` tools are pinned here too — the locked parameter shape in ``AGENT_TOOLS``, and scripted ``ToolCallPiece``s -executed through ``run_agent`` against the real DB: ``ls`` scoped to a -registered source name (unknown name → refusal), ``read`` on the -canonical combined ``source/path`` form (first-slash split; a bare -source name and an unknown identity get the no-document refusal), and -``grep`` (``all_documents`` for a whole-KB search, ``find_document`` for -a scoped one). +executed through ``run_agent`` against the real DB. Phase 94: the +drill-down ``ls`` against the REAL tables — ``ls()`` lists the +registered sources (registry order, recursive counts, stored +source-root summaries from ``folder_summaries``), ``ls(source)`` / +``ls(source/folder)`` list one folder level (the SQL prefix logic: +subfolders = slash-boundary prefixes, counts = the recursive subtree, +file lines in catalog order, capped at 50 + the grep-pointer note), +and the refusals (unknown source segment → the no-source refusal; an +unknown folder → NOT-A_FOLDER with the parent's subfolders). +``read`` runs on the canonical combined ``source/path`` form +(first-slash split; a bare source name and an unknown identity get the +no-document refusal), and ``grep`` (``all_documents`` for a whole-KB +search, ``find_document`` for a scoped one) — both byte-identical +across the phase-94 change. Requires: podman compose up -d db """ @@ -30,7 +39,7 @@ from sqlalchemy import delete, text from sqlalchemy.orm import Session from app.config import Settings -from app.models import Document, GitSource +from app.models import Document, FolderSummary, GitSource from app.rag import agent from app.rag.agent import AGENT_TOOLS, AgentHolder, run_agent from app.rag.llm import LLMClient, RetryPiece, StreamPiece, ToolCallPiece @@ -55,11 +64,30 @@ def _doc(db: Session, source: str, path: str, title: str, content: str) -> Docum @pytest.fixture() def kb(db) -> Iterator[None]: - """Fresh documents table (chunks first — the FK) for these accessors.""" - db.execute(text("TRUNCATE chunks, documents")) + """Fresh documents + folder_summaries tables (chunks first — the FK) + for these accessors (phase 94: the drill-down ``ls`` reads the + stored summaries too).""" + db.execute(text("TRUNCATE chunks, documents, folder_summaries")) db.commit() yield - db.execute(text("TRUNCATE chunks, documents")) + db.execute(text("TRUNCATE chunks, documents, folder_summaries")) + db.commit() + + +@pytest.fixture() +def registry(db) -> Iterator[None]: + """A FRESH two-source registry (phase 94): the drill-down ``ls`` + top level IS the registry, so the table is truncated and re-seeded + around the tests in a controlled ``(added_at, id)`` order — + ``Deployments`` before ``Homelab`` (the top-level listing order).""" + db.execute(text("TRUNCATE git_sources")) + db.commit() + db.add(GitSource(url="https://github.com/reese/Deployments.git", kind="git")) + db.commit() + db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git")) + db.commit() + yield + db.execute(text("TRUNCATE git_sources")) db.commit() @@ -76,21 +104,24 @@ def src(db) -> Iterator[GitSource]: db.commit() -def test_list_catalog_orders_by_source_then_path(kb, db) -> None: +def test_source_document_rows_order_by_path_within_the_source(kb, db) -> None: + """Phase 94: the file lines' order — the source's rows in ``path`` + order (the old ``list_catalog``'s per-source ordering, now the + ``ls`` folder-level accessor's contract; a different source's rows + never leak in).""" _doc(db, "Zeta", "b/second.md", "Zeta B", "ZB") _doc(db, "Zeta", "a/first.md", "Zeta A", "ZA") _doc(db, "Alpha", "c/third.md", "Alpha C", "AC") db.commit() - assert agent.list_catalog(db) == [ - ("Alpha", "c/third.md", "Alpha C"), - ("Zeta", "a/first.md", "Zeta A"), - ("Zeta", "b/second.md", "Zeta B"), + assert agent._source_document_rows(db, "Zeta") == [ + ("a/first.md", "Zeta A"), + ("b/second.md", "Zeta B"), ] -def test_list_catalog_is_empty_without_rows(kb, db) -> None: - assert agent.list_catalog(db) == [] +def test_source_document_rows_is_empty_without_rows(kb, db) -> None: + assert agent._source_document_rows(db, "Zeta") == [] def test_list_source_names_resolves_registry_rows(db) -> None: @@ -238,30 +269,156 @@ async def _consume( return out -# ---------- ls (scoped through the real registry) ---------- +# ---------- ls (the drill-down tree, phase 94 — the real registry + DB) ---------- -def test_ls_scoped_to_registered_source_through_run_agent(kb, src, db) -> None: - _doc(db, "Homelab", "a.md", "A", "A-CONTENT") - _doc(db, "Other", "b.md", "B", "B-CONTENT") +def test_ls_top_level_lists_registered_sources_through_run_agent( + kb, registry, db +) -> None: + """No path: the TOP level against the real tables — registry order + (``(added_at, id)`` — Deployments before Homelab), recursive counts + (all of a source's documents), the stored source-root summary + (``folder_path = ''``) shown only when stored.""" + _doc(db, "Deployments", "a/one.md", "A1", "A1-CONTENT") + _doc(db, "Homelab", "x.md", "X", "X-CONTENT") + _doc(db, "Homelab", "y/z.md", "Z", "Z-CONTENT") + db.add(FolderSummary(source="Homelab", folder_path="", summary="Home lab notes.")) db.commit() - holder, llm = _run_call(db, "ls", {"path": "Homelab"}) + holder, llm = _run_call(db, "ls", {}) - # Offered: the first request carries AGENT_TOOLS (the 3-tool list). assert llm.requests[0][1] == AGENT_TOOLS - # Executed against the real DB: the listing filtered to the source. assert llm.requests[1][0][3]["content"] == ( - "1 documents:\nsource: Homelab | path: a.md | title: A" + "2 sources:\n" + "\n" + "Deployments — 1 documents\n" + "Homelab — 2 documents\n" + " Home lab notes." ) assert holder.tool_calls == 1 assert holder.read_docs == [] +def test_ls_top_level_empty_registry_through_run_agent( + kb, db, monkeypatch: pytest.MonkeyPatch +) -> None: + """No registered sources: the top level is the header line alone + (``0 sources:`` — the old ``0 documents:`` behavior preserved in + spirit), still counted. The env fallback (``BOR_GIT_SOURCES`` — the + operator's ``.env`` may name sources) is emptied for the test, so + the registry is genuinely empty.""" + import app.rag.git_sources as git_sources_mod + + db.execute(text("TRUNCATE git_sources")) + db.commit() + monkeypatch.setattr( + git_sources_mod, + "get_settings", + lambda: _settings(git_sources=""), + ) + _doc(db, "Orphan", "a.md", "A", "A-CONTENT") # indexed but unregistered + db.commit() + + holder, llm = _run_call(db, "ls", {}) + assert llm.requests[1][0][3]["content"] == "0 sources:" + assert holder.tool_calls == 1 + + +def test_ls_source_scope_lists_root_folder_through_run_agent(kb, registry, db) -> None: + """A registered source name: the source's ROOT folder — the direct + subfolders (path order, recursive counts, stored summaries attached) + + the root's own file lines in catalog order — against the real + tables; a registered source with no documents lists its header + line alone.""" + _doc(db, "Homelab", "backups/cron.md", "Cron", "CRON") + _doc(db, "Homelab", "backups/restic.md", "Restic", "RESTIC") + _doc(db, "Homelab", "networking/lan.md", "LAN", "LAN") + _doc(db, "Homelab", "readme.md", "Readme", "README") + db.add( + FolderSummary( + source="Homelab", folder_path="backups", summary="Backup notes." + ) + ) + db.commit() + + holder, llm = _run_call(db, "ls", {"path": "Homelab"}) + + assert llm.requests[1][0][3]["content"] == ( + "Homelab — 1 documents, 2 folders:\n" + "\n" + " backups/ — 2 documents: Backup notes.\n" + " networking/ — 1 documents\n" + "\n" + "source: Homelab | path: readme.md | title: Readme" + ) + assert holder.tool_calls == 1 + assert holder.read_docs == [] + + # A registered source with no documents: the header line alone. + holder0, llm0 = _run_call(db, "ls", {"path": "Deployments"}) + assert llm0.requests[1][0][3]["content"] == "Deployments — 0 documents, 0 folders:" + assert holder0.tool_calls == 1 + + +def test_ls_nested_folder_scope_drills_one_level_through_run_agent( + kb, registry, db +) -> None: + """A ``source/folder`` path: that folder's subfolders + own file + lines (identity = ``source/folder``) — the drill-down against the + real tables.""" + _doc(db, "Homelab", "networking/lan/a.md", "A", "A") + _doc(db, "Homelab", "networking/lan/b.md", "B", "B") + _doc(db, "Homelab", "networking/vpn/c.md", "C", "C") + db.commit() + + holder, llm = _run_call(db, "ls", {"path": "Homelab/networking"}) + + assert llm.requests[1][0][3]["content"] == ( + "Homelab/networking — 0 documents, 2 folders:\n" + "\n" + " networking/lan/ — 2 documents\n" + " networking/vpn/ — 1 documents" + ) + assert holder.tool_calls == 1 + assert holder.read_docs == [] + + # One level deeper. + holder2, llm2 = _run_call(db, "ls", {"path": "Homelab/networking/lan"}) + assert llm2.requests[1][0][3]["content"] == ( + "Homelab/networking/lan — 2 documents, 0 folders:\n" + "\n" + "source: Homelab | path: networking/lan/a.md | title: A\n" + "source: Homelab | path: networking/lan/b.md | title: B" + ) + assert holder2.tool_calls == 1 + + +def test_ls_folder_file_cap_through_run_agent(kb, registry, db) -> None: + """The cap end-to-end: 51 direct files in one folder cost 50 file + lines + the deterministic grep-pointer note, never 51.""" + for i in range(51): + _doc(db, "Homelab", f"big/f{i:03d}.md", f"T{i}", "BODY") + db.commit() + + holder, llm = _run_call(db, "ls", {"path": "Homelab/big"}) + + content = llm.requests[1][0][3]["content"] + lines = content.splitlines() + assert lines[0] == "Homelab/big — 51 documents, 0 folders:" + assert lines[2] == "source: Homelab | path: big/f000.md | title: T0" + assert lines[51] == "source: Homelab | path: big/f049.md | title: T49" + assert lines[52] == ( + "…and 1 more documents in this folder — use grep (pattern) to " + "find a specific one." + ) + assert len(lines) == 53 + assert holder.tool_calls == 1 + + def test_ls_scoped_unknown_source_refused_through_run_agent(kb, src, db) -> None: - """Phase 72: the no-source refusal now carries the teaching - parenthetical — the prefix byte-identical to the pre-phase-72 line; - still not counted.""" + """A ``path`` without ``/`` matching no source name is a refusal — + the extended line with the teaching parenthetical (phase 72, the + prefix byte-identical to the pre-phase-72 line); still not counted.""" _doc(db, "Homelab", "a.md", "A", "A-CONTENT") db.commit() @@ -274,11 +431,11 @@ def test_ls_scoped_unknown_source_refused_through_run_agent(kb, src, db) -> None assert holder.tool_calls == 0 and holder.read_docs == [] -def test_ls_path_like_scope_teaching_refusal_through_run_agent(kb, src, db) -> None: - """Phase 72: a ``/``-containing ``path`` is a document path, not a - source name — the ``LS_PATH_NOT_A_SOURCE`` teaching line (no - registry lookup needed), not counted, the tools stay offered on the - next request.""" +def test_ls_path_like_scope_unknown_source_gets_no_source_refusal(kb, src, db) -> None: + """Phase 94: a ``/`` now names a folder — the phase-72 document-path + teaching is DELETED; a ``source/…`` argument whose FIRST segment + names no registered source gets the no-source refusal (the segment + echoed), not counted, the tools stay offered.""" _doc(db, "Homelab", "a.md", "A", "A-CONTENT") db.commit() @@ -286,7 +443,30 @@ def test_ls_path_like_scope_teaching_refusal_through_run_agent(kb, src, db) -> N assert ( llm.requests[1][0][3]["content"] - == agent.LS_PATH_NOT_A_SOURCE.format(path="app/rag/importer.py") + == agent.NO_SOURCE_NOT_A_DIRECTORY.format(scope="app") + ) + assert holder.tool_calls == 0 and holder.read_docs == [] + assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered + + +def test_ls_unknown_folder_gets_not_a_folder_with_parents_subfolders( + kb, src, db, +) -> None: + """Phase 94: a folder segment matching no indexed prefix gets the + NOT-A_FOLDER teaching — the argument echoed, the source named, its + DIRECT subfolders listed (the self-correction list), not counted, + the tools stay offered.""" + _doc(db, "Homelab", "backups/cron.md", "Cron", "CRON") + _doc(db, "Homelab", "containers/caddy.md", "Caddy", "CADDY") + _doc(db, "Homelab", "networking/lan.md", "LAN", "LAN") + _doc(db, "Homelab", "readme.md", "Readme", "README") + db.commit() + + holder, llm = _run_call(db, "ls", {"path": "Homelab/netwoking"}) + + assert llm.requests[1][0][3]["content"] == ( + "'Homelab/netwoking' is not a folder — Homelab has: " + "backups/ containers/ networking/" ) assert holder.tool_calls == 0 and holder.read_docs == [] assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered diff --git a/tests/integration/test_chat_api.py b/tests/integration/test_chat_api.py index 0a29067..44c7f4e 100644 --- a/tests/integration/test_chat_api.py +++ b/tests/integration/test_chat_api.py @@ -943,7 +943,10 @@ def test_tool_execution_db_failure_yields_error_event( def boom(*_a: Any, **_k: Any) -> Any: raise RuntimeError("db exploded mid tool call") - monkeypatch.setattr(agent, "list_catalog", boom) + # Phase 94: the no-arg ``ls`` executes through ``ls_top`` — the + # failure hook moves with the rewrite (same contract: the tool + # frame goes out first, the structured error ends the turn). + monkeypatch.setattr(agent, "ls_top", boom) fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted try: _, _, frames = _stream_chat(client, QUESTION) diff --git a/tests/integration/test_import_docs_git.py b/tests/integration/test_import_docs_git.py index a5b2ebd..31c3b2e 100644 --- a/tests/integration/test_import_docs_git.py +++ b/tests/integration/test_import_docs_git.py @@ -104,6 +104,21 @@ def _stub_bump(monkeypatch: pytest.MonkeyPatch) -> list[None]: return bumps +def _stub_folder_summaries(monkeypatch: pytest.MonkeyPatch) -> list[dict]: + """Stub the phase-94 folder-summary regeneration (this file keeps + its no-real-DB / no-network style — the real generator would read + the global ``documents`` table and burn ``lite`` calls). Returns + the call record; the canned stats are the zero dict.""" + calls: list[dict] = [] + + async def fake_generate(db: object, llm: object, *, skip: bool = False) -> dict[str, int]: + calls.append({"skip": skip}) + return {"generated": 0, "failed": 0, "pruned": 0} + + monkeypatch.setattr(import_docs, "generate_folder_summaries", fake_generate) + return calls + + # --- repo_name ------------------------------------------------------------- @@ -293,6 +308,7 @@ def test_main_rows_branch_passes_ignore_map_to_import( fake_import = FakeImportSources() monkeypatch.setattr(import_docs, "import_sources", fake_import) _stub_bump(monkeypatch) + _stub_folder_summaries(monkeypatch) rc = import_docs.main([]) @@ -329,6 +345,7 @@ def test_main_git_sources_clone_then_import( fake_import = FakeImportSources() monkeypatch.setattr(import_docs, "import_sources", fake_import) bumps = _stub_bump(monkeypatch) + _stub_folder_summaries(monkeypatch) rc = import_docs.main([]) @@ -371,6 +388,7 @@ def test_main_cli_source_still_imports_manual_dir( fake_import = FakeImportSources() monkeypatch.setattr(import_docs, "import_sources", fake_import) bumps = _stub_bump(monkeypatch) + _stub_folder_summaries(monkeypatch) rc = import_docs.main(["--source", str(manual)]) diff --git a/tests/integration/test_import_docs_overview.py b/tests/integration/test_import_docs_overview.py index 4913f4f..d5f453f 100644 --- a/tests/integration/test_import_docs_overview.py +++ b/tests/integration/test_import_docs_overview.py @@ -23,6 +23,15 @@ and unchanged re-runs never bump (``sources_version=skipped``), and a failed ``lite`` never rolls the bump back. The counter is pinned to the migration-0010 seed (0) around every test by :func:`_reset_sources_version`. + +Phase 94 (task 02, line-extension house rule): the summary line now +ends with the folder-summary stats — +``folder_summaries=//`` when the gate fired +(this fixture's 2-doc source holds exactly ONE qualifying subtree: the +source root) or ``folder_summaries=skipped`` otherwise — so the line +pinned here gains that token, and a KB-changing run burns exactly ONE +extra ``lite`` call (the source-root folder summary, markdown files +never get a document summary). """ from __future__ import annotations @@ -98,10 +107,10 @@ def src(tmp_path: Path) -> Path: @pytest.fixture(autouse=True) def _clean_kb(db: Session) -> Iterator[None]: - db.execute(text("TRUNCATE chunks, documents, kb_overview")) + db.execute(text("TRUNCATE chunks, documents, kb_overview, folder_summaries")) db.commit() yield - db.execute(text("TRUNCATE chunks, documents, kb_overview")) + db.execute(text("TRUNCATE chunks, documents, kb_overview, folder_summaries")) db.commit() @@ -149,16 +158,24 @@ def test_changed_import_writes_overview_row( assert rc == 0 assert "added=2" in out - assert out.rstrip().endswith("overview=updated sources_version=1") + # Phase 94: the line gains the folder-stats token — the 2-doc source + # holds one qualifying subtree (the source root): 1 generated. + assert out.rstrip().endswith( + "overview=updated sources_version=1 folder_summaries=1/0/0" + ) assert _version(db) == 1 # phase 53: a changed import bumps exactly once - # Exactly one lite call — the overview itself (markdown files never - # get a summary, so nothing else may touch ``chat``). - assert len(llm.chat_calls) == 1 + # Exactly two lite calls — the overview + the source-root folder + # summary (markdown files never get a document summary, so nothing + # else may touch ``chat``). + assert len(llm.chat_calls) == 2 by_role = {m["role"]: m["content"] for m in llm.chat_calls[0]} assert "KB_OVERVIEW_MODE" in by_role["system"] # One line per doc: source — path — title (no summary for markdown). assert "MyDocs — alpha.md — Alpha" in by_role["user"] assert "MyDocs — beta.md — Beta" in by_role["user"] + by_role = {m["role"]: m["content"] for m in llm.chat_calls[1]} + assert "FOLDER_SUMMARY_MODE" in by_role["system"] + assert by_role["user"].splitlines()[0] == "Folder: MyDocs" # The model's outline lands in the single row. row = _row(db) assert row is not None @@ -177,16 +194,20 @@ def test_unchanged_reimport_does_not_call_lite( llm = FakeEmbedder() rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys) assert rc == 0 - assert out.rstrip().endswith("overview=updated sources_version=1") - assert len(llm.chat_calls) == 1 + assert out.rstrip().endswith( + "overview=updated sources_version=1 folder_summaries=1/0/0" + ) + assert len(llm.chat_calls) == 2 # overview + source-root folder summary assert _row(db) is not None # Same hashes → no KB change → no lite call, previous outline kept. rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys) assert rc == 0 assert "unchanged=2" in out - assert out.rstrip().endswith("overview=skipped sources_version=skipped") - assert len(llm.chat_calls) == 1 # no new lite call + assert out.rstrip().endswith( + "overview=skipped sources_version=skipped folder_summaries=skipped" + ) + assert len(llm.chat_calls) == 2 # no new lite call row = _row(db) assert row is not None and row.content == "Summary of MyDocs" assert _version(db) == 1 # phase 53: an unchanged re-run never bumps @@ -201,7 +222,9 @@ def test_lite_failure_is_fail_soft( good = FakeEmbedder() rc, out = _run_main(monkeypatch, good, ["--source", str(src)], capsys) assert rc == 0 - assert out.rstrip().endswith("overview=updated sources_version=1") + assert out.rstrip().endswith( + "overview=updated sources_version=1 folder_summaries=1/0/0" + ) previous = _row(db) assert previous is not None previous_content = previous.content @@ -213,8 +236,12 @@ def test_lite_failure_is_fail_soft( rc, out = _run_main(monkeypatch, bad, ["--source", str(src)], capsys) assert rc == 0 # a failed outline must not fail the import assert "updated=1" in out - assert out.rstrip().endswith("overview=failed sources_version=2") - assert len(bad.chat_calls) == 1 # the (failed) attempt was made + # Phase 94: the folder batch fails too (per-folder fail-soft) — the + # failed attempt counts into the stats, the previous row stays. + assert out.rstrip().endswith( + "overview=failed sources_version=2 folder_summaries=0/1/0" + ) + assert len(bad.chat_calls) == 2 # the (failed) attempts were made row = _row(db) assert row is not None assert row.content == previous_content # previous row untouched @@ -232,8 +259,10 @@ def test_limit_run_skips_overview( llm = FakeEmbedder() rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys) assert rc == 0 - assert out.rstrip().endswith("overview=updated sources_version=1") - assert len(llm.chat_calls) == 1 + assert out.rstrip().endswith( + "overview=updated sources_version=1 folder_summaries=1/0/0" + ) + assert len(llm.chat_calls) == 2 # An incomplete walk must not rewrite the outline (mirrors the # --prune-with---limit guard) — and must not advance the version. @@ -241,8 +270,10 @@ def test_limit_run_skips_overview( rc, out = _run_main(monkeypatch, llm, ["--source", str(src), "--limit", "1"], capsys) assert rc == 0 assert "updated=1" in out - assert out.rstrip().endswith("overview=skipped sources_version=skipped") - assert len(llm.chat_calls) == 1 # --limit never burns a lite call + assert out.rstrip().endswith( + "overview=skipped sources_version=skipped folder_summaries=skipped" + ) + assert len(llm.chat_calls) == 2 # --limit never burns a lite call row = _row(db) assert row is not None and row.content == "Summary of MyDocs" assert _version(db) == 1 # phase 53: --limit debug runs never bump @@ -262,7 +293,9 @@ def test_empty_source_without_row_creates_nothing( assert rc == 0 assert "files=0" in out - assert out.rstrip().endswith("overview=skipped sources_version=skipped") + assert out.rstrip().endswith( + "overview=skipped sources_version=skipped folder_summaries=skipped" + ) assert llm.chat_calls == [] # no KB → no outline, no wasted model call assert _row(db) is None # nothing created assert _version(db) == 0 # nothing changed → nothing bumped @@ -283,7 +316,9 @@ def test_prune_only_run_bumps_sources_version( rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys) assert rc == 0 assert "added=2" in out - assert out.rstrip().endswith("overview=updated sources_version=1") + assert out.rstrip().endswith( + "overview=updated sources_version=1 folder_summaries=1/0/0" + ) assert _version(db) == 1 # Delete one file; a --prune run drops exactly it: no add/update, @@ -292,6 +327,12 @@ def test_prune_only_run_bumps_sources_version( rc, out = _run_main(monkeypatch, llm, ["--source", str(src), "--prune"], capsys) assert rc == 0 assert "pruned=1" in out - assert out.rstrip().endswith("overview=skipped sources_version=2") + # Phase 94: the folder gate is the overview's (added + updated > 0 + # or empty table) — a prune-only re-walk with a populated table + # skips generation (the remaining 1-doc source stays summarized by + # its existing root row, which still describes it). + assert out.rstrip().endswith( + "overview=skipped sources_version=2 folder_summaries=skipped" + ) assert _version(db) == 2 # the prune-only change bumped exactly once assert _row(db) is not None # the outline row is untouched diff --git a/tests/integration/test_migration_0017.py b/tests/integration/test_migration_0017.py new file mode 100644 index 0000000..98b012d --- /dev/null +++ b/tests/integration/test_migration_0017.py @@ -0,0 +1,248 @@ +"""Integration: migration 0017 (folder_summaries) schema contract +(phase 94, task 01). + +Drives the **real Alembic engine** against the live dev database +(``podman compose up -d db``), mirroring the house pattern of +``test_migration_0016.py`` (information_schema assertions on the state +the migration must leave). The tests target the 0016 → 0017 step +explicitly so later migrations cannot break them: + +* upgrade 0016 → 0017 → the ``folder_summaries`` table exists with the + full column contract — PK ``(source, folder_path)`` (VARCHAR(120) / + VARCHAR(1000) NOT NULL, mirroring ``documents.source`` / + ``documents.path``), ``summary`` TEXT NOT NULL, ``updated_at`` + TIMESTAMPTZ NOT NULL with the now() server default (house style) — + while the 0016 ``ui_settings`` schema survives; +* inserted rows round-trip their values (a source-root row with + ``folder_path = ''`` and a nested-folder row); +* downgrade to 0016 → the table is GONE (A13 — reversible), the rest of + the schema (``ui_settings`` + ``api_tokens``) survives; +* upgrade back to 0017 → the table is back (round-trip). + +The ``alembic`` fixture guarantees the DB ends at head even if a test +fails or the process is interrupted. +""" +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest +from alembic.config import Config +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from alembic import command +from app.db import db_available + + +@pytest.fixture() +def alembic(db: Session) -> Iterator[Config]: + """Real Alembic config bound to the dev DB (URL from app settings). + + Starts at head (repairs an interrupted earlier run); teardown upgrades + to head no matter what happened, so the dev DB is never left below + head. + """ + if not db_available(): + pytest.skip("Postgres not reachable — run `podman compose up -d db` first") + cfg = Config() # no alembic.ini file — env.py gets the URL from app config + cfg.set_main_option("script_location", "alembic") + command.upgrade(cfg, "head") + try: + yield cfg + finally: + command.upgrade(cfg, "head") + + +def _version(db: Session) -> str | None: + return db.execute(text("SELECT version_num FROM alembic_version")).scalar() + + +def _table_exists(db: Session, table: str) -> bool: + return ( + db.execute( + text("SELECT 1 FROM information_schema.tables WHERE table_name = :t"), + {"t": table}, + ).scalar() + is not None + ) + + +def _column(db: Session, table: str, column: str) -> tuple[Any, ...] | None: + """(data_type, is_nullable, column_default, character_maximum_length) + for one table column.""" + row = db.execute( + text( + "SELECT data_type, is_nullable, column_default, character_maximum_length" + " FROM information_schema.columns" + " WHERE table_name = :t AND column_name = :c" + ), + {"t": table, "c": column}, + ).fetchone() + return tuple(row) if row is not None else None + + +def _pk_columns(db: Session, table: str) -> list[str]: + """The table's PRIMARY KEY columns in ordinal position.""" + rows = db.execute( + text( + "SELECT kcu.column_name" + " FROM information_schema.table_constraints tc" + " JOIN information_schema.key_column_usage kcu" + " ON kcu.constraint_name = tc.constraint_name" + " AND kcu.table_name = tc.table_name" + " WHERE tc.table_name = :t" + " AND tc.constraint_type = 'PRIMARY KEY'" + " ORDER BY kcu.ordinal_position" + ), + {"t": table}, + ).fetchall() + return [r[0] for r in rows] + + +def _clear_rows(db: Session) -> None: + db.execute(text("DELETE FROM folder_summaries")) + db.commit() + + +def test_upgrade_to_0017_creates_folder_summaries(db: Session, alembic: Config) -> None: + """Upgrade 0016 → 0017: the table exists with the full column + contract (PK ``(source, folder_path)`` mirroring + ``documents.source`` / ``documents.path``; TEXT summary NOT NULL; + TIMESTAMPTZ updated_at NOT NULL with the now() server default), and + the table is ABSENT at 0016 while the 0016 ``ui_settings`` schema + survives the upgrade.""" + command.downgrade(alembic, "0016") # start from the pre-0017 state + assert _version(db) == "0016" + assert not _table_exists(db, "folder_summaries"), ( + "folder_summaries must be absent at 0016" + ) + + command.upgrade(alembic, "0017") + assert _version(db) == "0017", "alembic_version must be at 0017" + assert _table_exists(db, "folder_summaries"), "the table must exist at 0017" + + source = _column(db, "folder_summaries", "source") + assert source is not None, "folder_summaries.source is missing" + assert source[0] == "character varying", "source must be VARCHAR" + assert source[1] == "NO", "source must be NOT NULL (PK part 1)" + assert source[2] is None, "source must have no server default" + assert source[3] == 120, "source must be String(120) — documents.source" + + folder = _column(db, "folder_summaries", "folder_path") + assert folder is not None, "folder_summaries.folder_path is missing" + assert folder[0] == "character varying", "folder_path must be VARCHAR" + assert folder[1] == "NO", "folder_path must be NOT NULL (PK part 2)" + assert folder[2] is None, "folder_path must have no server default" + assert folder[3] == 1000, "folder_path must be String(1000) — documents.path" + + summary = _column(db, "folder_summaries", "summary") + assert summary is not None, "folder_summaries.summary is missing" + assert summary[0] == "text", "summary must be TEXT" + assert summary[1] == "NO", "summary must be NOT NULL (never stored empty)" + + updated = _column(db, "folder_summaries", "updated_at") + assert updated is not None, "folder_summaries.updated_at is missing" + assert updated[0] == "timestamp with time zone", "updated_at must be TIMESTAMPTZ" + assert updated[1] == "NO", "updated_at must be NOT NULL" + assert updated[2] is not None and "now" in str(updated[2]), ( + "updated_at must carry the now() server default (house style)" + ) + + assert _pk_columns(db, "folder_summaries") == ["source", "folder_path"] + + # The 0016 schema survives the additive upgrade. + semantic = _column(db, "ui_settings", "ok_bg") + assert semantic is not None and semantic[3] == 7, ( + "ui_settings.ok_bg (0016) must survive the upgrade" + ) + + +def test_inserted_rows_round_trip(db: Session, alembic: Config) -> None: + """At 0017, a source-root row (``folder_path = ''``) and a nested + folder row round-trip their values, and the composite PK rejects a + duplicate (source, folder_path) pair.""" + command.upgrade(alembic, "head") + try: + db.execute( + text( + "INSERT INTO folder_summaries (source, folder_path, summary)" + " VALUES ('Homelab', '', 'Root summary.')" + ) + ) + db.execute( + text( + "INSERT INTO folder_summaries (source, folder_path, summary)" + " VALUES ('Homelab', 'deployments/ansible', 'Ansible summary.')" + ) + ) + db.commit() + rows = db.execute( + text( + "SELECT source, folder_path, summary, updated_at" + " FROM folder_summaries ORDER BY folder_path" + ) + ).fetchall() + assert len(rows) == 2, "both rows must be stored" + assert rows[0][0] == "Homelab" and rows[0][1] == "", ( + "the source-root row uses folder_path = ''" + ) + assert rows[0][2] == "Root summary.", "summary must round-trip verbatim" + assert rows[0][3] is not None, "updated_at must be stamped (server default)" + assert rows[1][1] == "deployments/ansible", ( + "a nested folder path must round-trip verbatim" + ) + assert rows[1][2] == "Ansible summary." + + with pytest.raises(IntegrityError): + db.execute( + text( + "INSERT INTO folder_summaries (source, folder_path, summary)" + " VALUES ('Homelab', 'deployments/ansible', 'dup')" + ) + ) + db.rollback() # the IntegrityError aborts the open transaction + finally: + _clear_rows(db) + + +def test_downgrade_to_0016_drops_the_table(db: Session, alembic: Config) -> None: + """Downgrade 0017 → 0016: the table is gone (A13 — fully + reversible) while the rest of the schema survives (the 0016 + ``ui_settings`` semantic columns, ``api_tokens``, ``documents``).""" + command.downgrade(alembic, "0016") + assert _version(db) == "0016" + assert not _table_exists(db, "folder_summaries"), ( + "folder_summaries must be dropped" + ) + + semantic = _column(db, "ui_settings", "accent_line") + assert semantic is not None and semantic[3] == 7, ( + "ui_settings.accent_line (0016) must survive the downgrade" + ) + token_col = _column(db, "api_tokens", "token_hash") + assert token_col is not None and token_col[0] == "character varying", ( + "api_tokens.token_hash must survive the downgrade" + ) + doc_path = _column(db, "documents", "path") + assert doc_path is not None and doc_path[3] == 1000, ( + "documents.path must survive the downgrade" + ) + + +def test_upgrade_round_trip_restores_the_table(db: Session, alembic: Config) -> None: + """Downgrade to 0016, then upgrade back to 0017: the table is back + with the column contract and PK intact.""" + command.downgrade(alembic, "0016") + command.upgrade(alembic, "0017") + assert _version(db) == "0017", "round-trip upgrade must land at 0017" + assert _table_exists(db, "folder_summaries"), "the table must be back" + + folder = _column(db, "folder_summaries", "folder_path") + assert folder is not None, "folder_summaries.folder_path must be back" + assert folder[0] == "character varying", "folder_path must be VARCHAR" + assert folder[1] == "NO", "folder_path must be NOT NULL after the round-trip" + assert folder[3] == 1000, "folder_path must be String(1000) after the round-trip" + assert _pk_columns(db, "folder_summaries") == ["source", "folder_path"] diff --git a/tests/integration/test_sync_api.py b/tests/integration/test_sync_api.py index 2ccffa0..6968875 100644 --- a/tests/integration/test_sync_api.py +++ b/tests/integration/test_sync_api.py @@ -48,9 +48,16 @@ and every failure path (git error, model down) never bumps. The counter is pinned to the migration-0010 seed (0) around every test by :func:`_reset_sources_version`. -The git / import / overview layers are monkeypatched in ``app.api.sync`` -(same fake style as ``test_import_docs_git.py``) — no real git, no LLM: -the runner's state machine and HTTP surface are under test. +The git / import / overview / folder-summary layers are monkeypatched +in ``app.api.sync`` (same fake style as ``test_import_docs_git.py``) — +no real git, no LLM: the runner's state machine and HTTP surface are +under test. Phase 94 (task 02): the folder-summary layer gets the same +treatment — the fake-import tests' canned summaries would otherwise +steer the REAL ``generate_folder_summaries`` at the global +``documents`` table and the real ``LLMClient`` (network); the +real-import tests (host temp dirs, deterministic ``FakeEmbedder``) keep +the real generator, with ``folder_summaries`` truncated around every +test (:func:`_clean_folder_summaries`). The admin client is used **as a context manager** on purpose: the background sync task lives on the app's event loop, so the loop must @@ -182,6 +189,19 @@ def clean_documents(db: Session) -> Iterator[None]: db.commit() +@pytest.fixture(autouse=True) +def _clean_folder_summaries(db: Session) -> Iterator[None]: + """Phase 94: the ``folder_summaries`` table is global state the real + generator (the real-import tests) writes — truncated around every + sync test so the change-gate / table-empty-gate assertions start + from a known (empty) table.""" + db.execute(text("TRUNCATE folder_summaries")) + db.commit() + yield + db.execute(text("TRUNCATE folder_summaries")) + db.commit() + + def _real_llm(monkeypatch: pytest.MonkeyPatch) -> None: """The pipeline's ``LLMClient`` becomes the deterministic in-process ``FakeEmbedder`` (real import, no network). ``FakeEmbedder`` @@ -276,6 +296,34 @@ class FakeOverview: return self.ok +class FakeFolderSummaries: + """Records every ``generate_folder_summaries`` call; canned stats. + + Phase 94 (task 02): keeps the fake-import tests at the deterministic + layer boundary — the real generator would read the global + ``documents`` table and call the (real) ``LLMClient`` over the + network. The generator only flushes, so the fake honours the + ``skip`` flag the same way (the zero stats, no side effects).""" + + ZERO = {"generated": 0, "failed": 0, "pruned": 0} + + def __init__(self, stats: dict[str, int] | None = None) -> None: + self.stats = stats if stats is not None else dict(self.ZERO) + self.llms: list[LLMClient] = [] + self.sessions: list[Session] = [] + self.skip_flags: list[bool] = [] + + async def __call__( + self, db: Session, llm: LLMClient, *, skip: bool = False + ) -> dict[str, int]: + self.skip_flags.append(skip) + if skip: + return dict(self.ZERO) + self.llms.append(llm) + self.sessions.append(db) + return dict(self.stats) + + def _fake_clone() -> tuple[list[tuple[str, Path]], object]: """A ``clone_or_pull`` that materialises a checkout with one .md file.""" calls: list[tuple[str, Path]] = [] @@ -327,6 +375,7 @@ def test_admin_sync_success_reports_full_detail( monkeypatch.setattr(sync_api, "import_sources", fake_import) fake_overview = FakeOverview(ok=True) monkeypatch.setattr(sync_api, "regenerate_overview", fake_overview) + monkeypatch.setattr(sync_api, "generate_folder_summaries", FakeFolderSummaries()) _login(sync_client) assert sync_client.get("/api/sync/status").json() == { @@ -399,6 +448,7 @@ def test_unchanged_kb_skips_overview_refresh( monkeypatch.setattr(sync_api, "import_sources", fake_import) fake_overview = FakeOverview(ok=True) monkeypatch.setattr(sync_api, "regenerate_overview", fake_overview) + monkeypatch.setattr(sync_api, "generate_folder_summaries", FakeFolderSummaries()) _login(sync_client) assert sync_client.post("/api/sync").status_code == 202 @@ -435,6 +485,7 @@ def test_double_trigger_while_running_returns_409( fake_import = FakeImportSources(ImportSummary(files=1, added=1), delay=0.5) monkeypatch.setattr(sync_api, "import_sources", fake_import) monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True)) + monkeypatch.setattr(sync_api, "generate_folder_summaries", FakeFolderSummaries()) _login(sync_client) assert sync_client.post("/api/sync").status_code == 202 @@ -551,12 +602,17 @@ def test_db_rows_win_over_env( fake_import = FakeImportSources(ImportSummary(files=1, added=1)) monkeypatch.setattr(sync_api, "import_sources", fake_import) monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True)) + fake_folders = FakeFolderSummaries() + monkeypatch.setattr(sync_api, "generate_folder_summaries", fake_folders) _login(sync_client) with caplog.at_level(logging.INFO, logger="app.api.sync"): assert sync_client.post("/api/sync").status_code == 202 body = _poll(sync_client, "success") + # Phase 94: the changed canned summary fired the (stubbed) folder + # step with the run's own session + the import's LLM client. + assert fake_folders.llms == [fake_import.llms[0]] assert clone_calls == [(db_url, tmp_path / "bor" / "managed")] assert fake_import.sources == [[tmp_path / "bor" / "managed"]] assert "env.example.com" not in str(body) # the env URL never reaches the UI @@ -584,6 +640,7 @@ def test_env_fallback_when_table_empty( fake_import = FakeImportSources(ImportSummary(files=1, added=1)) monkeypatch.setattr(sync_api, "import_sources", fake_import) monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True)) + monkeypatch.setattr(sync_api, "generate_folder_summaries", FakeFolderSummaries()) _login(sync_client) with caplog.at_level(logging.INFO, logger="app.api.sync"): @@ -988,6 +1045,7 @@ def test_sync_builds_ignore_map_by_root_string_with_union( fake_import = FakeImportSources(ImportSummary(files=1, added=1)) monkeypatch.setattr(sync_api, "import_sources", fake_import) monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True)) + monkeypatch.setattr(sync_api, "generate_folder_summaries", FakeFolderSummaries()) _login(sync_client) assert sync_client.post("/api/sync").status_code == 202 @@ -1022,6 +1080,7 @@ def test_sync_without_ignore_lists_passes_empty_map( fake_import = FakeImportSources(ImportSummary(files=1, added=1)) monkeypatch.setattr(sync_api, "import_sources", fake_import) monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True)) + monkeypatch.setattr(sync_api, "generate_folder_summaries", FakeFolderSummaries()) _login(sync_client) assert sync_client.post("/api/sync").status_code == 202 diff --git a/tests/integration/test_sync_folder_summaries.py b/tests/integration/test_sync_folder_summaries.py new file mode 100644 index 0000000..efa0747 --- /dev/null +++ b/tests/integration/test_sync_folder_summaries.py @@ -0,0 +1,664 @@ +"""Integration: the sync-time folder-summary wiring (phase 94, task 02). + +Extends the KB-overview sync pattern (``test_import_docs_overview.py`` +is the template) to the phase-94 folder summaries — both sync paths +regenerate ``folder_summaries`` change-gated (added + updated > 0) or +on an empty table (the first full run after migration 0017 / a +``--limit`` first walk), per-folder fail-soft, best-effort, and in the +run's own short-lived session (the phase-53 flush-then-caller-commits +convention — the generator only flushes, the sync path commits). + +Script path (``scripts.import_docs.main`` end to end, fake LLM, real +DB, explicit ``--source``): + +- a KB-changing import → one row per ≥ 2-doc subtree (the source root + + the 2-doc folder; the 1-doc folder gets none), committed in the + run's transaction, the summary line ending + ``folder_summaries=//``; +- an unchanged re-import → zero ``lite`` calls, + ``folder_summaries=skipped``, rows untouched; +- a subtree dropping below 2 docs after a changed re-walk → its row + pruned; +- one folder's ``lite`` failure → its previous row kept, the other + lands, exit code 0, stats ``1/1/0``; +- a ``--limit`` debug run → no generation, no rows, + ``folder_summaries=skipped``; +- a fresh (empty) table after a ``--limit`` first walk → an unchanged + full walk generates (the table-empty first-run trigger). + +API path (``POST /api/sync`` end to end, real import over a host temp +local dir, deterministic ``FakeEmbedder``): + +- a KB-changing sync → the rows land (visible via the test's own + session) and the status detail keeps its exact pre-phase key set + (no folder-summary surface — the stats are log-only); +- an unchanged re-sync → zero ``FOLDER_SUMMARY_MODE`` calls; +- a ``lite`` outage (one folder failing) → the failed folder's row is + kept, the run reports ``success`` (never ``failed``), and the + sources-version bump still lands (the bump is change-gated on the + KB, not on the summaries); +- an empty table after a populated sync (the migration-0017 scenario) + → an unchanged walk regenerates (the overview's API gate, purely + change-gated, does not). +""" +from __future__ import annotations + +import logging +import time +from collections.abc import Iterator +from datetime import datetime +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select, text +from sqlalchemy.orm import Session + +from app.api import sync as sync_api +from app.config import Settings +from app.db import SessionLocal, db_available +from app.main import app as fastapi_app +from app.models import FolderSummary, GitSource +from app.rag import git_sources as git_sources_resolver +from app.rag.llm import LLMError +from app.rag.sources_meta import current_sources_version +from scripts import import_docs +from tests.conftest import ADMIN_PASSWORD +from tests.fakes import FakeEmbedder + +# --- shared helpers --------------------------------------------------------- + + +def _folder_calls(llm: FakeEmbedder) -> list[list[dict[str, str]]]: + """The ``FOLDER_SUMMARY_MODE`` chat calls on the client (the marker + in the system prompt — the KB-overview marker never contains it).""" + return [ + msgs + for msgs in llm.chat_calls + if any( + "FOLDER_SUMMARY_MODE" in m.get("content", "") + for m in msgs + if m.get("role") == "system" + ) + ] + + +def _rows(db: Session) -> dict[tuple[str, str], str]: + """The stored folder summaries as ``{(source, folder_path): summary}``.""" + db.expire_all() + return { + (source, folder_path): summary + for source, folder_path, summary in db.execute( + select(FolderSummary.source, FolderSummary.folder_path, FolderSummary.summary) + ).all() + } + + +def _updated_at(db: Session, source: str, folder_path: str) -> datetime | None: + db.expire_all() + row = db.get(FolderSummary, (source, folder_path)) + return row.updated_at if row is not None else None + + +class FolderFailingChatEmbedder(FakeEmbedder): + """``lite`` stand-in that fails exactly one folder-summary call — + the one whose user message contains *fail_label* (a ``Folder: …`` + header) — and answers everything else (incl. the KB overview) + normally. Drives the per-folder fail-soft path.""" + + def __init__(self, fail_label: str) -> None: + super().__init__() + self.fail_label = fail_label + + async def chat( + self, messages: list[dict[str, str]], model: str | None = None + ) -> str: + user = next((m["content"] for m in messages if m.get("role") == "user"), "") + if self.fail_label in user: + self.chat_calls.append(list(messages)) + raise LLMError("simulated folder-summary outage (test sentinel)") + return await super().chat(messages, model) + + +# --- fixtures ---------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _clean_kb(db: Session) -> Iterator[None]: + """Global KB + registry state, truncated around every test (the + real import and the real generator write ``documents``/``chunks``/ + ``folder_summaries``; the API tests seed ``git_sources``).""" + db.execute( + text("TRUNCATE chunks, documents, kb_overview, folder_summaries, git_sources") + ) + db.commit() + yield + db.execute( + text("TRUNCATE chunks, documents, kb_overview, folder_summaries, git_sources") + ) + db.commit() + + +@pytest.fixture(autouse=True) +def _reset_sources_version() -> Iterator[None]: + """The sources version counter is global mutable state — pin it to + the migration-0010 seed (0) around every test (own session: both + sync paths bump through their own short-lived ``SessionLocal``). + Skips like the ``db`` fixture when Postgres is down.""" + if not db_available(): + pytest.skip("Postgres not reachable — run `podman compose up -d db` first") + session = SessionLocal() + try: + session.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1")) + session.commit() + yield + finally: + session.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1")) + session.commit() + session.close() + + +@pytest.fixture(autouse=True) +def _fresh_sync_state() -> Iterator[None]: + """The module-level status object + task are process-global: reset + them around every test (harmless for the script-path tests).""" + sync_api._status = sync_api.SyncStatus() + sync_api._task = None + yield + sync_api._status = sync_api.SyncStatus() + sync_api._task = None + + +@pytest.fixture() +def src(tmp_path: Path) -> Path: + """MyDocs: a/ (2 docs) + b/ (1 doc) → subtree counts root 3, a 2, b 1. + + md → no document-summary chat calls, so the ``lite`` traffic is + exactly the overview + the folder summaries.""" + root = tmp_path / "MyDocs" + (root / "a").mkdir(parents=True) + (root / "b").mkdir() + (root / "a" / "one.md").write_text("# A One\nFirst folder document.\n", encoding="utf-8") + (root / "a" / "two.md").write_text("# A Two\nSecond folder document.\n", encoding="utf-8") + (root / "b" / "three.md").write_text("# B Three\nLone folder document.\n", encoding="utf-8") + return root + + +def _run_main( + monkeypatch: pytest.MonkeyPatch, + llm: FakeEmbedder, + argv: list[str], + capsys: pytest.CaptureFixture[str], +) -> tuple[int, str]: + """Run ``import_docs.main`` with fresh settings, a fake LLM, and a + fail-loud git mock (``--source`` always wins, so git must stay + idle) — the phase-31 overview test's runner.""" + monkeypatch.setattr( + import_docs, + "get_settings", + lambda: Settings(_env_file=None), # pyright: ignore[reportCallIssue] + ) + + def _no_git(url: str, dest: Path | str) -> Path: + raise AssertionError("git sync must not run with explicit --source") + + monkeypatch.setattr(import_docs, "clone_or_pull", _no_git) + monkeypatch.setattr(import_docs, "LLMClient", lambda: llm) + rc = import_docs.main(argv) + return rc, capsys.readouterr().out + + +# --- script path: scripts/import_docs.py ------------------------------------- + + +def test_changed_import_generates_folder_rows( + db: Session, + src: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """A KB-changing import upserts one row per ≥ 2-doc subtree in the + run's own transaction — committed and visible afterwards — with the + stats on the summary line (PLAN §9).""" + llm = FakeEmbedder() + records: list[logging.LogRecord] = [] + + class _Sink(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + records.append(record) + + fs_logger = logging.getLogger("app.rag.folder_summaries") + sink = _Sink() + fs_logger.addHandler(sink) + fs_logger.setLevel(logging.INFO) + try: + rc, out = _run_main(monkeypatch, llm, ["--source", str(src)], capsys) + finally: + fs_logger.removeHandler(sink) + + assert rc == 0 + assert "added=3" in out + assert out.rstrip().endswith( + "overview=updated sources_version=1 folder_summaries=2/0/0" + ) + # Two FOLDER_SUMMARY_MODE calls — the source root (3 docs) and the + # 2-doc folder a; the 1-doc folder b gets no row (no lite burn). + calls = _folder_calls(llm) + assert len(calls) == 2 + headers = [c[1]["content"].splitlines()[0] for c in calls] + assert headers == ["Folder: MyDocs", "Folder: MyDocs/a"] + for c in calls: + assert "FOLDER_SUMMARY_MODE" in c[0]["content"] + # The rows land committed (the run's own session committed them) — + # one per ≥ 2-doc subtree, never empty, stamped. + rows = _rows(db) + assert set(rows) == {("MyDocs", ""), ("MyDocs", "a")} + assert all(rows.values()) + assert _updated_at(db, "MyDocs", "a") is not None + # The stats log line (PLAN §9 ample logging). + assert any( + "folder_summaries: generated=2 failed=0 pruned=0" in r.getMessage() + for r in records + ) + + +def test_unchanged_reimport_burns_zero_folder_calls( + db: Session, + src: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Same hashes → no KB change → the populated table stays untouched + and zero ``lite`` calls burn (overview AND folder summaries).""" + llm1 = FakeEmbedder() + rc, out = _run_main(monkeypatch, llm1, ["--source", str(src)], capsys) + assert rc == 0 + assert out.rstrip().endswith( + "overview=updated sources_version=1 folder_summaries=2/0/0" + ) + rows = _rows(db) + assert set(rows) == {("MyDocs", ""), ("MyDocs", "a")} + + llm2 = FakeEmbedder() + rc, out = _run_main(monkeypatch, llm2, ["--source", str(src)], capsys) + assert rc == 0 + assert "unchanged=3" in out + assert out.rstrip().endswith( + "overview=skipped sources_version=skipped folder_summaries=skipped" + ) + assert llm2.chat_calls == [] # zero lite calls, any mode + assert _rows(db) == rows # rows byte-identical + + +def test_subtree_dropping_below_two_docs_is_pruned( + db: Session, + src: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """A subtree that drops below the ≥ 2 rule on a changed re-walk + loses its (stale) row; the remaining qualifiers regenerate.""" + llm1 = FakeEmbedder() + rc, _ = _run_main(monkeypatch, llm1, ["--source", str(src)], capsys) + assert rc == 0 + assert set(_rows(db)) == {("MyDocs", ""), ("MyDocs", "a")} + + # a/ loses one of its two docs (below the ≥ 2 rule) while b's doc + # changes → the re-walk is a KB change, so generation runs and the + # stale a/ row is pruned; b (1 doc) still gets no row. + (src / "a" / "two.md").unlink() + (src / "b" / "three.md").write_text("# B Three\nChanged content.\n", encoding="utf-8") + llm2 = FakeEmbedder() + rc, out = _run_main(monkeypatch, llm2, ["--source", str(src), "--prune"], capsys) + + assert rc == 0 + assert "pruned=1" in out + assert "updated=1" in out + assert out.rstrip().endswith( + "overview=updated sources_version=2 folder_summaries=1/0/1" + ) + # Only the source root (the 2 remaining docs) still qualifies. + assert set(_rows(db)) == {("MyDocs", "")} + assert _updated_at(db, "MyDocs", "a") is None # the row is gone + + +def test_folder_lite_failure_keeps_previous_row_and_stays_green( + db: Session, + src: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """One folder's ``lite`` failure: its previous row is kept, the + other folder lands, and the run's exit code stays 0 — the stats + carry the failure (``1/1/0``).""" + llm1 = FakeEmbedder() + rc, _ = _run_main(monkeypatch, llm1, ["--source", str(src)], capsys) + assert rc == 0 + rows_before = _rows(db) + a_stamp_before = _updated_at(db, "MyDocs", "a") + root_stamp_before = _updated_at(db, "MyDocs", "") + assert a_stamp_before is not None and root_stamp_before is not None + + # a/ changes (a KB change → generation runs); lite fails for a/ + # only — its previous row must stay, the root row must land. + (src / "a" / "one.md").write_text("# A One\nChanged content.\n", encoding="utf-8") + llm2 = FolderFailingChatEmbedder(fail_label="Folder: MyDocs/a") + rc, out = _run_main(monkeypatch, llm2, ["--source", str(src)], capsys) + + assert rc == 0 # a failed folder must not fail the import + assert "updated=1" in out + assert out.rstrip().endswith( + "overview=updated sources_version=2 folder_summaries=1/1/0" + ) + rows_after = _rows(db) + assert rows_after["MyDocs", "a"] == rows_before["MyDocs", "a"] # kept + assert _updated_at(db, "MyDocs", "a") == a_stamp_before # untouched + root_stamp_after = _updated_at(db, "MyDocs", "") + assert root_stamp_after is not None and root_stamp_after > root_stamp_before + + +def test_limit_run_skips_folder_generation( + db: Session, + src: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """A ``--limit`` debug run (an incomplete walk) never generates — + no ``lite`` call at all, no rows, the line says ``skipped``.""" + llm = FakeEmbedder() + rc, out = _run_main(monkeypatch, llm, ["--source", str(src), "--limit", "2"], capsys) + assert rc == 0 + assert "added=2" in out + assert out.rstrip().endswith( + "overview=skipped sources_version=skipped folder_summaries=skipped" + ) + assert llm.chat_calls == [] # no lite call, any mode + assert _rows(db) == {} # an incomplete walk never writes rows + + +def test_empty_table_generates_on_unchanged_walk( + db: Session, + src: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """The table-empty first-run trigger: after a ``--limit`` first + walk (populated KB, empty table), an unchanged full walk generates + — for the folder summaries AND the missing outline, still never + bumping the version.""" + llm1 = FakeEmbedder() + rc, out = _run_main(monkeypatch, llm1, ["--source", str(src), "--limit", "3"], capsys) + assert rc == 0 + assert "added=3" in out + assert out.rstrip().endswith( + "overview=skipped sources_version=skipped folder_summaries=skipped" + ) + assert _rows(db) == {} + + llm2 = FakeEmbedder() + rc, out = _run_main(monkeypatch, llm2, ["--source", str(src)], capsys) + assert rc == 0 + assert "unchanged=3" in out + assert out.rstrip().endswith( + "overview=updated sources_version=skipped folder_summaries=2/0/0" + ) + assert set(_rows(db)) == {("MyDocs", ""), ("MyDocs", "a")} + assert len(_folder_calls(llm2)) == 2 + assert current_sources_version(db) == 0 # the unchanged walk never bumps + + +# --- API path: POST /api/sync ------------------------------------------------- + + +@pytest.fixture() +def sync_client() -> Iterator[TestClient]: + """Context-managed TestClient — one app event loop across requests + (the background task must survive between the POST and the polls).""" + with TestClient(fastapi_app) as client: + yield client + + +def _settings(sources_dir: str) -> Settings: + return Settings(_env_file=None, sources_dir=sources_dir) # pyright: ignore[reportCallIssue] + + +def _stub_env(monkeypatch: pytest.MonkeyPatch, git_sources: str = "") -> None: + """The resolver's env fallback, driven by a fresh ``Settings`` (the + dev ``.env`` never leaks in).""" + monkeypatch.setattr( + git_sources_resolver, + "get_settings", + lambda: Settings(_env_file=None, git_sources=git_sources), # pyright: ignore[reportCallIssue] + ) + + +def _seed_local(db: Session, path: Path) -> None: + """A ``kind=local`` row as the phase-38 API stores it: the expanded + absolute path in both ``path`` and the NOT-NULL ``url`` column.""" + db.add(GitSource(url=str(path), kind="local", path=str(path))) + db.commit() + + +def _capture_llm(monkeypatch: pytest.MonkeyPatch) -> list[FakeEmbedder]: + """The pipeline's ``LLMClient`` becomes a recorded + ``FakeEmbedder`` (real import + real generator, no network).""" + clients: list[FakeEmbedder] = [] + + def _factory() -> FakeEmbedder: + client = FakeEmbedder() + clients.append(client) + return client + + monkeypatch.setattr(sync_api, "LLMClient", _factory) + return clients + + +def _login(client: TestClient) -> None: + r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) + assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}" + + +def _poll(client: TestClient, want: str, timeout: float = 10.0) -> dict: + """Poll ``GET /api/sync/status`` until ``state == want`` (terminal). + + Any state other than ``running`` before the deadline fails loudly — + an unexpected ``failed`` must never be masked by the wait.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + body = client.get("/api/sync/status").json() + if body["state"] == want: + return body + assert body["state"] == "running", ( + f"unexpected state {body['state']!r} while waiting for {want!r}: {body}" + ) + time.sleep(0.05) + raise AssertionError(f"sync did not reach {want!r} within {timeout}s") + + +@pytest.fixture() +def local_dir(tmp_path: Path) -> Path: + """LocalDocs: a/ (2 docs) → subtree counts root 2, a 2 (one + qualifying source-root row + one folder row).""" + d = tmp_path / "LocalDocs" + (d / "a").mkdir(parents=True) + (d / "a" / "one.md").write_text("# A One\nfirst local file\n", encoding="utf-8") + (d / "a" / "two.md").write_text("# A Two\nsecond local file\n", encoding="utf-8") + return d + + +def test_api_changed_sync_generates_folder_rows( + sync_client: TestClient, + monkeypatch: pytest.MonkeyPatch, + db: Session, + local_dir: Path, +) -> None: + """A KB-changing sync upserts the rows (visible via the test's own + session) and keeps the status detail's exact pre-phase shape — the + folder stats are log-only, not a new status surface.""" + _seed_local(db, local_dir) + _stub_env(monkeypatch) + monkeypatch.setattr( + sync_api, + "get_settings", + lambda: _settings(str(local_dir.parent / "bor")), + ) + clients = _capture_llm(monkeypatch) + + _login(sync_client) + assert sync_client.post("/api/sync").status_code == 202 + body = _poll(sync_client, "success") + + assert body["error"] is None + # No new sync-status surface: the detail keeps its exact key set. + assert set(body["detail"]) == { + "files", "added", "updated", "unchanged", "pruned", "errors", + "chunks", "summaries", "summary_errors", "overview", + "sources_version", + } + assert body["detail"]["files"] == 2 + assert body["detail"]["added"] == 2 + assert body["detail"]["overview"] is True + assert body["detail"]["sources_version"] == 1 + # One LLM client for probe + import + overview + folder summaries; + # exactly two FOLDER_SUMMARY_MODE calls (root + the 2-doc a/ folder). + assert len(clients) == 1 + calls = _folder_calls(clients[0]) + assert len(calls) == 2 + headers = [c[1]["content"].splitlines()[0] for c in calls] + assert headers == ["Folder: LocalDocs", "Folder: LocalDocs/a"] + # The rows land committed — visible from the test's own session. + rows = _rows(db) + assert set(rows) == {("LocalDocs", ""), ("LocalDocs", "a")} + assert all(rows.values()) + assert current_sources_version(db) == 1 + + +def test_api_unchanged_resync_burns_zero_folder_calls( + sync_client: TestClient, + monkeypatch: pytest.MonkeyPatch, + db: Session, + local_dir: Path, +) -> None: + """Same files → unchanged re-sync: no ``lite`` call of any kind + (populated table), rows untouched, version unadvanced.""" + _seed_local(db, local_dir) + _stub_env(monkeypatch) + monkeypatch.setattr( + sync_api, + "get_settings", + lambda: _settings(str(local_dir.parent / "bor")), + ) + clients = _capture_llm(monkeypatch) + + _login(sync_client) + assert sync_client.post("/api/sync").status_code == 202 + _poll(sync_client, "success") + rows = _rows(db) + assert set(rows) == {("LocalDocs", ""), ("LocalDocs", "a")} + + assert sync_client.post("/api/sync").status_code == 202 + body = _poll(sync_client, "success") + assert body["detail"]["overview"] is False + assert body["detail"]["sources_version"] == 1 # unchanged → no bump + assert len(clients) == 2 + # Zero GENERATION calls — no folder summary, no KB overview. The + # only chat the re-sync's client makes is the phase-41 probe's ping. + assert _folder_calls(clients[1]) == [] + assert all( + "KB_OVERVIEW_MODE" not in m.get("content", "") + for msgs in clients[1].chat_calls + for m in msgs + if m.get("role") == "system" + ) + assert len(clients[1].chat_calls) == 1 + assert clients[1].chat_calls[0][0]["content"] == "ping" + assert _rows(db) == rows + assert current_sources_version(db) == 1 + + + +def test_api_folder_lite_failure_keeps_rows_stays_green_and_bumps( + sync_client: TestClient, + monkeypatch: pytest.MonkeyPatch, + db: Session, + local_dir: Path, +) -> None: + """A ``lite`` outage for one folder: the run stays ``success`` + (never ``failed``), the failed folder's previous row is kept, the + rest regenerates, and the sources-version bump still lands (the + bump is change-gated on the KB, not on the summaries).""" + _seed_local(db, local_dir) + _stub_env(monkeypatch) + monkeypatch.setattr( + sync_api, + "get_settings", + lambda: _settings(str(local_dir.parent / "bor")), + ) + clients = _capture_llm(monkeypatch) + + _login(sync_client) + assert sync_client.post("/api/sync").status_code == 202 + _poll(sync_client, "success") + rows_before = _rows(db) + a_stamp_before = _updated_at(db, "LocalDocs", "a") + root_stamp_before = _updated_at(db, "LocalDocs", "") + assert a_stamp_before is not None and root_stamp_before is not None + assert len(clients) == 1 # sync 1 used the recording factory + + # a/ changes (the KB changes) while lite fails for a/ only. + (local_dir / "a" / "one.md").write_text( + "# A One\nchanged local file\n", encoding="utf-8" + ) + monkeypatch.setattr( + sync_api, + "LLMClient", + lambda: FolderFailingChatEmbedder(fail_label="Folder: LocalDocs/a"), + ) + assert sync_client.post("/api/sync").status_code == 202 + body = _poll(sync_client, "success") # GREEN — a lite outage is fail-soft + + assert body["error"] is None + assert body["detail"]["updated"] == 1 + assert body["detail"]["sources_version"] == 2 # the bump was not blocked + assert current_sources_version(db) == 2 + rows_after = _rows(db) + assert rows_after["LocalDocs", "a"] == rows_before["LocalDocs", "a"] + assert _updated_at(db, "LocalDocs", "a") == a_stamp_before # kept as-is + root_stamp_after = _updated_at(db, "LocalDocs", "") + assert root_stamp_after is not None and root_stamp_after > root_stamp_before + + +def test_api_empty_table_first_sync_regenerates( + sync_client: TestClient, + monkeypatch: pytest.MonkeyPatch, + db: Session, + local_dir: Path, +) -> None: + """The migration-0017 scenario: the KB predates the table — wipe + the rows and re-sync an unchanged KB: the empty-table trigger + fires for the folder summaries (the overview's API gate, purely + change-gated, does not).""" + _seed_local(db, local_dir) + _stub_env(monkeypatch) + monkeypatch.setattr( + sync_api, + "get_settings", + lambda: _settings(str(local_dir.parent / "bor")), + ) + clients = _capture_llm(monkeypatch) + + _login(sync_client) + assert sync_client.post("/api/sync").status_code == 202 + _poll(sync_client, "success") + assert set(_rows(db)) == {("LocalDocs", ""), ("LocalDocs", "a")} + + db.execute(text("TRUNCATE folder_summaries")) + db.commit() + assert sync_client.post("/api/sync").status_code == 202 + body = _poll(sync_client, "success") + + assert body["detail"]["overview"] is False # unchanged → no overview + assert body["detail"]["sources_version"] == 1 # unchanged → no bump + assert len(clients) == 2 + assert len(_folder_calls(clients[1])) == 2 # the folders regenerated + assert set(_rows(db)) == {("LocalDocs", ""), ("LocalDocs", "a")} diff --git a/tests/unit/test_agent.py b/tests/unit/test_agent.py index e6d7c93..20f1bfe 100644 --- a/tests/unit/test_agent.py +++ b/tests/unit/test_agent.py @@ -2,15 +2,20 @@ harness-aligned ``ls``/``read``/``grep`` surface, phase 70). A scripted fake LLM (canned stream sequences) + monkeypatched -``list_catalog`` / ``list_source_names`` / ``find_document`` / +``ls_top`` / ``ls_folder`` / ``list_source_names`` / ``find_document`` / ``all_documents`` — no database, no network. Covers the loop mechanics: the ls → read (combined ``source/path``) → answer happy path (event order, holder state, the tools staying offered on every request — phase 45 removed the per-tool budgets, the assistant/tool message -history), the ``ls`` scoping (no-arg full catalog in the phase-63 -labeled-field format, a one-source scope, a known source with 0 -documents → ``0 documents:`` counted, an unknown-source refusal that -counts nothing), ``read`` on the canonical combined form (split at the +history), the phase-94 drill-down ``ls`` (no-arg top level = the +registered sources with counts + stored summaries in the pinned +``{N} sources:`` template, a source scope = its root folder — +subfolders + capped file lines in the pinned folder template —, a +``source/folder`` scope = one level deeper, a registered source with 0 +documents → the ``… — 0 documents, 0 folders:`` header counted, an +unknown-source refusal that counts nothing, the NOT-A-FOLDER teaching +with the parent's subfolders, the 50-file cap + grep-pointer note), +``read`` on the canonical combined form (split at the FIRST slash, full content, the bare-source-name refusal, the already-in-context dedupe, missing-args refusals), the phase-68 ``grep`` contract under its new name (the locked A5 pins: fixed substring, @@ -149,28 +154,35 @@ def test_agent_tools_names_and_parameters() -> None: # Task 05 (live gate iteration 2): the one-call-at-a-time discipline # clause (the harness prior batches calls; the loop executes one # per round — the extras count as unexecuted in the gate). + # Phase 94 (task 03): the description is the drill-down tree + # contract (pinned copy — the tool-surface revision, owner + # permission 2026-09-10, ``TODO.md`` L4): one level per call, + # sources at the top, folders + files below, the file-line format + # and the combined-identity handoff to read/grep intact. assert ls["description"] == ( - "List the indexed documents as `source: X | path: Y | " - "title: Z` lines. Call one tool at a time — wait for " - "this result before your next call." + "List the knowledge base as a tree, one level at a time. " + "With no path: the synced sources — each with its document " + "count and a summary of its contents. With a source name (no " + "'/'): that source's top-level folders and files. With a " + "`source/folder` path: that folder's subfolders and files. " + "Folder lines carry a summary of what the folder contains. " + "File lines are `source: X | path: Y | title: Z` — use the " + "combined `source/path` with `read` and `grep`. Call one tool " + "at a time — wait for this result before your next call." ) ls_params = ls["parameters"] assert ls_params["type"] == "object" assert ls_params["required"] == [] # path is optional assert set(ls_params["properties"]) == {"path"} assert ls_params["properties"]["path"]["type"] == "string" - # Phase 72: the description states the contract up front — the - # 'path' argument is a source name, not a file or directory path. - # Task 05 (live gate iteration 5): the cross-tool contrast clause - # (ls is the ONLY tool whose path is a source name — the model - # kept transferring that scope to grep's document identity). + # Phase 94 (task 03): the 'path' argument teaches the drill-down + # semantics — a source name lists that source's top level, a + # `source/folder` path drills one level deeper, omitted lists + # every source (pinned copy). assert ls_params["properties"]["path"]["description"] == ( - "Source name to list one source's documents (e.g. 'homelab') — " - "a source name, not a file or directory path; omit to list " - "every document. This is the only tool " - "whose `path` is a source name — for " - "`read` and `grep` it must be a document's " - "combined `source/path`." + "Optional — a source name (e.g. 'homelab') to list its top " + "level, or a `source/folder` path to drill down (e.g. " + "'homelab/active'). Omit it to list every source." ) read = by_name["read"]["function"] # Tool-calling fast loop (2026-09-04, controlled fixture gate): @@ -285,16 +297,11 @@ def test_refusal_constants_are_harness_aligned() -> None: assert agent.UNKNOWN_TOOL == "Unknown tool." assert agent.MISSING_READ_ARGS == "read requires a string argument 'path'." assert agent.MISSING_SEARCH_ARGS == "grep requires a string argument 'pattern'." - # Phase 72: the ls teaching-refusal templates, pinned byte-for-byte - # (task 01 — the read/grep suggestion templates below, task 02). - assert agent.LS_PATH_NOT_A_SOURCE == ( - "'{path}' looks like a document path, not a source name. The " - "'path' argument of ls filters by source name (e.g. 'homelab') — " - "omit it to list every document, or read a document by its " - "combined 'source/path' string." - ) - # The pre-phase-72 no-source line is the byte-identical prefix of - # the extended line — only the teaching parenthetical was appended. + # Phase 94 (task 03): the phase-72 document-path teaching refusal is + # DELETED (a ``/`` now names a folder — the drill-down contract); + # the no-source refusal stays byte-identical (the task's "existing + # refusal, teaching parenthetical intact" pin). + assert not hasattr(agent, "LS_PATH_NOT_A_SOURCE") assert agent.NO_SOURCE_NOT_A_DIRECTORY.startswith( "No source named '{scope}' — check the ls output." ) @@ -303,6 +310,11 @@ def test_refusal_constants_are_harness_aligned() -> None: "argument is a source name, not a directory — omit it to list " "every document.)" ) + # Phase 94 (task 03): the NOT-A-FOLDER drill-down teaching template, + # pinned byte-for-byte (argument echoed, parent's subfolders + # listed), and the pinned file-line cap constant. + assert agent.NOT_A_FOLDER == "'{arg}' is not a folder — {parent} has: {subfolders}" + assert agent.LS_MAX_FILE_LINES == 50 # Phase 72 (task 02): the read/grep "did you mean …?" suggestion # templates, pinned byte-for-byte, and the suggestion cap. assert agent.NO_DOCUMENT_DID_YOU_MEAN == ( @@ -348,11 +360,12 @@ def test_list_source_names_empty_registry(monkeypatch: pytest.MonkeyPatch) -> No def test_ls_then_read_then_answer(monkeypatch: pytest.MonkeyPatch) -> None: - catalog = [ - ("Deployments", "backups.md", "Backup Strategy"), - ("Homelab", "aws-route53.md", "AWS Route53 Records"), - ] - monkeypatch.setattr(agent, "list_catalog", lambda db: catalog) + # Phase 94 (task 03): the no-arg ``ls`` is the drill-down TOP level + # (the registered sources, registry order) — monkeypatched the way + # the phase-70 full-catalog listing used to be. + monkeypatch.setattr( + agent, "ls_top", lambda db: [("Deployments", 1, None), ("Homelab", 1, None)] + ) target = _doc("Homelab", "aws-route53.md", "AWS Route53 Records", "R53-CONTENT") calls: list[tuple[str, str]] = [] @@ -423,11 +436,7 @@ def test_ls_then_read_then_answer(monkeypatch: pytest.MonkeyPatch) -> None: assert msgs[3] == { "role": "tool", "tool_call_id": "call_1", - "content": ( - "2 documents:\n" - "source: Deployments | path: backups.md | title: Backup Strategy\n" - "source: Homelab | path: aws-route53.md | title: AWS Route53 Records" - ), + "content": "2 sources:\n\nDeployments — 1 documents\nHomelab — 1 documents", } # The second follow-up request carries the read call + the FULL text. msgs = llm.requests[2][0] @@ -448,7 +457,7 @@ def test_content_and_tool_call_in_one_stream_keeps_both( ) -> None: """Rare stream with content AND a tool call: the content stays (it was already emitted) and the tool still runs.""" - monkeypatch.setattr(agent, "list_catalog", lambda db: []) + monkeypatch.setattr(agent, "ls_top", lambda db: []) holder = AgentHolder() llm = ScriptedLLM( [ @@ -460,7 +469,7 @@ def test_content_and_tool_call_in_one_stream_keeps_both( pieces = asyncio.run(_run(llm, holder, _settings())) assert [type(p) for p in pieces] == [StreamPiece, ToolCallPiece, StreamPiece] assert holder.tool_calls == 1 # the tool ran despite the content - assert llm.requests[1][0][3]["content"] == "0 documents:\n" + assert llm.requests[1][0][3]["content"] == "0 sources:" # ---------- phase 74: client history between system and user ---------- @@ -519,7 +528,7 @@ def test_run_agent_history_survives_a_tool_round( """The tool rounds append assistant/tool messages to the SAME ``messages`` list — the prior history stays in place between the system prompt and the current question on the SECOND request too.""" - monkeypatch.setattr(agent, "list_catalog", lambda db: []) + monkeypatch.setattr(agent, "ls_top", lambda db: []) llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], [StreamPiece("content", "the answer")], @@ -534,22 +543,25 @@ def test_run_agent_history_survives_a_tool_round( ] -# ---------- ls: full catalog + scoping ---------- +# ---------- ls: the drill-down tree (phase 94, task 03) ---------- -def test_ls_full_catalog_format(monkeypatch: pytest.MonkeyPatch) -> None: - """No argument: the full catalog in the phase-63 labeled-field format - (``source: X | path: Y | title: Z``) — counted; no registry lookup.""" - catalog = [ - ("Deployments", "backups.md", "Backup Strategy"), - ("Homelab", "aws-route53.md", "AWS Route53 Records"), - ] - monkeypatch.setattr(agent, "list_catalog", lambda db: catalog) - - def _boom_sources(*_a: Any, **_k: Any) -> None: - raise AssertionError("no registry lookup for an unscoped ls") - - monkeypatch.setattr(agent, "list_source_names", _boom_sources) +def test_ls_top_level_lists_sources_with_summaries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No argument: the TOP level — the registered sources, registry + order, each ``{source} — {n} documents`` + the indented summary line + only when stored — the pinned template, counted; the registry IS + consulted (unlike the phase-70 full catalog, the top level is the + registry itself).""" + monkeypatch.setattr( + agent, + "ls_top", + lambda db: [ + ("Deployments", 0, None), + ("Homelab", 1, "The homelab notes."), + ], + ) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], @@ -557,22 +569,27 @@ def test_ls_full_catalog_format(monkeypatch: pytest.MonkeyPatch) -> None: ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"] == ( - "2 documents:\n" - "source: Deployments | path: backups.md | title: Backup Strategy\n" - "source: Homelab | path: aws-route53.md | title: AWS Route53 Records" + "2 sources:\n" + "\n" + "Deployments — 0 documents\n" + "Homelab — 1 documents\n" + " The homelab notes." ) assert holder.tool_calls == 1 -def test_ls_empty_catalog_says_zero_documents(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(agent, "list_catalog", lambda db: []) +def test_ls_empty_registry_says_zero_sources(monkeypatch: pytest.MonkeyPatch) -> None: + """No registered sources: the top level is the header line alone — + ``0 sources:`` (the old ``0 documents:`` behavior preserved in + spirit), still a counted result.""" + monkeypatch.setattr(agent, "ls_top", lambda db: []) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) - assert llm.requests[1][0][3]["content"] == "0 documents:\n" + assert llm.requests[1][0][3]["content"] == "0 sources:" assert holder.tool_calls == 1 @@ -583,13 +600,12 @@ def test_ls_empty_catalog_says_zero_documents(monkeypatch: pytest.MonkeyPatch) - ({"path": 7}, "non-string path"), ], ) -def test_ls_blank_path_lists_full_catalog( +def test_ls_blank_path_lists_top_level( monkeypatch: pytest.MonkeyPatch, arguments: dict[str, Any], label: str ) -> None: - """A blank (or non-string) ``path`` is treated as omitted — the full - catalog, counted (no refusal for an empty scope).""" - catalog = [("S", "a.md", "A")] - monkeypatch.setattr(agent, "list_catalog", lambda db: catalog) + """A blank (or non-string) ``path`` is treated as omitted — the top + level (the sources), counted (no refusal for an empty scope).""" + monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) monkeypatch.setattr(agent, "list_source_names", lambda db: ["S"]) holder = AgentHolder() llm = ScriptedLLM( @@ -597,21 +613,25 @@ def test_ls_blank_path_lists_full_catalog( [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) - assert ( - llm.requests[1][0][3]["content"] == "1 documents:\nsource: S | path: a.md | title: A" - ) + assert llm.requests[1][0][3]["content"] == "1 sources:\n\nS — 1 documents" assert holder.tool_calls == 1 -def test_ls_scoped_to_known_source(monkeypatch: pytest.MonkeyPatch) -> None: - """A known source name: the same listing filtered to that source — +def test_ls_source_scope_lists_root_folder(monkeypatch: pytest.MonkeyPatch) -> None: + """A registered source name (no ``/``): the source's ROOT folder — + subfolders (2-space-indented, path order, ``: {summary}`` only when + stored) + the root's own file lines in EXACTLY the + ``source: X | path: Y | title: Z`` format — the pinned template, counted.""" - catalog = [ - ("Deployments", "backups.md", "Backup Strategy"), - ("Homelab", "a.md", "A"), - ("Homelab", "b.md", "B"), - ] - monkeypatch.setattr(agent, "list_catalog", lambda db: catalog) + monkeypatch.setattr( + agent, + "ls_folder", + lambda db, source, folder: ( + [("backups", 2, "Backup notes."), ("networking", 1, None)], + [("Homelab", "readme.md", "Readme")], + 1, + ), + ) monkeypatch.setattr(agent, "list_source_names", lambda db: ["Deployments", "Homelab"]) holder = AgentHolder() llm = ScriptedLLM( @@ -620,9 +640,50 @@ def test_ls_scoped_to_known_source(monkeypatch: pytest.MonkeyPatch) -> None: ) asyncio.run(_run(llm, holder, _settings())) assert llm.requests[1][0][3]["content"] == ( - "2 documents:\n" - "source: Homelab | path: a.md | title: A\n" - "source: Homelab | path: b.md | title: B" + "Homelab — 1 documents, 2 folders:\n" + "\n" + " backups/ — 2 documents: Backup notes.\n" + " networking/ — 1 documents\n" + "\n" + "source: Homelab | path: readme.md | title: Readme" + ) + assert holder.tool_calls == 1 + + +def test_ls_nested_folder_scope_lists_one_level_deeper( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A ``source/folder`` path: that folder's subfolders + own file + lines, identity = ``source/folder`` (the same template as the + root), counted; the fetchers are the source-scoped ones.""" + + def _rows(db: Any, source: str) -> list[tuple[str, str]]: + assert (source, db) == ("Homelab", None) + return [ + ("networking/lan.md", "LAN"), + ("networking/vpn.md", "VPN"), + ] + + monkeypatch.setattr(agent, "_source_document_rows", _rows) + monkeypatch.setattr( + agent, "_source_folder_summaries", lambda db, source: {"networking": "Network notes."} + ) + monkeypatch.setattr(agent, "list_source_names", lambda db: ["Homelab"]) + holder = AgentHolder() + llm = ScriptedLLM( + [ + ToolCallPiece( + id="call_1", name="ls", arguments={"path": "Homelab/networking"} + ) + ], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm, holder, _settings())) + assert llm.requests[1][0][3]["content"] == ( + "Homelab/networking — 2 documents, 0 folders:\n" + "\n" + "source: Homelab | path: networking/lan.md | title: LAN\n" + "source: Homelab | path: networking/vpn.md | title: VPN" ) assert holder.tool_calls == 1 @@ -631,9 +692,13 @@ def test_ls_scoped_known_source_with_zero_docs_counts( monkeypatch: pytest.MonkeyPatch, ) -> None: """A registered source with no indexed documents is KNOWN (the - registry is the source of truth, not the catalog): it lists as - ``0 documents:`` — a valid, counted result, not a refusal.""" - monkeypatch.setattr(agent, "list_catalog", lambda db: [("Other", "a.md", "A")]) + registry is the source of truth, not the catalog): it lists its + header line alone (``… — 0 documents, 0 folders:`` — the old + ``0 documents:`` behavior preserved in spirit) — a valid, counted + result, not a refusal.""" + monkeypatch.setattr( + agent, "ls_folder", lambda db, source, folder: ([], [], 0) + ) monkeypatch.setattr(agent, "list_source_names", lambda db: ["Homelab", "Other"]) holder = AgentHolder() llm = ScriptedLLM( @@ -641,7 +706,7 @@ def test_ls_scoped_known_source_with_zero_docs_counts( [StreamPiece("content", "ans")], ) asyncio.run(_run(llm, holder, _settings())) - assert llm.requests[1][0][3]["content"] == "0 documents:\n" + assert llm.requests[1][0][3]["content"] == "Homelab — 0 documents, 0 folders:" assert holder.tool_calls == 1 # an executed ls, not a refusal assert llm.requests[1][1] == AGENT_TOOLS @@ -650,7 +715,6 @@ def test_ls_scoped_unknown_source_refused(monkeypatch: pytest.MonkeyPatch) -> No """A ``path`` without ``/`` matching no source name is a refusal — the extended line with the teaching parenthetical (phase 72), not counted, the round cap bounds its repetition.""" - monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")]) monkeypatch.setattr(agent, "list_source_names", lambda db: ["S"]) holder = AgentHolder() llm = ScriptedLLM( @@ -666,20 +730,14 @@ def test_ls_scoped_unknown_source_refused(monkeypatch: pytest.MonkeyPatch) -> No assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered -def test_ls_path_like_scope_gets_document_path_teaching_refusal( +def test_ls_path_like_scope_unknown_source_gets_no_source_refusal( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Phase 72: a stripped scope containing ``/`` looks like a document - path (the incident's ``ls(path='app/rag/importer.py')``) — a source - name is a directory basename and can never contain one, so this gets - the ``LS_PATH_NOT_A_SOURCE`` teaching line with the argument echoed; - no registry lookup, counts in nothing, tools stay offered.""" - monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")]) - - def _boom_sources(*_a: Any, **_k: Any) -> None: - raise AssertionError("no registry lookup for a path-like scope") - - monkeypatch.setattr(agent, "list_source_names", _boom_sources) + """Phase 94: a ``/`` now names a folder, so the phase-72 + document-path teaching is DELETED — a ``source/…`` argument whose + FIRST segment names no registered source gets the no-source refusal + (the segment echoed), counted in nothing, tools stay offered.""" + monkeypatch.setattr(agent, "list_source_names", lambda db: ["S"]) holder = AgentHolder() llm = ScriptedLLM( [ @@ -695,7 +753,7 @@ def test_ls_path_like_scope_gets_document_path_teaching_refusal( assert holder.tool_calls == 0 # a refusal counts in nothing assert ( llm.requests[1][0][3]["content"] - == agent.LS_PATH_NOT_A_SOURCE.format(path="app/rag/importer.py") + == agent.NO_SOURCE_NOT_A_DIRECTORY.format(scope="app") ) assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered @@ -706,8 +764,7 @@ def test_ls_dot_scope_gets_not_a_directory_teaching_refusal( """Phase 72: ``ls(path='.')`` (the incident's second round — no ``/``, no matching source) gets the extended no-source refusal with the teaching parenthetical, ``'.'`` echoed — not counted, tools stay - offered.""" - monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")]) + offered (unchanged by phase 94).""" monkeypatch.setattr(agent, "list_source_names", lambda db: ["S"]) holder = AgentHolder() llm = ScriptedLLM( @@ -723,6 +780,312 @@ def test_ls_dot_scope_gets_not_a_directory_teaching_refusal( assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered +def test_ls_unknown_top_level_folder_gets_not_a_folder_teaching( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 94: a ``source/…`` argument whose TOP-LEVEL folder segment + matches no indexed prefix gets the NOT-A-FOLDER teaching — the + argument echoed, the source named, its direct subfolders listed so + the model self-corrects in the next round; not counted, tools stay + offered.""" + monkeypatch.setattr( + agent, + "_source_document_rows", + lambda db, source: [ + ("backups/cron.md", "Cron"), + ("containers/caddy.md", "Caddy"), + ("networking/lan.md", "LAN"), + ], + ) + monkeypatch.setattr(agent, "_source_folder_summaries", lambda db, source: {}) + monkeypatch.setattr(agent, "list_source_names", lambda db: ["Homelab"]) + holder = AgentHolder() + llm = ScriptedLLM( + [ + ToolCallPiece( + id="call_1", name="ls", arguments={"path": "Homelab/netwoking"} + ) + ], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm, holder, _settings())) + assert holder.tool_calls == 0 # a refusal counts in nothing + assert llm.requests[1][0][3]["content"] == ( + "'Homelab/netwoking' is not a folder — Homelab has: " + "backups/ containers/ networking/" + ) + assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered + + +def test_ls_unknown_nested_folder_gets_not_a_folder_with_nested_parent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 94: a nested miss names the DEEPEST existing ancestor — + ``source/folder`` — and lists ITS direct subfolders (bounded: the + parent's own listing, no new flood path).""" + monkeypatch.setattr( + agent, + "_source_document_rows", + lambda db, source: [ + ("networking/lan/a.md", "A"), + ("networking/vpn/b.md", "B"), + ("readme.md", "Readme"), + ], + ) + monkeypatch.setattr(agent, "_source_folder_summaries", lambda db, source: {}) + monkeypatch.setattr(agent, "list_source_names", lambda db: ["Homelab"]) + holder = AgentHolder() + llm = ScriptedLLM( + [ + ToolCallPiece( + id="call_1", name="ls", arguments={"path": "Homelab/networking/lan/x"} + ) + ], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm, holder, _settings())) + assert holder.tool_calls == 0 + assert llm.requests[1][0][3]["content"] == ( + "'Homelab/networking/lan/x' is not a folder — " + "Homelab/networking/lan has: none" + ) + + +def test_ls_file_path_scope_gets_not_a_folder(monkeypatch: pytest.MonkeyPatch) -> None: + """Phase 94: a document's OWN path is never a folder (nothing starts + with ``path + '/'``) — ``ls`` of a file path refuses with the + NOT-A-FOLDER teaching (the parent's subfolders listed).""" + monkeypatch.setattr( + agent, + "_source_document_rows", + lambda db, source: [("notes.md", "Notes"), ("a/b.md", "B")], + ) + monkeypatch.setattr(agent, "_source_folder_summaries", lambda db, source: {}) + monkeypatch.setattr(agent, "list_source_names", lambda db: ["S"]) + holder = AgentHolder() + llm = ScriptedLLM( + [ + ToolCallPiece(id="call_1", name="ls", arguments={"path": "S/notes.md"}) + ], + [StreamPiece("content", "ans")], + ) + asyncio.run(_run(llm, holder, _settings())) + assert holder.tool_calls == 0 + assert llm.requests[1][0][3]["content"] == ( + "'S/notes.md' is not a folder — S has: a/" + ) + + +# ---------- ls_top / ls_folder: the drill-down accessors (pure + composed) ---------- + + +def test_ls_top_registry_order_zero_docs_and_summaries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``ls_top``: registry order (not catalog order), a 0-document + source still lists, the summary is the stored ``(source, "")`` row + or ``None`` when absent; an empty registry → ``[]``.""" + monkeypatch.setattr(agent, "list_source_names", lambda db: ["Zeta", "Alpha"]) + monkeypatch.setattr( + agent, "_source_document_counts", lambda db: [("Zeta", 3), ("Beta", 1)] + ) + monkeypatch.setattr( + agent, "_source_root_summaries", lambda db: [("Zeta", "Zeta stuff.")] + ) + assert agent.ls_top(cast("Session", object())) == [ + ("Zeta", 3, "Zeta stuff."), + ("Alpha", 0, None), # 0 docs (no count row) + no stored summary + ] + + +def test_ls_top_empty_registry_is_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(agent, "list_source_names", lambda db: []) + + def _boom(*_a: Any, **_k: Any) -> None: + raise AssertionError("no fetches for an empty registry") + + monkeypatch.setattr(agent, "_source_document_counts", _boom) + monkeypatch.setattr(agent, "_source_root_summaries", _boom) + assert agent.ls_top(cast("Session", object())) == [] + + +def test_ls_folder_composes_the_fetchers(monkeypatch: pytest.MonkeyPatch) -> None: + """``ls_folder`` = the source's document rows + stored summaries + through the pure :func:`group_folder_listing` (the fetchers are the + monkeypatch surface).""" + seen: list[tuple[str, str, str]] = [] + + def _rows(db: Any, source: str) -> list[tuple[str, str]]: + seen.append(("rows", source, "")) + return [("a/b.md", "B"), ("a.md", "A")] + + def _summaries(db: Any, source: str) -> dict[str, str]: + seen.append(("summaries", source, "")) + return {"a": "A stuff."} + + monkeypatch.setattr(agent, "_source_document_rows", _rows) + monkeypatch.setattr(agent, "_source_folder_summaries", _summaries) + assert agent.ls_folder(cast("Session", object()), "S", "") == ( + [("a", 1, "A stuff.")], + [("S", "a.md", "A")], + 1, + ) + assert seen == [("rows", "S", ""), ("summaries", "S", "")] + + +def test_group_folder_listing_subfolder_recursion_and_counts() -> None: + """The recursive count per subfolder — every path equal to the + folder or starting with ``folder + '/'`` (a doc under ``a/b/`` + counts for BOTH ``a`` and ``a/b``), path order, the stored summary + attached or ``None``.""" + rows = [ + ("a/b/c.md", "C"), + ("a/b/d.md", "D"), + ("a/e.md", "E"), + ("f.md", "F"), + ] + sub, files, total = agent.group_folder_listing( + "S", "", rows, {"a": "A subtree.", "a/b": "B subtree."} + ) + # ROOT level: the direct subfolders of "" are the TOP-LEVEL folders + # only (a/b is nested under a, not direct) — a's count is its whole + # recursive subtree (a/e.md + a/b/c.md + a/b/d.md), the stored + # summary attached. + assert sub == [("a", 3, "A subtree.")] + assert files == [("S", "f.md", "F")] + assert total == 1 + # One level down: a/b is a's direct subfolder with its own count. + sub2, _files2, _total2 = agent.group_folder_listing("S", "a", rows, {"a/b": "B subtree."}) + assert sub2 == [("a/b", 2, "B subtree.")] + + +def test_group_folder_listing_nested_level_counts_and_membership() -> None: + """One level down: ``a``'s direct subfolder is ``a/b`` (count 2), + its own direct file is ``a/e.md`` (``a/b/c.md`` is NOT a direct + file of ``a``) — membership is the folder_of rule, order is path + order.""" + rows = [ + ("a/b/c.md", "C"), + ("a/b/d.md", "D"), + ("a/e.md", "E"), + ] + sub, files, total = agent.group_folder_listing("S", "a", rows, {}) + assert sub == [("a/b", 2, None)] + assert files == [("S", "a/e.md", "E")] + assert total == 1 + + +def test_group_folder_listing_file_path_is_not_a_folder() -> None: + """A document whose path is a prefix of NO other path is a file, + never a folder: ``ls`` of it must not list a subfolder (and the + ``path == folder`` count arm only fires for TRUE folders — a doc + sharing a real folder's name counts for that folder, the existence + rule intact).""" + rows = [ + ("a.md", "A"), # a file at the root, and a folder name? NO — + ("b/x.md", "X"), # nothing starts with "a.md/" + ] + sub, files, total = agent.group_folder_listing("S", "", rows, {}) + assert sub == [("b", 1, None)] # "a.md" is NOT a subfolder + assert files == [("S", "a.md", "A")] # b/x.md is NOT a direct root file + assert total == 1 + # The path == folder arm: a doc named "a" under a real folder "a/". + rows2 = [("a", "FileA"), ("a/c.md", "C")] + sub2, files2, total2 = agent.group_folder_listing("S", "", rows2, {}) + assert sub2 == [("a", 2, None)] # the file "a" counts for folder "a" + assert files2 == [("S", "a", "FileA")] # …and is a direct ROOT file + assert total2 == 1 + + +def test_group_folder_listing_caps_files_at_fifty_keeps_the_total() -> None: + """The cap: 51 direct files → 50 file lines + the PRE-cap total (51) + for the renderer's note; 50 files → 50 lines, no note material. + A 500-file folder costs 50 lines, never 500.""" + rows51 = [(f"big/f{i:03d}.md", f"T{i}") for i in range(51)] + sub, files, total = agent.group_folder_listing("S", "big", rows51, {}) + assert sub == [] + assert total == 51 + assert len(files) == 50 + assert files[0] == ("S", "big/f000.md", "T0") + assert files[-1] == ("S", "big/f049.md", "T49") + rows50 = [(f"big/f{i:03d}.md", f"T{i}") for i in range(50)] + _sub, files50, total50 = agent.group_folder_listing("S", "big", rows50, {}) + assert total50 == 50 and len(files50) == 50 + + +# ---------- the pinned drill-down templates (byte-for-byte) ---------- + + +def test_render_ls_top_template() -> None: + assert ( + agent.render_ls_top( + [("Deployments", 3, None), ("Homelab", 5, "Home lab notes.")] + ) + == "2 sources:\n\n" + "Deployments — 3 documents\n" + "Homelab — 5 documents\n" + " Home lab notes." + ) + assert agent.render_ls_top([]) == "0 sources:" + + +def test_render_folder_listing_root_template() -> None: + assert ( + agent.render_folder_listing( + "Homelab", + [("backups", 2, "Backup notes."), ("networking", 1, None)], + [("Homelab", "readme.md", "Readme")], + 1, + ) + == "Homelab — 1 documents, 2 folders:\n" + "\n" + " backups/ — 2 documents: Backup notes.\n" + " networking/ — 1 documents\n" + "\n" + "source: Homelab | path: readme.md | title: Readme" + ) + + +def test_render_folder_listing_empty_level_is_header_alone() -> None: + """A registered source with no documents: the header line alone — + the old ``0 documents:`` behavior preserved in spirit.""" + assert agent.render_folder_listing("Homelab", [], [], 0) == ( + "Homelab — 0 documents, 0 folders:" + ) + + +def test_render_folder_listing_subfolders_only_no_blank_trailer() -> None: + """Subfolders but no own files: header + blank + subfolder lines — + no trailing blank line, no file section.""" + assert ( + agent.render_folder_listing("S", [("a", 1, None)], [], 0) + == "S — 0 documents, 1 folders:\n\n a/ — 1 documents" + ) + + +def test_render_folder_listing_cap_note_only_past_fifty() -> None: + """The note appears ONLY when the folder's own files outnumber the + cap: 51 → 50 lines + the deterministic grep-pointer note (the + ``…and 1 more…`` shape — unpluralized, the house pin); 50 → no + note.""" + files51 = [("S", f"f{i:03d}.md", f"T{i}") for i in range(51)] + capped = files51[:50] + rendered = agent.render_folder_listing("S/big", [], capped, 51) + lines = rendered.splitlines() + assert lines[0] == "S/big — 51 documents, 0 folders:" + assert len(lines) == 1 + 1 + 50 + 1 # header, blank, 50 lines, note + assert lines[-1] == ( + "…and 1 more documents in this folder — use grep (pattern) to " + "find a specific one." + ) + files50 = [("S", f"f{i:03d}.md", f"T{i}") for i in range(50)] + rendered50 = agent.render_folder_listing("S/big", [], files50, 50) + assert rendered50.splitlines()[-1] == "source: S | path: f049.md | title: T49" + assert "more documents" not in rendered50 + + # ---------- read: the canonical combined source/path form ---------- @@ -771,7 +1134,6 @@ def test_read_bare_source_name_refused_without_db(monkeypatch: pytest.MonkeyPatc no-document refusal (the argument echoed as passed), no DB lookup (NOT even the phase-72 candidate lookup — ``all_documents`` must not run either), nothing counted.""" - monkeypatch.setattr(agent, "list_catalog", lambda db: [("Homelab", "a.md", "A")]) def _boom(*_a: Any, **_k: Any) -> None: raise AssertionError( @@ -1158,7 +1520,6 @@ def test_reading_an_already_read_doc_is_deduped(monkeypatch: pytest.MonkeyPatch) def test_unknown_tool_name_refused(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(agent, "list_catalog", lambda db: []) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="delete_universe", arguments={"x": 1})], @@ -1702,13 +2063,11 @@ def test_grep_counts_but_never_adds_context(monkeypatch: pytest.MonkeyPatch) -> def test_relist_executes_and_counts(monkeypatch: pytest.MonkeyPatch) -> None: - """Re-lists execute — a second ``ls`` in one turn returns the catalog - again and counts in ``tool_calls`` (no budget to exhaust).""" - catalog = [ - ("Deployments", "backups.md", "Backup Strategy"), - ("Homelab", "aws-route53.md", "AWS Route53 Records"), - ] - monkeypatch.setattr(agent, "list_catalog", lambda db: catalog) + """Re-lists execute — a second ``ls`` in one turn returns the top + level again and counts in ``tool_calls`` (no budget to exhaust).""" + monkeypatch.setattr( + agent, "ls_top", lambda db: [("Deployments", 1, None), ("Homelab", 1, None)] + ) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], @@ -1717,12 +2076,8 @@ def test_relist_executes_and_counts(monkeypatch: pytest.MonkeyPatch) -> None: ) asyncio.run(_run(llm, holder, _settings())) assert holder.tool_calls == 2 # both re-lists executed and counted - listing = ( - "2 documents:\n" - "source: Deployments | path: backups.md | title: Backup Strategy\n" - "source: Homelab | path: aws-route53.md | title: AWS Route53 Records" - ) - # The answer request carries the catalog a second time as a tool result. + listing = "2 sources:\n\nDeployments — 1 documents\nHomelab — 1 documents" + # The answer request carries the listing a second time as a tool result. assert llm.requests[2][0][3]["content"] == listing # first listing assert llm.requests[2][0][5]["content"] == listing # the re-list assert llm.requests[2][1] == AGENT_TOOLS # still offered (no budgets) @@ -1758,8 +2113,8 @@ def test_always_ls_bounded_by_round_cap(monkeypatch: pytest.MonkeyPatch) -> None """A model that keeps calling ``ls`` gets exactly ``agent_max_rounds`` tool rounds, then one forced ``tools=None`` request streams the answer — the cap is the only forced exit.""" - monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")]) - listing = "1 documents:\nsource: S | path: a.md | title: A" + monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) + listing = "1 sources:\n\nS — 1 documents" holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], @@ -1923,7 +2278,7 @@ def test_round_retried_before_first_piece( same messages: the stream carries a RetryPiece BEFORE the tool call, the tool executes, the final answer streams, and the per-call log line is still emitted exactly once (retries are invisible to the loop).""" - monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")]) + monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) holder = AgentHolder() llm = FailingLLM( [ @@ -1961,7 +2316,6 @@ def test_round_failure_after_first_piece_is_terminal(monkeypatch: pytest.MonkeyP the LLMError propagates out of ``run_agent``, no RetryPiece, no sleep, no second request, and the holder is untouched (the tool never ran).""" - monkeypatch.setattr(agent, "list_catalog", lambda db: []) holder = AgentHolder() llm = FailingLLM( [([StreamPiece("content", "partial ")], LLMError("mid-stream drop"))] @@ -1994,7 +2348,7 @@ def test_forced_final_no_tools_call_is_retried(monkeypatch: pytest.MonkeyPatch) """The forced final request (round cap reached) goes through the same retry rule: a failure before its first piece yields a RetryPiece and restarts with ``tools=None``; the answer from the retry streams.""" - monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")]) + monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) holder = AgentHolder() llm = FailingLLM( [ @@ -2026,7 +2380,6 @@ def test_zero_retries_is_one_plain_attempt(monkeypatch: pytest.MonkeyPatch) -> N """The kill-switch path (``llm_retries=0``): a dead round raises immediately — one request, no RetryPiece, no sleep (pre-phase-67 behavior).""" - monkeypatch.setattr(agent, "list_catalog", lambda db: []) holder = AgentHolder() llm = FailingLLM([([], LLMError("connection refused"))]) sleeps = _record_sleeps(monkeypatch) @@ -2065,7 +2418,6 @@ def test_abandon_mid_retry_sleep_leaks_nothing(monkeypatch: pytest.MonkeyPatch) await asyncio.Event().wait() # park until the abandon arrives monkeypatch.setattr(asyncio, "sleep", parking_sleep) - monkeypatch.setattr(agent, "list_catalog", lambda db: []) holder = AgentHolder() llm = FailingLLM( [([], LLMError("endpoint down")), ([StreamPiece("content", "never")], None)] @@ -2111,7 +2463,7 @@ def test_retries_are_invisible_to_the_round_cap( 2, the retried first round and the second tool round fill the cap — the forced final follows the SECOND call, and the log lines read round=1/2 and round=2/2.""" - monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")]) + monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) holder = AgentHolder() llm = FailingLLM( [ @@ -2364,7 +2716,7 @@ def test_scaffolding_round_with_tool_calls_needs_no_recovery( ran, and the policy keys on the no-calls exit only — no recovery (the next round is a normal tools-offered round carrying the tool history).""" - monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")]) + monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) holder = AgentHolder() llm = ScriptedLLM( [ @@ -2398,7 +2750,7 @@ def test_recovery_after_tool_rounds_keeps_the_history( keeps the SINGLE (folded) system message at the front and the tool history intact behind it — no second system message, no duplicated correction.""" - monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")]) + monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], @@ -2423,7 +2775,7 @@ def test_recovery_after_tool_rounds_keeps_the_history( assert recovered[3] == { "role": "tool", "tool_call_id": "call_1", - "content": "1 documents:\nsource: S | path: a.md | title: A", + "content": "1 sources:\n\nS — 1 documents", } assert sum(1 for m in recovered if m["role"] == "system") == 1 assert holder.scaffold_stripped == len(_INCIDENT_SPAN) @@ -2436,7 +2788,7 @@ def test_forced_final_scaffolding_only_settles_malformed( scaffolding-only forced answer never reaches the user raw — the turn settles with :class:`MalformedReplyError` (the same terminal semantics; this turn used no recovery, so nothing is doubled up).""" - monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")]) + monkeypatch.setattr(agent, "ls_top", lambda db: [("S", 1, None)]) holder = AgentHolder() llm = ScriptedLLM( [ToolCallPiece(id="call_1", name="ls", arguments={})], diff --git a/tests/unit/test_folder_summaries.py b/tests/unit/test_folder_summaries.py new file mode 100644 index 0000000..663aded --- /dev/null +++ b/tests/unit/test_folder_summaries.py @@ -0,0 +1,628 @@ +"""Unit: folder summary storage + generator (phase 94, task 01). + +The prompt/grouping tests are pure (no DB): ``FOLDER_SUMMARY_MODE`` +system prompt, the ``folder_of`` / ``group_by_folder`` recursive-subtree +concept, and the user-message cap with the shared ``[…truncated…]`` +marker. The generator tests run against the local compose Postgres +(preferred — real upsert/prune on the ``folder_summaries`` table), +skipping with clear instructions when the stack is not up — same +pattern as ``test_overview.py``. +""" +from __future__ import annotations + +import asyncio +import logging +import uuid +from datetime import UTC, datetime +from typing import Any + +import pytest +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.config import Settings, get_settings +from app.db import SessionLocal +from app.models import Document, FolderSummary +from app.rag.folder_summaries import ( + FOLDER_HEADER_PREFIX, + FOLDER_SUMMARY_INSTRUCTION, + FOLDER_SUMMARY_MODE, + MIN_DOCS_PER_FOLDER, + SYSTEM_PROMPT, + build_folder_summary_prompt, + folder_of, + folder_summary_table_empty, + generate_folder_summaries, + group_by_folder, + summarize_folder, +) +from app.rag.llm import LLMError +from app.rag.retriever import TRUNCATION_MARKER +from tests.e2e.mock_llm import compose_answer + +REPLY = "Covers lab automation runbooks: inventories, playbooks, and schedules." + + +class _FakeLLM: + """Duck-typed stand-in for ``LLMClient`` (``chat`` + ``settings``). + + Records each ``(system, user)`` request and the ``model`` kwarg; + returns the canned reply, or raises — either a fixed exception or a + per-folder failure keyed on the user message's ``Folder: …`` header + (the per-folder fail-soft tests). + """ + + def __init__( + self, + reply: str = REPLY, + fail_folders: tuple[str, ...] = (), + fail: Exception | None = None, + ) -> None: + self._reply = reply + self._fail_folders = tuple(fail_folders) + self._fail = fail + self.settings = Settings(_env_file=None) # pyright: ignore[reportCallIssue] + self.calls = 0 + self.model: str | None = None + self.requests: list[tuple[str, str]] = [] + + async def chat( + self, messages: list[dict[str, str]], model: str | None = None + ) -> str: + self.calls += 1 + self.model = model + system = messages[0]["content"] + user = messages[-1]["content"] + self.requests.append((system, user)) + for folder in self._fail_folders: + if FOLDER_HEADER_PREFIX + folder in user: + raise LLMError(f"simulated lite-model failure for {folder}") + if self._fail is not None: + raise self._fail + return self._reply + + +# ---------- folder_of ---------- + + +def test_folder_of_root_level_file_is_empty() -> None: + assert folder_of("a.md") == "" + + +def test_folder_of_one_level() -> None: + assert folder_of("a/b.md") == "a" + + +def test_folder_of_deep_path() -> None: + assert folder_of("a/b/c/d.md") == "a/b/c" + + +def test_folder_of_iterating_walks_prefixes_to_root() -> None: + """Iterating ``folder_of`` over its own result walks the folder + prefixes nearest-first, ending at the root (the grouping walk).""" + folder = folder_of("a/b/c.md") + chain: list[str] = [] + while folder: + chain.append(folder) + folder = folder_of(folder) + assert chain == ["a/b", "a"] # plus the "" root the grouping adds + + +# ---------- group_by_folder ---------- + + +def test_group_by_folder_root_file_lands_only_in_source_root() -> None: + rows = [("S", "top.md", "T", None)] + groups = group_by_folder(rows) + assert set(groups) == {("S", "")} + assert groups[("S", "")] == rows + + +def test_group_by_folder_nested_multi_source_recursive_subtree() -> None: + """A doc under ``a/b/`` is present in the ``a``, ``a/b``, and ``""`` + groups (recursive subtree — the ``ls`` count scope, one concept); + per source the candidates are ``""`` + every distinct folder + prefix; group lists keep the input (catalogue) order.""" + rows = [ + ("S", "a/b/c.md", "C", None), + ("S", "a/b/d.md", "D", None), + ("S", "a/x.md", "X", None), + ("S", "top.md", "T", None), + ("T", "a/b/e.md", "E", None), + ] + groups = group_by_folder(rows) + assert set(groups) == { + ("S", ""), + ("S", "a"), + ("S", "a/b"), + ("T", ""), + ("T", "a"), + ("T", "a/b"), + } + # The recursive-subtree concept: a/b/ docs in the a/, a/b/, and "" + # groups alike — exactly the set each level's ls count shows. + assert [r[1] for r in groups[("S", "a/b")]] == ["a/b/c.md", "a/b/d.md"] + assert [r[1] for r in groups[("S", "a")]] == ["a/b/c.md", "a/b/d.md", "a/x.md"] + assert [r[1] for r in groups[("S", "")]] == [ + "a/b/c.md", + "a/b/d.md", + "a/x.md", + "top.md", + ] + # Multi-source: the same folder prefix under another source is a + # separate group (PK is (source, folder_path)). + assert [r[1] for r in groups[("T", "a/b")]] == ["a/b/e.md"] + assert [r[1] for r in groups[("T", "")]] == ["a/b/e.md"] + # Input (catalogue) order is preserved inside each group. + assert [r[2] for r in groups[("S", "")]] == ["C", "D", "X", "T"] + + +def test_group_by_folder_single_doc_folder_is_a_group_too() -> None: + """Grouping is pure subtree membership (≥ 1 docs): the ≥ 2 rule is + the GENERATOR's (the recursive count below the minimum yields no + row — pinned by the generator tests, not the grouping).""" + rows = [("S", "a/only.md", "O", None)] + groups = group_by_folder(rows) + assert len(groups[("S", "a")]) == 1 # present, but below the minimum + + +def test_group_by_folder_doc_path_equal_to_a_folder_prefix_counts_for_it() -> None: + """The count rule's ``path == folder`` arm: a document whose path + IS one of the source's folder prefixes (a file sharing its name + with a directory) belongs to that folder's group too — the grouping + stays EXACTLY the set the ``ls`` count rule counts (path equal or + starting with ``folder + "/"``), while the returned keys remain the + true folder prefixes only (no file-path keys).""" + rows = [ + ("S", "a/b", "B", None), # a file named "b" ... (its path is a folder prefix) + ("S", "a/b/c.md", "C", None), # ... and a real folder "a/b/" holding a doc + ("S", "a/x.md", "X", None), + ] + groups = group_by_folder(rows) + assert set(groups) == {("S", ""), ("S", "a"), ("S", "a/b")}, ( + "the keys stay the true folder prefixes — the file's own path adds no key" + ) + assert [r[1] for r in groups[("S", "a/b")]] == ["a/b", "a/b/c.md"] + assert [r[1] for r in groups[("S", "a")]] == ["a/b", "a/b/c.md", "a/x.md"] + assert [r[1] for r in groups[("S", "")]] == ["a/b", "a/b/c.md", "a/x.md"] + + +def test_group_by_folder_plain_file_path_is_not_a_group_key() -> None: + """A file path that is NO folder prefix (no doc under it) adds no + group key of its own — the ``path == folder`` arm only fires when + the path really is a prefix of the catalogue.""" + rows = [("S", "top.md", "T", None), ("S", "a/one.md", "O", None)] + groups = group_by_folder(rows) + assert set(groups) == {("S", ""), ("S", "a")} + assert "top.md" not in [folder for _source, folder in groups] + + +# ---------- build_folder_summary_prompt: system ---------- + + +def test_system_prompt_has_marker_and_locked_instruction() -> None: + assert SYSTEM_PROMPT.startswith(FOLDER_SUMMARY_MODE) + assert FOLDER_SUMMARY_INSTRUCTION in SYSTEM_PROMPT + for fragment in ( + "1-3 sentence", + "plain-text summary", + "natural language", + "Do not use markdown", + "not in the list", + ): + assert fragment in SYSTEM_PROMPT + system, _ = build_folder_summary_prompt("S", "a/b", []) + assert system == SYSTEM_PROMPT + assert FOLDER_SUMMARY_MODE in system # the marker the E2E mock keys on + + +# ---------- build_folder_summary_prompt: user ---------- + + +def test_user_prompt_header_names_the_folder() -> None: + """The first line is the ``FOLDER_HEADER_PREFIX`` header the E2E + mock parses: ```` for the root, ``/`` + for a folder.""" + _, user = build_folder_summary_prompt("Homelab", "deployments/ansible", []) + assert user == FOLDER_HEADER_PREFIX + "Homelab/deployments/ansible" + _, user = build_folder_summary_prompt("Homelab", "", []) + assert user == FOLDER_HEADER_PREFIX + "Homelab" + + +def test_user_lines_carry_path_title_and_first_summary_line() -> None: + docs = [ + ("S", "a/b/one.md", "One", "First lead.\nSecond line.\nSource: S/a/b/one.md"), + ("S", "a/b/two.md", "Two", None), + ] + system, user = build_folder_summary_prompt("S", "a/b", docs) + assert system == SYSTEM_PROMPT + assert user == ( + "Folder: S/a/b\n" + "a/b/one.md — One — First lead.\n" + "a/b/two.md — Two" + ) + + +def test_user_line_omits_summary_field_when_absent_or_blank() -> None: + docs = [ + ("S", "a/x.md", "X", None), + ("S", "a/y.md", "Y", " \n\t "), + ] + _, user = build_folder_summary_prompt("S", "a", docs) + assert user == "Folder: S/a\na/x.md — X\na/y.md — Y" + assert " — " in user # the path — title join only + assert not any(line.endswith(" — ") for line in user.splitlines()) + + +def test_user_line_uses_only_first_summary_line() -> None: + docs = [ + ("S", "a/x.md", "X", "First line.\nSecond line.\nSource: S/a/x.md"), + ] + _, user = build_folder_summary_prompt("S", "a", docs) + assert user == "Folder: S/a\na/x.md — X — First line." + assert "Second line" not in user + assert "Source:" not in user + + +def test_user_prompt_truncated_with_marker_when_over_custom_cap() -> None: + docs = [("S", f"a/f{i}.md", f"T{i}", None) for i in range(10)] + _, full = build_folder_summary_prompt("S", "a", docs, max_chars=10_000) + cap = 30 + _, user = build_folder_summary_prompt("S", "a", docs, max_chars=cap) + assert user == full[:cap] + "\n" + TRUNCATION_MARKER + assert user.endswith(TRUNCATION_MARKER) + assert len(user) > cap # the marker makes the cut visible past the cap + + +def test_user_prompt_at_exact_cap_not_truncated() -> None: + docs = [("S", "a/x.md", "X", None)] # "Folder: S/a\na/x.md — X" = 22 chars + _, user = build_folder_summary_prompt("S", "a", docs, max_chars=22) + assert user == "Folder: S/a\na/x.md — X" + assert TRUNCATION_MARKER not in user + + +def test_user_prompt_truncated_at_default_cap() -> None: + """No explicit cap → ``BOR_FOLDER_SUMMARY_INPUT_MAX_CHARS`` (read + from the live settings, so the test holds for any configured + value).""" + cap = get_settings().folder_summary_input_max_chars + docs = [("S", f"a/f{i}.md", "T", None) for i in range(3_000)] + _, user = build_folder_summary_prompt("S", "a", docs) + assert user.endswith(TRUNCATION_MARKER) + body = user.removesuffix("\n" + TRUNCATION_MARKER) + assert len(body) == cap # cut exactly at the cap, marker on its own line + assert "f2999.md" not in body # the overflow never reaches the model + + +# ---------- summarize_folder ---------- + + +# ---------- the E2E mock's FOLDER_SUMMARY_MODE branch ---------- + + +def _mock_body(system: str, user: str) -> dict[str, Any]: + """A minimal chat-completion body for the mock's ``compose_answer``.""" + return {"messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ]} + + +def test_mock_returns_canned_folder_summary_naming_the_folder() -> None: + """The deterministic E2E mock keys on the ``FOLDER_SUMMARY_MODE`` + marker in the system prompt and returns the canned one-liner naming + the folder from the ``Folder: …`` header — driven through the + GENERATOR's real prompt, so the two can never drift (the drill-down + E2E asserts on this exact template).""" + 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." + ) + # 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." + ) + + +def test_mock_folder_marker_is_not_shadowed_by_the_summary_branch() -> None: + """``FOLDER_SUMMARY_MODE`` contains ``SUMMARY_MODE`` as a substring — + the mock must check the folder branch FIRST, or every folder call + would land in the document-summary digest (regression pin).""" + system, user = build_folder_summary_prompt( + "S", "a", [("S", "a/x.md", "X", None)] + ) + assert "SUMMARY_MODE" in system # the shadowing hazard is real + answer = compose_answer(_mock_body(system, user)) + assert answer == "Fixture folder summary for S/a." + assert not answer.startswith("This document covers") + + +def test_summarize_folder_happy_path_returns_trimmed_text() -> None: + docs = [("S", "a/x.md", "X", None)] + llm = _FakeLLM(reply=f" {REPLY} \n") + out = asyncio.run(summarize_folder("S", "a", docs, llm)) + assert out == REPLY # the model's text, trimmed + assert llm.calls == 1 + + +def test_summarize_folder_calls_the_configured_summary_model_with_marker() -> None: + docs = [("S", "a/x.md", "X", "X lead.")] + llm = _FakeLLM() + asyncio.run(summarize_folder("S", "a", docs, llm)) + assert llm.model == llm.settings.llm_summary_model # the ``lite`` default + assert llm.model == "lite" + system, user = llm.requests[0] + assert FOLDER_SUMMARY_MODE in system + assert user.startswith(FOLDER_HEADER_PREFIX + "S/a") + assert "a/x.md — X — X lead." in user + + +def test_summarize_folder_empty_reply_raises_llm_error() -> None: + docs = [("S", "a/x.md", "X", None)] + for reply in ("", " \n\t "): + llm = _FakeLLM(reply=reply) + with pytest.raises(LLMError, match="empty content for S/a"): + asyncio.run(summarize_folder("S", "a", docs, llm)) + + +def test_summarize_folder_error_propagates() -> None: + docs = [("S", "a/x.md", "X", None)] + llm = _FakeLLM(fail=LLMError("simulated transport failure")) + with pytest.raises(LLMError, match="simulated transport failure"): + asyncio.run(summarize_folder("S", "a", docs, llm)) + + +# ---------- generate_folder_summaries (real Postgres) ---------- + + +def _add_doc( + db: Session, source: str, path: str, title: str, summary: str | None = None +) -> Document: + doc = Document( + id=uuid.uuid4(), + source=source, + path=path, + full_path=f"/tmp/{path}", + title=title, + content="body", + content_hash="0" * 64, + summary=summary, + ) + db.add(doc) + db.commit() + return doc + + +def _truncate(db: Session) -> None: + db.execute(text("TRUNCATE chunks, documents")) + db.execute(text("DELETE FROM folder_summaries")) + db.commit() + + +def _rows(db: Session) -> dict[tuple[str, str], str]: + """The stored folder summaries: ``{(source, folder_path): summary}``.""" + result = db.execute( + text("SELECT source, folder_path, summary FROM folder_summaries") + ).all() + return {(source, folder_path): summary for source, folder_path, summary in result} + + +def _seed_catalogue(db: Session) -> None: + """The shared catalogue: FSU has four docs in three candidate + folders (root 4, a 3, a/b 2 — all ≥ the minimum); FSU-solo has one + doc (its root folder is below the minimum — no row, no call).""" + _add_doc(db, "FSU", "a/b/one.md", "One", "One lead.\nSource: FSU/a/b/one.md") + _add_doc(db, "FSU", "a/b/two.md", "Two") + _add_doc(db, "FSU", "a/three.md", "Three") + _add_doc(db, "FSU", "root.md", "Root") + _add_doc(db, "FSU-solo", "solo.md", "Solo") + + +@pytest.fixture() +def clean_tables(db: Session): + _truncate(db) + yield + _truncate(db) + + +def test_generate_happy_path_upserts_every_candidate_folder( + db: Session, clean_tables, caplog: pytest.LogCaptureFixture +) -> None: + """Every folder with ≥ 2 recursive docs gets a row (the source root + row included — ``folder_path = ''``); single-doc folders get none; + rows are stamped fresh; the stats dict and the log line are right; + folders are processed in deterministic (source, folder_path) order.""" + _seed_catalogue(db) + llm = _FakeLLM() + with caplog.at_level(logging.INFO, logger="app.rag.folder_summaries"): + stats = asyncio.run(generate_folder_summaries(db, llm)) + assert stats == {"generated": 3, "failed": 0, "pruned": 0} + assert llm.calls == 3, "one lite call per candidate folder (the solo folder: none)" + + stored = _rows(db) + assert set(stored) == {("FSU", ""), ("FSU", "a"), ("FSU", "a/b")} + assert all(summary == REPLY for summary in stored.values()) + assert ("FSU-solo", "") not in stored, ( + "a single-doc folder is fully described by its one file line — no row" + ) + + row = db.get(FolderSummary, ("FSU", "a/b")) + assert row is not None + assert row.summary == REPLY + assert row.updated_at is not None + age = datetime.now(UTC) - row.updated_at + assert age.total_seconds() < 300, "updated_at must be a fresh UTC timestamp" + + # Deterministic (source, folder_path) order — root before the + # nested folders, one header per call. + assert [user.splitlines()[0] for _s, user in llm.requests] == [ + "Folder: FSU", + "Folder: FSU/a", + "Folder: FSU/a/b", + ] + # The recursive-subtree input: the a/ prompt carries a/b's docs too. + a_prompt = llm.requests[1][1] + assert "a/b/one.md — One — One lead." in a_prompt + assert "a/three.md — Three" in a_prompt + assert "root.md — Root" not in a_prompt + + assert ( + "folder_summaries: generated=3 failed=0 pruned=0" in caplog.text + ), "the stats line must be greppable (PLAN §9 ample logging)" + + +def test_generate_per_folder_fail_soft_keeps_previous_and_lands_others( + db: Session, clean_tables, caplog: pytest.LogCaptureFixture +) -> None: + """One folder's lite failure is logged and counted, its PREVIOUS + row is kept (an old summary is better than none), and the remaining + folders still land — a lite outage never fails the sync.""" + _seed_catalogue(db) + db.add(FolderSummary(source="FSU", folder_path="a/b", summary="old summary")) + db.commit() + llm = _FakeLLM(fail_folders=("FSU/a/b",)) + with caplog.at_level(logging.ERROR, logger="app.rag.folder_summaries"): + stats = asyncio.run(generate_folder_summaries(db, llm)) + assert stats == {"generated": 2, "failed": 1, "pruned": 0} + assert llm.calls == 3 # the failing folder was attempted too + + stored = _rows(db) + assert stored[("FSU", "a/b")] == "old summary", ( + "the previous row survives the per-folder failure" + ) + assert stored[("FSU", "")] == REPLY and stored[("FSU", "a")] == REPLY, ( + "the other folders still land" + ) + assert "folder summary failed for FSU/a/b" in caplog.text + assert "simulated lite-model failure for FSU/a/b" in caplog.text + + +def test_generate_per_folder_fail_soft_without_previous_row_creates_nothing( + db: Session, clean_tables +) -> None: + _seed_catalogue(db) + llm = _FakeLLM(fail_folders=("FSU/a/b",)) + stats = asyncio.run(generate_folder_summaries(db, llm)) + assert stats["failed"] == 1 + stored = _rows(db) + assert ("FSU", "a/b") not in stored, "no row must be invented for a failed folder" + assert ("FSU", "") in stored and ("FSU", "a") in stored + + +def test_generate_prunes_stale_rows_and_keeps_live_ones(db: Session, clean_tables) -> None: + """Rows for folders that dropped below 2 recursive docs are deleted + (pruned/renamed — the summary would go stale); rows for folders + that still qualify persist (an unchanged folder's summary is still + true — regenerated in place).""" + _seed_catalogue(db) + # A stale row for a folder no longer in the catalogue (3→1 docs / + # renamed away) + a live row with old content. + db.add(FolderSummary(source="FSU", folder_path="gone/old", summary="stale")) + db.add(FolderSummary(source="FSU", folder_path="a", summary="old a summary")) + db.add(FolderSummary(source="FSU-solo", folder_path="", summary="solo stale")) + db.commit() + stats = asyncio.run(generate_folder_summaries(db, _FakeLLM())) + assert stats["pruned"] == 2 # gone/old + the FSU-solo root (1 doc) + + stored = _rows(db) + assert ("FSU", "gone/old") not in stored, "the stale folder row must be pruned" + assert ("FSU-solo", "") not in stored, ( + "a folder that dropped below 2 docs loses its row" + ) + assert ("FSU", "a") in stored, "the still-qualifying folder keeps its row" + assert stored[("FSU", "a")] == REPLY # regenerated, not stale + assert stored[("FSU", "")] == REPLY and stored[("FSU", "a/b")] == REPLY + + +def test_generate_skip_is_a_full_noop(db: Session, clean_tables) -> None: + """``skip=True`` (the ``--limit`` debug run): the LLM is never + called, no rows are touched, zero stats.""" + _seed_catalogue(db) + db.add(FolderSummary(source="FSU", folder_path="", summary="existing")) + db.commit() + llm = _FakeLLM() + stats = asyncio.run(generate_folder_summaries(db, llm, skip=True)) + assert stats == {"generated": 0, "failed": 0, "pruned": 0} + assert llm.calls == 0 + assert _rows(db) == {("FSU", ""): "existing"} + + +def test_generate_empty_kb_prunes_every_row(db: Session, clean_tables) -> None: + """No documents → no candidate folders → every stored row is + pruned, with zero wasted lite calls.""" + db.add(FolderSummary(source="FSU", folder_path="", summary="old")) + db.add(FolderSummary(source="FSU", folder_path="a/b", summary="old")) + db.commit() + llm = _FakeLLM() + stats = asyncio.run(generate_folder_summaries(db, llm)) + assert stats == {"generated": 0, "failed": 0, "pruned": 2} + assert llm.calls == 0 + assert _rows(db) == {} + + +def test_generate_summarizes_folder_whose_prefix_is_also_a_doc_path( + db: Session, clean_tables +) -> None: + """The ``path == folder`` arm end to end: a file sharing its name + with a directory counts toward the folder's recursive count (2 + docs → the folder is summarized, and BOTH docs are in its prompt). + """ + _add_doc(db, "FSU", "a/b", "B") # a file named "b" (its path is a prefix) + _add_doc(db, "FSU", "a/b/c.md", "C") # and a real folder "a/b/" + llm = _FakeLLM() + stats = asyncio.run(generate_folder_summaries(db, llm)) + assert stats["generated"] == 3 # root (2), a (2), a/b (2) — all ≥ the minimum + stored = _rows(db) + assert set(stored) == {("FSU", ""), ("FSU", "a"), ("FSU", "a/b")} + a_b_prompt = [ + user for _system, user in llm.requests if user.startswith("Folder: FSU/a/b\n") + ][0] + assert "a/b — B" in a_b_prompt + assert "a/b/c.md — C" in a_b_prompt + + +def test_generate_only_flushes_caller_commits(db: Session, clean_tables) -> None: + """The generator only flushes — the sync path owns the transaction + (the phase-53 ``bump_sources_version`` convention): the catalogue + is committed (the real sync path commits the import before the + summary hooks run), but a second session sees the generator's rows + as NOTHING until the CALLER commits — and sees them after.""" + _add_doc(db, "FSU", "x/y/one.md", "One") + _add_doc(db, "FSU", "x/y/two.md", "Two") + stats = asyncio.run(generate_folder_summaries(db, _FakeLLM())) + assert stats["generated"] == 3 # root + x + x/y — all 2 recursive docs + + with SessionLocal() as other: + n = other.scalar( + text("SELECT count(*) FROM folder_summaries WHERE source = 'FSU'") + ) + assert n == 0, "unflushed-by-caller rows must not be visible yet" + + db.commit() + with SessionLocal() as other: + n = other.scalar( + text("SELECT count(*) FROM folder_summaries WHERE source = 'FSU'") + ) + assert n == 3, "the caller's commit makes the flushed rows durable" + + assert MIN_DOCS_PER_FOLDER == 2 # the ≥ 2 scope rule, pinned by name + + +def test_folder_summary_table_empty_gate(db: Session, clean_tables) -> None: + """The sync-path gate probe (phase 94, task 02): empty → True + (the first full sync after migration 0017 must still generate), + one row → False (a populated table waits for a KB change).""" + assert folder_summary_table_empty(db) is True # the truncated table + _add_doc(db, "FSU", "a/one.md", "One") + _add_doc(db, "FSU", "a/two.md", "Two") + asyncio.run(generate_folder_summaries(db, _FakeLLM())) + db.commit() + assert folder_summary_table_empty(db) is False # rows landed + db.execute(text("DELETE FROM folder_summaries")) + db.commit() + assert folder_summary_table_empty(db) is True # emptied again diff --git a/tests/unit/test_mock_tool_flow.py b/tests/unit/test_mock_tool_flow.py index 433ef04..2b917c6 100644 --- a/tests/unit/test_mock_tool_flow.py +++ b/tests/unit/test_mock_tool_flow.py @@ -4,12 +4,18 @@ The mock (``tests/e2e/mock_llm.py``) classifies marker requests statelessly into one step of the agent tool flow. This file pins the classification at unit speed — no Playwright, no LLM process: -* the phase-37 SINGLE-READ flow (``TOOLS_TRIGGER`` only) stays - byte-identical: list → read (first catalog line, ``call_1``) → answer; -* the phase-45 MULTI-READ flow (``TOOLS_TRIGGER`` + ``MULTI_READ_TRIGGER``) - classifies by the count of ``tool``-role read results: list → read #1 - (``call_1``) → read #2 (second catalog line, ``call_2``) → the - byte-stable ``multi_answer`` naming both read paths. +* the phase-37/94 SINGLE-READ flow (``TOOLS_TRIGGER`` only): list (the + top-level source listing) → drill (``ls`` scoped to the first source + — phase 94: the top level carries sources only, so the flow drills + one level for the file lines) → read (first file line, the combined + ``source/path``) → answer; +* the phase-45 MULTI-READ flow (``TOOLS_TRIGGER`` + + ``MULTI_READ_TRIGGER``) classifies by the count of ``tool``-role read + results: list → drill → read #1 (first file line) → read #2 (second + file line) → the byte-stable ``multi_answer`` naming both read paths; +* the degenerate empty-KB case: every listed source already drilled + with no file lines → the flow falls back to the re-list loop (the + round cap settles it — the phase-70 empty-catalog behavior). """ from __future__ import annotations @@ -36,28 +42,50 @@ SYSTEM_LOW = "LOW\n" #: the phase-70 harness-aligned names). TOOLS = [{"type": "function", "function": {"name": "ls"}}] -#: The agent's ``ls`` output for a two-document KB -#: (``app/rag/agent.py`` ``_execute_tool``): one -#: ``source: X | path: Y | title: Z`` line per document (phase 63: labeled, -#: unambiguous fields), ``(source, path)`` order. -CATALOG_2 = ( - "2 documents:\n" - "source: Deployments | path: example-record-file.json | title: Example Record File\n" - "source: Homelab | path: aws-route53.md | title: AWS Route 53 Notes" +#: The agent's drill-down ``ls`` output for a two-source KB +#: (``app/rag/agent.py`` ``render_ls_top`` / ``render_folder_listing``, +#: phase 94): the top level lists the registered sources (registry +#: order, recursive counts — no file lines); the folder level carries +#: the file lines (``source: X | path: Y | title: Z`` — the phase-63 +#: labeled fields, unchanged), ``path`` order. +TOP_LEVEL_2 = ( + "2 sources:\n" + "\n" + "Deployments — 1 documents\n" + "Homelab — 1 documents" ) -CATALOG_1 = ( - "1 documents:\n" +#: The first source's root folder: one file line (the single-read +#: flow's read target). +FOLDER_DEPLOYMENTS = ( + "Deployments — 1 documents, 0 folders:\n" + "\n" "source: Deployments | path: example-record-file.json | title: Example Record File" ) -CATALOG_3 = ( - "3 documents:\n" +#: Two file lines in the first source (the multi-read flow's reads). +FOLDER_DEPLOYMENTS_2 = ( + "Deployments — 2 documents, 0 folders:\n" + "\n" + "source: Deployments | path: aaa.md | title: AAA\n" + "source: Deployments | path: bbb.md | title: BBB" +) + +#: Three file lines in the first source (the listing-order pin: read +#: #2 is the SECOND line, not the last). +FOLDER_DEPLOYMENTS_3 = ( + "Deployments — 3 documents, 0 folders:\n" + "\n" "source: Deployments | path: aaa.md | title: AAA\n" "source: Deployments | path: bbb.md | title: BBB\n" - "source: Homelab | path: ccc.md | title: CCC" + "source: Deployments | path: ccc.md | title: CCC" ) +#: Empty folder levels (a registered source with no documents — the +#: header line alone; the drill's degenerate arm). +FOLDER_DEPLOYMENTS_EMPTY = "Deployments — 0 documents, 0 folders:" +FOLDER_HOMELAB_EMPTY = "Homelab — 0 documents, 0 folders:" + DOC1_SP = "Deployments/example-record-file.json" DOC1_CONTENT = ( "The record file keeps every hosted zone record — first line is longer " @@ -122,10 +150,39 @@ def test_single_flow_list_step() -> None: assert _tool_flow(_body(SINGLE_USER)) == ("list", "", "") -def test_single_flow_read_step_first_catalog_line() -> None: - flow = _tool_flow(_body(SINGLE_USER, (CATALOG_3,))) - # The FIRST listing line (Deployments/aaa.md), labeled fields. - assert flow == ("read", "Deployments", "aaa.md", "call_1") +def test_single_flow_drill_step_after_top_level() -> None: + # Phase 94: the top level lists SOURCES only — the flow drills one + # level into the FIRST source (listing order = registry order). + flow = _tool_flow(_body(SINGLE_USER, (TOP_LEVEL_2,))) + assert flow == ("drill", "Deployments", "call_1") + + +def test_single_flow_drill_skips_already_drilled_source() -> None: + # The first source's folder level is already in the messages (an + # empty listing — header only, no file lines): the drill proceeds + # to the NEXT un-drilled source. + flow = _tool_flow(_body(SINGLE_USER, (TOP_LEVEL_2, FOLDER_DEPLOYMENTS_EMPTY))) + assert flow == ("drill", "Homelab", "call_1") + + +def test_single_flow_all_sources_drilled_empty_falls_back_to_list() -> None: + # Degenerate: every listed source already drilled, no file lines + # anywhere — the flow falls back to the re-list loop (settled at the + # round cap, the phase-70 empty-catalog behavior). + flow = _tool_flow( + _body( + SINGLE_USER, + (TOP_LEVEL_2, FOLDER_DEPLOYMENTS_EMPTY, FOLDER_HOMELAB_EMPTY), + ) + ) + assert flow == ("list", "", "") + + +def test_single_flow_read_step_first_file_line() -> None: + # The folder level reached: the FIRST file line (Deployments/aaa.md), + # labeled fields, the combined ``source/path`` join. + flow = _tool_flow(_body(SINGLE_USER, (TOP_LEVEL_2, FOLDER_DEPLOYMENTS_2))) + assert flow == ("read", "Deployments", "aaa.md", "call_2") def test_read_step_nested_path_stays_intact() -> None: @@ -133,19 +190,20 @@ def test_read_step_nested_path_stays_intact() -> None: # ``source/path — title`` + ``rpartition("/")`` parse misread the # split (``source=brain-of-reese-main/homelab``). The labeled fields # recover the nested path intact, however deep. - catalog = ( - "1 documents:\n" + listing = ( + "brain-of-reese-main — 1 documents, 0 folders:\n" + "\n" "source: brain-of-reese-main | path: homelab/aws-route53.md | title: aws-route53" ) - flow = _tool_flow(_body(SINGLE_USER, (catalog,))) - assert flow == ("read", "brain-of-reese-main", "homelab/aws-route53.md", "call_1") + flow = _tool_flow(_body(SINGLE_USER, (TOP_LEVEL_2, listing))) + assert flow == ("read", "brain-of-reese-main", "homelab/aws-route53.md", "call_2") def test_single_flow_answer_step_with_tools_offered() -> None: # Phase 45: the round cap keeps the tools offered until it is hit — # the answer step fires regardless of the ``tools`` parameter. flow = _tool_flow( - _body(SINGLE_USER, (CATALOG_2, _read_result(DOC1_SP, DOC1_CONTENT))) + _body(SINGLE_USER, (TOP_LEVEL_2, FOLDER_DEPLOYMENTS, _read_result(DOC1_SP, DOC1_CONTENT))) ) assert flow == ("answer", DOC1_SP, DOC1_CONTENT) @@ -154,7 +212,7 @@ def test_single_flow_answer_step_without_tools() -> None: flow = _tool_flow( _body( SINGLE_USER, - (CATALOG_2, _read_result(DOC1_SP, DOC1_CONTENT)), + (TOP_LEVEL_2, FOLDER_DEPLOYMENTS, _read_result(DOC1_SP, DOC1_CONTENT)), tools=None, ) ) @@ -184,22 +242,39 @@ def test_multi_flow_list_step() -> None: assert _tool_flow(_body(MULTI_USER)) == ("list", "", "") +def test_multi_flow_drill_step_after_top_level() -> None: + # Phase 94: the top level lists SOURCES only — the multi flow drills + # too, before its first read. + flow = _tool_flow(_body(MULTI_USER, (TOP_LEVEL_2,))) + assert flow == ("drill", "Deployments", "call_1") + + def test_multi_flow_read_first_step() -> None: - flow = _tool_flow(_body(MULTI_USER, (CATALOG_2,))) - assert flow == ("read", DOC1_SP.split("/", 1)[0], DOC1_SP.rsplit("/", 1)[1], "call_1") + flow = _tool_flow(_body(MULTI_USER, (TOP_LEVEL_2, FOLDER_DEPLOYMENTS))) + assert flow == ("read", DOC1_SP.split("/", 1)[0], DOC1_SP.rsplit("/", 1)[1], "call_2") def test_multi_flow_read_second_step_skips_already_read() -> None: - flow = _tool_flow(_body(MULTI_USER, (CATALOG_2, _read_result(DOC1_SP, DOC1_CONTENT)))) - # The second catalog line — the first line differing from DOC1. - assert flow == ("read", "Homelab", "aws-route53.md", "call_2") + flow = _tool_flow( + _body( + MULTI_USER, + (TOP_LEVEL_2, FOLDER_DEPLOYMENTS_2, _read_result("Deployments/aaa.md", DOC1_CONTENT)), + ) + ) + # The second file line — the first line differing from the read doc. + assert flow == ("read", "Deployments", "bbb.md", "call_3") def test_multi_flow_read_second_is_listing_order_not_last() -> None: - # Three-doc catalog, first doc read: read #2 is the SECOND line + # Three-file listing, first file read: read #2 is the SECOND line # (Deployments/bbb.md), not the last one. - flow = _tool_flow(_body(MULTI_USER, (CATALOG_3, _read_result("Deployments/aaa.md", "x")))) - assert flow == ("read", "Deployments", "bbb.md", "call_2") + flow = _tool_flow( + _body( + MULTI_USER, + (TOP_LEVEL_2, FOLDER_DEPLOYMENTS_3, _read_result("Deployments/aaa.md", "x")), + ) + ) + assert flow == ("read", "Deployments", "bbb.md", "call_3") def test_multi_flow_answer_step_names_both_paths() -> None: @@ -207,7 +282,8 @@ def test_multi_flow_answer_step_names_both_paths() -> None: _body( MULTI_USER, ( - CATALOG_2, + TOP_LEVEL_2, + FOLDER_DEPLOYMENTS_2, _read_result(DOC1_SP, DOC1_CONTENT), _read_result(DOC2_SP, DOC2_CONTENT), ), @@ -227,7 +303,8 @@ def test_multi_flow_answer_step_without_tools_offered() -> None: _body( MULTI_USER, ( - CATALOG_2, + TOP_LEVEL_2, + FOLDER_DEPLOYMENTS_2, _read_result(DOC1_SP, DOC1_CONTENT), _read_result(DOC2_SP, DOC2_CONTENT), ), @@ -238,11 +315,11 @@ def test_multi_flow_answer_step_without_tools_offered() -> None: assert flow[0] == "multi_answer" -def test_multi_flow_one_document_catalog_degenerates_to_single_answer() -> None: +def test_multi_flow_one_file_listing_degenerates_to_single_answer() -> None: # Nothing second to read — the single-read answer shape, quoting the # only read result. flow = _tool_flow( - _body(MULTI_USER, (CATALOG_1, _read_result(DOC1_SP, DOC1_CONTENT))) + _body(MULTI_USER, (TOP_LEVEL_2, FOLDER_DEPLOYMENTS, _read_result(DOC1_SP, DOC1_CONTENT))) ) assert flow == ("answer", DOC1_SP, DOC1_CONTENT) @@ -320,9 +397,12 @@ def test_search_flow_found_step_without_tools_offered() -> None: def test_search_flow_ignores_catalog_and_read_results() -> None: - # A catalog (labeled lines) and a read result ("Document …" prefix) - # are NOT search results — the flow stays at the search step. - flow = _search_flow(_body(SEARCH_USER, (CATALOG_2, _read_result(DOC1_SP, DOC1_CONTENT)))) + # Listings (top-level + folder level, labeled file lines) and a read + # result ("Document …" prefix) are NOT search results — the flow + # stays at the search step. + flow = _search_flow( + _body(SEARCH_USER, (TOP_LEVEL_2, FOLDER_DEPLOYMENTS, _read_result(DOC1_SP, DOC1_CONTENT))) + ) assert flow == ("search",) diff --git a/tests/unit/test_prompts.py b/tests/unit/test_prompts.py index 0625db6..21badd1 100644 --- a/tests/unit/test_prompts.py +++ b/tests/unit/test_prompts.py @@ -11,11 +11,15 @@ And the phase-71 deflection plain-text line (owner-permitted line; the ``DEFLECT_MODE`` marker-keying contract is unchanged and the line never leaks into the HIGH prompt. -And the phase-72 ```` copy: the document-identity contract is -stated up front (the ``ls`` source-name scope, the combined -``source/path`` identity for ``read``/``grep``) — the same contract -the teaching refusals in :mod:`app.rag.agent` re-state; the -```` marker keying (HIGH only) is unchanged. +And the ```` copy (phase 72: the document-identity contract +stated up front — the combined ``source/path`` identity for +``read``/``grep``; phase 94, task 03: the ``ls`` clause rewritten to +the drill-down tree contract — one level per call, sources at the +top, folders + files below, ``grep`` as the without-listing locator — +while the ``read``/``grep`` clauses and the discipline rules are +byte-identical): the teaching refusals in :mod:`app.rag.agent` +re-state the same contract; the ```` marker keying (HIGH +only) is unchanged. """ from __future__ import annotations @@ -194,20 +198,22 @@ pinned byte-for-byte in def test_tools_section_phase72_contract_clauses() -> None: - """Phase 72: the two contract clauses the teaching refusals + """Phase 72 + phase 94: the contract clauses the teaching refusals re-state after the fact, pinned byte-for-byte in the constant — - the ``ls`` source-name clause (its optional ``path`` is a source - name, not a directory or file path; omit it to list every - document) and the ``read``/``grep`` combined-identity clause - (the combined ``source/path`` string exactly as shown in the - ``ls`` output, *including the source name*; a bare document path - will not resolve).""" - # The ls source-name clause. - assert ( - "a source name (e.g. 'homelab'), not a directory or file " - "path — omit it to list every document" - ) in TOOLS_SECTION - # The read combined-identity clause. + the ``ls`` clause (phase 94: the drill-down tree contract — one + level per call, sources at the top, folders + files below, never + the whole KB in one call, ``grep`` as the without-listing locator) + and the ``read``/``grep`` combined-identity clause (the combined + ``source/path`` string exactly as shown in the ``ls`` output, + *including the source name*; a bare document path will not + resolve).""" + # The ls drill-down clauses (phase 94, task 03). + assert "one level at a time" in TOOLS_SECTION + assert "lists every synced source with its document count" in TOOLS_SECTION + assert "that source's top-level folders and files" in TOOLS_SECTION + assert "never the whole knowledge base in one call" in TOOLS_SECTION + assert "to find one specific document without listing, use `grep`" in TOOLS_SECTION + # The read combined-identity clause (byte-identical across phases). assert ( "combined `source/path` string, exactly as shown in the `ls` " "output — including the source name" @@ -217,23 +223,25 @@ def test_tools_section_phase72_contract_clauses() -> None: "a bare document path (without the source name) will not resolve" ) == 2 # The pre-phase-70 scope wording is gone — replaced by the - # explicit source-name contract. + # explicit source-name contract (and the phase-72 source-name-only + # clause by the phase-94 drill-down contract). assert "pass a source name as `path`" not in TOOLS_SECTION + assert "not a directory or file path" not in TOOLS_SECTION def test_tools_section_phase72_clauses_in_high_prompt_not_low() -> None: - """Phase 72: the contract clauses ride the HIGH prompt with the + """Phase 72/94: the contract clauses ride the HIGH prompt with the rest of the section and never leak into the LOW/deflection prompt (whose byte-identity is pinned in :func:`test_zero_note_prompt_is_byte_identical_to_pre_steering`).""" doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster") high = build_high_prompt([doc]) assert "" in high - assert "not a directory or file path" in high + assert "one level at a time" in high # the phase-94 ls clause assert "including the source name" in high for low in (build_deflect_prompt(["T1"]), build_deflect_prompt([])): assert "" not in low - assert "not a directory or file path" not in low + assert "one level at a time" not in low assert "including the source name" not in low diff --git a/tests/unit/test_sync_button.py b/tests/unit/test_sync_button.py index ecaf2e1..33f8627 100644 --- a/tests/unit/test_sync_button.py +++ b/tests/unit/test_sync_button.py @@ -752,8 +752,9 @@ def _patch_sync_seams( """The runner's seams, monkeypatched on ``app.api.sync`` (the house mock-import pattern): fresh settings (no ``.env`` leak), a no-op model probe, a sentinel LLM client, one git row, the gated clone + - import, a no-op overview, and the DB-free sources-version step - (dummy session + pinned counters).""" + import, a no-op overview, a no-op folder-summary step (phase 94 — + the sentinel LLM client has no ``chat``), and the DB-free + sources-version step (dummy session + pinned counters).""" monkeypatch.setattr( sync_api, "get_settings", @@ -783,6 +784,13 @@ def _patch_sync_seams( monkeypatch.setattr(sync_api, "regenerate_overview", fake_overview) + async def fake_folder_summaries( + db: object, llm: object, *, skip: bool = False + ) -> dict[str, int]: + return {"generated": 0, "failed": 0, "pruned": 0} + + monkeypatch.setattr(sync_api, "generate_folder_summaries", fake_folder_summaries) + class _DummySession: def close(self) -> None: pass