phase: 103_suggestions_session_openers
Build and Push Containers / build-and-push-app (push) Successful in 2m32s
Build and Push Containers / build-and-push-db (push) Successful in 12s

Phase 103 final verification pass — all green.

**Verified (all 3 tasks already in `complete/`; no code changes needed):**
- `opening_questions` in `app/api/suggestions.py` — forward walk, one opener per chat (first non-blank user msg, A3), reads raw `messages` not `title` (A4), phase-80 order/dedup/cap/seed contracts; `last_questions` name gone from `app/`+`tests/`
- Docs updated: `app/config.py` seed docstring, `.env.example` `BOR_SUGGESTIONS`, `README.md` — "session openers" wording
- Diff scope correct: only the 6 expected files + phase-file moves; `app/rag/suggestions.py` and `frontend/` untouched

**Test / lint / coverage results:**
- `uv run pytest tests/integration/test_suggestions_api.py -v` → 12 passed
- `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov` → 8 passed in isolation (opener-only core pin included)
- `test_responsive_polish.py` → 7 passed; `test_chat_persistence.py` → 4 passed (both isolated, no edits)
- `uv run pytest --cov=app --cov-report=term-missing` → 2086 passed, TOTAL 99% (>90%); `app/api/suggestions.py` 100%
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings

**Completion criteria:** all 7 ✅ (follow-ups-never-surface pin; cap-across-chats pin; seed/dedup/case/partial/A3/401 pins; E2E suites isolated; deflection chips unchanged; full suite + lint; commit + dir move left to harness per executor rules).

**Deviations:** none — no defects found; nothing changed in this pass.
**Next pending phase:** `98_sync_summary_visibility` (numeric order in `todo/`).
This commit is contained in:
2026-09-12 16:37:50 -04:00
parent 3b2dea5685
commit 1f1c01c9f7
25 changed files with 55781 additions and 204 deletions
@@ -0,0 +1,18 @@
Phase 103 final verification pass — all green.
**Verified (all 3 tasks already in `complete/`; no code changes needed):**
- `opening_questions` in `app/api/suggestions.py` — forward walk, one opener per chat (first non-blank user msg, A3), reads raw `messages` not `title` (A4), phase-80 order/dedup/cap/seed contracts; `last_questions` name gone from `app/`+`tests/`
- Docs updated: `app/config.py` seed docstring, `.env.example` `BOR_SUGGESTIONS`, `README.md` — "session openers" wording
- Diff scope correct: only the 6 expected files + phase-file moves; `app/rag/suggestions.py` and `frontend/` untouched
**Test / lint / coverage results:**
- `uv run pytest tests/integration/test_suggestions_api.py -v` → 12 passed
- `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov` → 8 passed in isolation (opener-only core pin included)
- `test_responsive_polish.py` → 7 passed; `test_chat_persistence.py` → 4 passed (both isolated, no edits)
- `uv run pytest --cov=app --cov-report=term-missing` → 2086 passed, TOTAL 99% (>90%); `app/api/suggestions.py` 100%
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings
**Completion criteria:** all 7 ✅ (follow-ups-never-surface pin; cap-across-chats pin; seed/dedup/case/partial/A3/401 pins; E2E suites isolated; deflection chips unchanged; full suite + lint; commit + dir move left to harness per executor rules).
**Deviations:** none — no defects found; nothing changed in this pass.
**Next pending phase:** `98_sync_summary_visibility` (numeric order in `todo/`).
@@ -0,0 +1,96 @@
........................................................................ [ 3%]
........................................................................ [ 6%]
........................................................................ [ 10%]
........................................................................ [ 13%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 27%]
........................................................................ [ 31%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 44%]
........................................................................ [ 48%]
........................................................................ [ 51%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 69%]
........................................................................ [ 72%]
........................................................................ [ 75%]
........................................................................ [ 79%]
........................................................................ [ 82%]
........................................................................ [ 86%]
........................................................................ [ 89%]
........................................................................ [ 93%]
........................................................................ [ 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 181 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 134 1 99%
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 33 0 100%
app/api/sync.py 115 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 125 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 121 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 197 3 98%
app/rag/llm.py 243 1 99%
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 307 0 100%
--------------------------------------------------
TOTAL 3866 15 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,16 @@
Task 01 complete — all green.
**Implemented**
- `app/api/suggestions.py`: renamed `last_questions` → `opening_questions` (module-internal; no other importers); replaced the REVERSE every-user-question walk with the FORWARD opener walk (first non-blank `who=="user"` message per chat; leading blanks skipped, no-blank-user chats contribute nothing); phase-80 order (`updated_at`/`created_at` DESC), exact dedup, cap-3 (now binding across chats), and seed fallback unchanged.
- Dense docstrings: module = opener contract + WHY (qwen/llama.cpp follow-up example), function = forward walk + A3 rule + A4 why-not-`SavedChat.title` (`_auto_title` 120-char truncation/whitespace-collapse, user-editable), endpoint = "opening questions of the 3 most recent saved chats — or … the seed list".
- Rewrote `tests/integration/test_suggestions_api.py` matrix (12 tests): follow-ups-never-surface core pin (1 chat, 4 questions → exactly `[Q_ONE]`), new `test_cap_three_across_chats` (4 multi-turn chats → 3 newest openers, oldest dropped, no follow-ups anywhere), rewritten chat-order/dedup pair, partial pins (follow-ups don't pad), new A3 leading-blank test; seed/brain-only/401 pins kept. Added `FOLLOW_UP` text.
**Results**
- `uv run pytest tests/integration/test_suggestions_api.py -v` → 12 passed (DB up)
- `uv run pytest --cov=app --cov-report=term-missing` → 2086 passed; `app/api/suggestions.py` 100% (33/33); TOTAL 99% (>90%)
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings
- `grep last_questions app/ tests/` → no matches
**Decisions**: none beyond the phase design. E2E suite intentionally left on phase-80 semantics (task 03); docs wording is task 02. Only the two allowed files changed.
**Next pending task**: `02_openers_docs.md` (phase 103).
@@ -0,0 +1,96 @@
........................................................................ [ 3%]
........................................................................ [ 6%]
........................................................................ [ 10%]
........................................................................ [ 13%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 27%]
........................................................................ [ 31%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 44%]
........................................................................ [ 48%]
........................................................................ [ 51%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 69%]
........................................................................ [ 72%]
........................................................................ [ 75%]
........................................................................ [ 79%]
........................................................................ [ 82%]
........................................................................ [ 86%]
........................................................................ [ 89%]
........................................................................ [ 93%]
........................................................................ [ 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 181 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 134 1 99%
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 33 0 100%
app/api/sync.py 115 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 125 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 121 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 197 3 98%
app/rag/llm.py 243 1 99%
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 307 0 100%
--------------------------------------------------
TOTAL 3866 15 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,17 @@
Task 02 complete — all gates green.
**Implemented (task 02 — "session openers" wording):**
- `app/config.py` — `suggestions` seed comment now describes "the opening questions of the 3 most recent saved chats (the session openers — a chat's first user question; follow-ups never chip — phase 103)"; `BOR_SUGGESTIONS` override sentence kept
- `.env.example` — `BOR_SUGGESTIONS` comment appended with "; after that the chips are the 3 newest chats' opening questions (phase 103)"
- `README.md` — chat-features line now reads "follow the opening questions of the 3 most recent saved chats (each chat's first question — a follow-up never chips, phase 103)"; surrounding sentence intact
- `app/api/suggestions.py` — module docstring's historical "last 3 questions asked" phrase reworded (task-01 file, comment-only) so the required grep sweep over `app/` returns zero hits; stale gitignored `.pyc` that matched the sweep removed
**Test / lint / coverage:**
- `grep -rn "last 3 questions" app/ .env.example README.md` → zero hits
- `uv run pytest tests/unit/test_config.py -q` → 45 passed
- `uv run pytest --cov=app --cov-report=term-missing` → 2086 passed, `app/` coverage **99%** (>90%), `app/api/suggestions.py` 100%
- `uv run ruff check . && uv run pyright` → clean (0 errors)
**Decisions:** the docstring rewording in `suggestions.py` was necessary — task 02's grep sweep covers all of `app/`, and task 01's docstring retained the retired phrase as history; the full WHY (follow-ups meaningless without their session, qwen/llama.cpp example) remains in that module docstring. No logic touched; changes left uncommitted in the working tree per harness protocol.
**Next pending task:** `.agents/phases/todo/103_suggestions_session_openers/03_e2e_suite_commit.md` (story-suite E2E rewrite + full gate + atomic commit).
@@ -0,0 +1,96 @@
........................................................................ [ 3%]
........................................................................ [ 6%]
........................................................................ [ 10%]
........................................................................ [ 13%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 27%]
........................................................................ [ 31%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 44%]
........................................................................ [ 48%]
........................................................................ [ 51%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 69%]
........................................................................ [ 72%]
........................................................................ [ 75%]
........................................................................ [ 79%]
........................................................................ [ 82%]
........................................................................ [ 86%]
........................................................................ [ 89%]
........................................................................ [ 93%]
........................................................................ [ 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 181 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 134 1 99%
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 33 0 100%
app/api/sync.py 115 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 125 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 121 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 197 3 98%
app/rag/llm.py 243 1 99%
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 307 0 100%
--------------------------------------------------
TOTAL 3866 15 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,21 @@
All work for task 03 is complete and green. Per harness rules I left the move/commit to the harness (no `git add`/`commit`, task file untouched in `todo/`).
## Report
**Implemented**
- Rewrote `tests/e2e/test_suggestion_chips.py` in place to the phase-103 opener contract: new module docstring (opener contract + all states + run-in-isolation header); kept all scaffolding (`SEED` literal pin, `login`, autouse `saved_chats` TRUNCATE, fixture-KB import, `MOCK_ANSWER_MARKER`)
- States pinned: **seed** (unchanged), **opener-only** (NEW core state — a 3-turn chat yields EXACTLY its opener as the single chip; Q2/Q3 absent — the owner's qwen/llama.cpp scenario), **three-openers** (3 multi-turn chats, API-stamped `updated_at`, 3 openers newest-first, no follow-ups), **partial** (2 multi-turn chats → 2 opener chips, no seed top-up), **refetch** (multi-turn save → New chat → exactly the opener + 2nd `GET /api/suggestions`)
- Carried over unchanged: one-tap submit, Tab+Enter keyboard walk, mobile single-scroll row
- Regression E2Es `test_responsive_polish.py` + `test_chat_persistence.py`: green **without any edits** (they only assert chip visibility/contrast/flow, not content)
**Gate results**
- `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov` → 8 passed (isolation, DB up)
- `uv run pytest tests/e2e/test_responsive_polish.py -v --no-cov` → 7 passed; `.../test_chat_persistence.py` → 4 passed
- `uv run pytest --cov=app --cov-report=term-missing` → 2086 passed, **99%** total (`app/api/suggestions.py` 100%)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
**Notable decisions**
- Diff scope verified per Work step 4: only `app/api/suggestions.py`, `tests/integration/test_suggestions_api.py`, `tests/e2e/test_suggestion_chips.py`, `app/config.py`, `.env.example`, `README.md` + phase files; `app/rag/suggestions.py`, `frontend/`, `alembic/` have zero delta
- Skipped task step 5 (phase-dir move + commit) — harness owns both, per the pipeline override
**Next pending task:** phase 103 complete; queue resumes at `98_sync_summary_visibility/01_summary_phase_status.md`
@@ -0,0 +1,19 @@
## Report
**What was fixed**
- No code defect — the rejected run failed from **shared-DB corruption** that landed between attempts: `alembic_version` had been rolled back to `0003` and the `documents`/`chunks` tables were left with **1592 dead (dropped) `pg_attribute` rows each**, hitting Postgres's 1600-column hard limit, so every integration test's DDL-dependent fixture errored (`relation does not exist` / `TooManyColumns`)
- `VACUUM FULL` proved insufficient (it rewrites the heap but keeps dead catalog rows), so I rebuilt the database: terminated backends, `DROP/CREATE DATABASE brain_of_reese`, `uv run alembic upgrade head` (all 18 migrations, extension created by 0001)
- Phase-103 task code (E2E rewrite + tasks 01/02 changes) was already green and is **unchanged** — working tree identical to the accepted-diff scope
**Gate results (all re-run after the repair)**
- `uv run pytest --cov=app --cov-report=term-missing` → **2086 passed**, TOTAL **99%** (>90%; `app/api/suggestions.py` 100%)
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors
- `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov` → 8 passed (isolation)
- `.../test_responsive_polish.py` → 7 passed; `.../test_chat_persistence.py` → 4 passed (isolation, no edits)
- `git diff --stat` scope unchanged: `app/api/suggestions.py`, `tests/integration/test_suggestions_api.py`, `tests/e2e/test_suggestion_chips.py`, `app/config.py`, `.env.example`, `README.md` + phase files; zero delta in `alembic/`, `frontend/`, `app/rag/suggestions.py`
**Notable decisions**
- DB rebuild is safe: both tables held only disposable E2E fixture data; no repo files modified by the repair
- Phase-dir move + commit still left to the harness (pipeline override)
**Next pending task:** phase 103 complete; queue resumes at `98_sync_summary_visibility/01_summary_phase_status.md`
@@ -0,0 +1,96 @@
........................................................................ [ 3%]
........................................................................ [ 6%]
........................................................................ [ 10%]
........................................................................ [ 13%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 27%]
........................................................................ [ 31%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 44%]
........................................................................ [ 48%]
........................................................................ [ 51%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 69%]
........................................................................ [ 72%]
........................................................................ [ 75%]
........................................................................ [ 79%]
........................................................................ [ 82%]
........................................................................ [ 86%]
........................................................................ [ 89%]
........................................................................ [ 93%]
........................................................................ [ 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 181 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 134 1 99%
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 33 0 100%
app/api/sync.py 115 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 125 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 121 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 197 3 98%
app/rag/llm.py 243 1 99%
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 307 0 100%
--------------------------------------------------
TOTAL 3866 15 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
+1 -1
View File
@@ -60,7 +60,7 @@ BOR_RRF_K=60 # Reciprocal Rank Fusion damping constant
# insensitive, no partial names — phase 102). A blank list or a # insensitive, no partial names — phase 102). A blank list or a
# malformed token (e.g. md,sh!) fails startup loudly, naming the value. # malformed token (e.g. md,sh!) fails startup loudly, naming the value.
BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py,container,network,volume,image,pod,kube,swap,os,endpoint,j2 BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py,container,network,volume,image,pod,kube,swap,os,endpoint,j2
# BOR_SUGGESTIONS=["How is my Kubernetes cluster set up?"] # JSON seed chips — shown only before any question has been saved (phase 80) # BOR_SUGGESTIONS=["How is my Kubernetes cluster set up?"] # JSON seed chips — shown only before any question has been saved (phase 80); after that the chips are the 3 newest chats' opening questions (phase 103)
# --- Import sources (git; phase 28, admin-managed since phase 35) --- # --- Import sources (git; phase 28, admin-managed since phase 35) ---
# Comma-separated git repo URLs; import_docs clones each (first run) or # Comma-separated git repo URLs; import_docs clones each (first run) or
+3 -2
View File
@@ -71,8 +71,9 @@ client-side router. Direct URLs deep-link to the matching view.
Ask questions; answers stream in with **source chips** that cite the exact Ask questions; answers stream in with **source chips** that cite the exact
documents used. Clicking a chip opens the document in an **almost-fullscreen documents used. Clicking a chip opens the document in an **almost-fullscreen
modal** on the same page (no new tab). The onboarding suggestion chips modal** on the same page (no new tab). The onboarding suggestion chips
follow the last 3 questions asked — on a fresh deployment they seed from follow the opening questions of the 3 most recent saved chats (each
`BOR_SUGGESTIONS`. chat's first question — a follow-up never chips, phase 103) — on a
fresh deployment they seed from `BOR_SUGGESTIONS`.
### RAG / Knowledge base (`/sources.html`) ### RAG / Knowledge base (`/sources.html`)
+55 -26
View File
@@ -1,11 +1,20 @@
"""Suggested-question endpoint (drives the onboarding chips in the UI). """Suggested-question endpoint (drives the onboarding chips in the UI).
Phase 80: the chips are the **last 3 questions asked** — the three Phase 103: the chips are the **session openers** — each saved chat
most recent user questions across ALL saved chats (chats walked contributes AT MOST ONE chip: the first non-blank user message (the
newest-``updated_at`` first, each chat's messages walked newest-first, question that OPENED the session; the chats are walked newest-
exact de-duplicated, cap 3 — see :func:`last_questions`). A fresh ``updated_at`` first, exact de-duplicated, cap 3 — see
deployment — zero saved questions — gets the seed list instead :func:`opening_questions`). Follow-up questions can NEVER surface:
(``BOR_SUGGESTIONS`` override, or the built-in default). a follow-up like "What about qwen 3.6 35b?" (asked after "What are
the correct arguments for qwen 3.8 27b on llama.cpp?") is meaningless
as a conversation starter without the session behind it — the only
questions that make sense on their own are the session openers (owner
2026-09-12). A fresh deployment — zero saved openers — gets the seed
list instead (``BOR_SUGGESTIONS`` override, or the built-in default).
Phase 80 introduced the endpoint and the surviving contracts — the
newest-``updated_at`` walk, exact de-dup, the cap of 3, the seed
fallback — with a walk that surfaced EVERY user question, newest-
first; phase 103 replaces that walk with the opener rule.
Phase 79: user-gated (``require_user``) — the chips are part of the app Phase 79: user-gated (``require_user``) — the chips are part of the app
surface (chat, suggestions, cited documents); the ONLY anonymous surface (chat, suggestions, cited documents); the ONLY anonymous
@@ -31,26 +40,41 @@ from app.schemas import SuggestionList
router = APIRouter(tags=["chat"]) router = APIRouter(tags=["chat"])
def last_questions(db: Session, limit: int = 3) -> list[str]: def opening_questions(db: Session, limit: int = 3) -> list[str]:
"""The ``limit`` most recent user questions, across all saved chats. """The OPENING question of each saved chat, newest chat first.
Chats are walked ``updated_at DESC, created_at DESC`` (the Chats are walked ``updated_at DESC, created_at DESC`` (the
tiebreak keeps the order deterministic when timestamps collide); tiebreak keeps the order deterministic when timestamps collide).
each chat's ``messages`` (a JSONB column that deserializes to a Each chat's ``messages`` (a JSONB column that deserializes to a
plain Python list of ``bor.chat.v1`` dicts — NO SQL JSON ops plain Python list of ``bor.chat.v1`` dicts — NO SQL JSON ops
needed, the record shape is the ``SavedChat.messages`` model needed, the record shape is the ``SavedChat.messages`` model
docstring) is walked in REVERSE (conversational order is docstring) is walked FORWARD (conversational order is
oldest→newest), collecting the whitespace-trimmed ``text`` of oldest→newest): the FIRST entry with ``who == "user"`` whose
every entry with ``who == "user"``. Blank texts are skipped. whitespace-trimmed ``text`` is non-blank is the chat's opener.
A LEADING blank user entry does NOT disqualify the chat (the UI
cannot produce one — ``handleSend`` trims and guards empty text;
keep scanning), and a chat with no non-blank user message
(brain-only, or blank-user-only) contributes nothing.
The opener is read from the raw ``messages`` record, NOT from
``SavedChat.title``: the title is whitespace-collapsed and
TRUNCATED to 120 chars at save time (``app.api.chats._auto_title``)
and is user-editable on re-Save — the chips must carry the EXACT
full opener text.
Why the opener and not every user question (the phase-80 rule,
replaced): a follow-up only makes sense inside the session that
asked it, so follow-ups never become chips.
De-duplication is EXACT (case-sensitive) against the collected De-duplication is EXACT (case-sensitive) against the collected
window: a verbatim re-ask counts once, while a legitimately window: a verbatim re-ask counts once, while a legitimately
differently-cased re-ask is kept (case-insensitive dedup would differently-cased re-ask is kept (case-insensitive dedup would
drop it). The walk stops once ``limit`` UNIQUE texts are drop it). The walk stops once ``limit`` UNIQUE openers are
collected; the result is in encounter order (newest first). collected (the cap binds ACROSS chats); the result is in
encounter order (newest chat first).
Pure-DB helper (unit-testable without the endpoint); returns Pure-DB helper (unit-testable without the endpoint); returns
``[]`` when no saved question exists (the caller then falls back ``[]`` when no saved opener exists (the caller then falls back
to the seed list). to the seed list).
""" """
result: list[str] = [] result: list[str] = []
@@ -60,14 +84,18 @@ def last_questions(db: Session, limit: int = 3) -> list[str]:
SavedChat.updated_at.desc(), SavedChat.created_at.desc() SavedChat.updated_at.desc(), SavedChat.created_at.desc()
) )
): ):
for m in reversed(chat.messages or []): opener: str | None = None
for m in chat.messages or []:
if m.get("who") != "user": if m.get("who") != "user":
continue continue
question = str(m.get("text", "")).strip() question = str(m.get("text", "")).strip()
if not question or question in seen: if question:
opener = question
break
if opener is None or opener in seen:
continue continue
seen.add(question) seen.add(opener)
result.append(question) result.append(opener)
if len(result) >= limit: if len(result) >= limit:
return result return result
return result return result
@@ -78,11 +106,12 @@ def suggestions(
_user: None = Depends(require_user), # noqa: B008 # phase 79: admin or live token _user: None = Depends(require_user), # noqa: B008 # phase 79: admin or live token
db: Session = Depends(get_db), # noqa: B008 db: Session = Depends(get_db), # noqa: B008
) -> SuggestionList: ) -> SuggestionList:
"""The onboarding chips (admin OR token user, else 401): the last 3 """The onboarding chips (admin OR token user, else 401): the opening
questions asked across saved chats — or, before any question has questions of the 3 most recent saved chats — or, before any
ever been saved, the seed list (``BOR_SUGGESTIONS`` / the question has ever been saved, the seed list (``BOR_SUGGESTIONS`` /
built-in default). The deflection "Maybe try" chips are a separate the built-in default). The deflection "Maybe try" chips are a
contract (``app.rag.suggestions.derive_suggestions``), untouched. separate contract (``app.rag.suggestions.derive_suggestions``),
untouched.
""" """
qs = last_questions(db) qs = opening_questions(db)
return SuggestionList(suggestions=qs if qs else get_settings().suggestions) return SuggestionList(suggestions=qs if qs else get_settings().suggestions)
+5 -3
View File
@@ -375,9 +375,11 @@ class Settings(BaseSettings):
# Onboarding-chip SEED (phase 80, TODO.md L6): shown ONLY while no # Onboarding-chip SEED (phase 80, TODO.md L6): shown ONLY while no
# saved chat has ever asked a question — after that, # saved chat has ever asked a question — after that,
# ``GET /api/suggestions`` serves the last 3 questions asked # ``GET /api/suggestions`` serves the opening questions of the 3
# (deployment-wide, newest first). ``BOR_SUGGESTIONS`` overrides # most recent saved chats (the session openers — a chat's first
# this seed for a new deployment. # user question; follow-ups never chip — phase 103; deployment-
# wide, newest first). ``BOR_SUGGESTIONS`` overrides this seed
# for a new deployment.
suggestions: list[str] = [ suggestions: list[str] = [
"What documents are in the knowledge base?", "What documents are in the knowledge base?",
"Which source does each answer come from?", "Which source does each answer come from?",
+170 -99
View File
@@ -1,42 +1,56 @@
"""Phase 80 E2E (Playwright): onboarding chips = the last 3 questions asked. """Phase 103 E2E (Playwright): onboarding chips = the session openers.
Story: ``.agents/user_stories/suggestion-chips.md`` (phase 05) — Story: ``.agents/user_stories/suggestion-chips.md`` (phase 05) —
REWRITTEN in place for the phase-80 semantics (the phase-76 precedent: REWRITTEN in place for the phase-103 semantics (the phase-76/80
a semantic change rewrites the story suite in place). Source: precedent: a semantic change rewrites the story suite in place).
``TODO.md`` L6. Source: owner request 2026-09-12 — a suggested question must make
sense on its own, and a follow-up never does.
The new contract (owner decision A6): the empty-state chip row is the The new contract (owner decision A1): the chips are the **session
3 most recent user questions across ALL saved chats — chats walked openers** — each saved chat contributes AT MOST ONE chip: its FIRST
newest-``updated_at`` first, each chat's messages newest-first, non-blank user message, the question that OPENED the session. Follow-up
exact (case-sensitive) de-duplicated, cap 3. A fresh deployment — zero questions can NEVER surface: a follow-up like "What about qwen 3.6
saved questions — gets the SEED list instead (``BOR_SUGGESTIONS`` / 35b?" (asked after "What are the correct arguments for qwen 3.8 27b on
the built-in default). 1–2 saved questions → exactly those chips (NO llama.cpp?") is meaningless as a conversation starter without the
mixing with the seed). The row refetches when the empty state comes session behind it. Everything else is the phase-80 contract, unchanged:
back (New chat), so it is never stale. The deflection "Maybe try" chats are walked newest-``updated_at`` first, openers are exact
chips are a separate contract (``derive_suggestions``) — untouched. (case-sensitive) de-duplicated, cap 3 — the cap binds ACROSS chats. A
fresh deployment — zero saved openers — gets the SEED list instead
(``BOR_SUGGESTIONS`` / the built-in default). The row refetches when
the empty state comes back (New chat), so it is never stale. The
deflection "Maybe try" chips are a separate contract
(``derive_suggestions``) — untouched.
The four states pinned here: The states pinned here:
* **seed** — fresh DB (no saved chats) → the chip texts equal the * **seed** (unchanged) — fresh DB (no saved chats) → the chip texts
built-in default list EXACTLY (the ``SEED`` literal below is the equal the built-in default list EXACTLY (the ``SEED`` literal below
pin for the exact seed list — ``tests/unit/test_config.py`` pins is the pin for the exact seed list — ``tests/unit/test_config.py``
only the shape) — rendered as accessible buttons in the role=list pins only the shape) — rendered as accessible buttons in the
group, exactly as the phase-05 component contract; role=list group, exactly as the phase-05 component contract;
* **last-3** — two saved chats with 5 user questions total (the older * **opener-only** (the NEW core state — the owner's exact scenario) —
one saved FIRST — the API stamps ``updated_at``) → a fresh page ONE saved chat with a 3-turn conversation (the opener Q1, the
load shows EXACTLY the 3 newest questions, newest-first; follow-up Q2, the follow-up Q3, brain replies between) → a fresh
* **partial** — exactly 2 saved questions deployment-wide → exactly page load shows EXACTLY ONE chip: Q1 (the opener); Q2/Q3 are absent;
2 chips (no seed top-up — the A6 contract, visible in the UI); * **three-openers** (replaces the old "last-3" state) — THREE saved
* **refetch** — boot with the seed chips, save a chat whose newest chats, each multi-turn (opener + at least one follow-up), DISTINCT
question is Q via the API, click New chat (``#new-chat-btn``) → the ``updated_at`` (the API stamps them on save — the test saves
chips now are Q, and the request log shows a SECOND oldest→newest) → exactly 3 chips = the three openers, newest
``GET /api/suggestions`` (the boot fetch was the first). ``updated_at`` first; none of the chats' FOLLOW-UPS appears;
* **partial** (kept, re-scoped) — exactly 2 saved (multi-turn) chats →
exactly 2 chips (the two openers — NO seed top-up; the follow-ups in
those chats do not pad the row);
* **refetch** (kept) — boot with the seed chips → save a multi-turn
chat (opener Q + a follow-up) via the API → click New chat
(``#new-chat-btn``) → the chips now are exactly Q, and the request
log shows a SECOND ``GET /api/suggestions`` (the boot fetch was the
first).
Carried-over story behavior (unchanged semantics from the phase-05 Carried-over story behavior (unchanged semantics from the phase-05/80
suite): one-tap submit (chip click → composer filled → submitted → suites): one-tap submit (chip click → composer filled → submitted →
the mock-LLM brain bubble), Tab+Enter keyboard reachability of the the mock-LLM brain bubble with the ``MOCK_ANSWER_MARKER``), Tab+Enter
chips (the keyboard-walk assertion), and the mobile single keyboard reachability of the chips (the keyboard-walk assertion), and
horizontal-scroll row. the mobile single horizontal-scroll row.
The endpoint is authed (phase 79, ``require_user``), so every test The endpoint is authed (phase 79, ``require_user``), so every test
signs in as admin first (``auth_helpers.login``). ``saved_chats`` is signs in as admin first (``auth_helpers.login``). ``saved_chats`` is
@@ -55,7 +69,6 @@ import asyncio
import json import json
import time import time
from collections.abc import Iterator from collections.abc import Iterator
from datetime import datetime
from pathlib import Path from pathlib import Path
from threading import Thread from threading import Thread
from typing import Any from typing import Any
@@ -75,12 +88,12 @@ FIXTURES = REPO / "tests" / "fixtures" / "docs"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E" MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
#: The EXACT built-in onboarding SEED (phase 80, TODO.md L6): the chip #: The EXACT built-in onboarding SEED (phase 80, TODO.md L6): the chip
#: row of a brand-new deployment, shown only before any question has #: row of a brand-new deployment, shown only while no saved chat has
#: ever been saved. This literal is the E2E pin for the exact seed #: ever opened with a question. This literal is the E2E pin for the
#: list — ``tests/unit/test_config.py`` pins only the SHAPE (>=3 #: exact seed list — ``tests/unit/test_config.py`` pins only the SHAPE
#: non-blank distinct strings), and the e2e app under test is forced #: (>=3 non-blank distinct strings), and the e2e app under test is
#: to the code default by conftest's leak guard — keep in sync with #: forced to the code default by conftest's leak guard — keep in sync
#: the ``Settings.suggestions`` default in ``app/config.py``. #: with the ``Settings.suggestions`` default in ``app/config.py``.
SEED: list[str] = [ SEED: list[str] = [
"What documents are in the knowledge base?", "What documents are in the knowledge base?",
"Which source does each answer come from?", "Which source does each answer come from?",
@@ -91,10 +104,10 @@ SEED: list[str] = [
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def clean_chats(db_ready: None) -> Iterator[None]: def clean_chats(db_ready: None) -> Iterator[None]:
"""``saved_chats`` is the state the phase-80 contract reads: """``saved_chats`` is the state the phase-103 contract reads:
truncate it before and after every test so each state test starts truncate it before and after every test so each state test starts
from (and leaves) an empty deployment. Unlike the KB tables, this from (and leaves) an empty deployment. Unlike the KB tables, this
reset is non-optional — the chips ARE these rows, and the reset is non-optional — the chips ARE these rows' openers, and the
carried-over submit tests auto-save a row per turn, which would carried-over submit tests auto-save a row per turn, which would
otherwise leak into the later state tests.""" otherwise leak into the later state tests."""
with SessionLocal() as db: with SessionLocal() as db:
@@ -223,92 +236,142 @@ def test_seed_state_chips_are_the_builtin_default(
assert box is not None and box["height"] >= 44 assert box is not None and box["height"] >= 44
def test_last_three_questions_state(page: Page, app_url: str, db_ready: None) -> None: def test_opener_only_state(page: Page, app_url: str, db_ready: None) -> None:
"""5 user questions across two saved chats (the older one saved """The NEW core state — the owner's exact scenario: ONE saved chat
FIRST — the API stamps ``updated_at`` at INSERT) → a fresh page with a 3-turn conversation (opener Q1, follow-up Q2, follow-up Q3)
load shows EXACTLY the 3 newest questions, newest-first: the → a fresh page load shows EXACTLY ONE chip: Q1 (the opener). Q2/Q3
newer chat is walked first, then the older chat newest-first.""" are absent — a follow-up like "What about …?" is meaningless as a
conversation starter without the session behind it."""
page.set_default_timeout(30_000) page.set_default_timeout(30_000)
login(page, app_url, next="/") login(page, app_url, next="/")
old_q = [ opener = "What are the correct arguments for qwen 3.8 27b on llama.cpp?"
"How did I install the GitLab runner on the Proxmox node?", follow_up_1 = "What about qwen 3.6 35b?"
"Which disk holds the Borg backup archives?", follow_up_2 = "And which of the three needs the most VRAM?"
"How is the nftables firewall rule set ordered?",
]
new_q = [
"What TLS termination does Traefik do for homelab.local?",
"Which provider is the primary DNS for reeseapps.com?",
]
# The OLDER chat first: the API stamps ``updated_at`` (server # ONE multi-turn chat (three user questions, brain replies between)
# now()), so save order IS walk order. The short pause keeps the # — exactly the owner's session shape.
# two stamps strictly apart (and the assert below pins that the _save_chat(
# order the walk sees is the order the test intended).
older = _save_chat(
page, page,
app_url, app_url,
[ [
_user(old_q[0]), _brain(), _user(opener), _brain(),
_user(old_q[1]), _brain(), _user(follow_up_1), _brain(),
_user(old_q[2]), _brain(), _user(follow_up_2), _brain(),
], ],
) )
time.sleep(0.05)
newer = _save_chat(
page,
app_url,
[
_user(new_q[0]), _brain(),
_user(new_q[1]), _brain(),
],
)
assert datetime.fromisoformat(newer["updated_at"]) > datetime.fromisoformat(
older["updated_at"]
), "the two API-stamped updated_at values must be strictly ordered"
# A FRESH page load (a new boot fetch, not the pre-save boot): # A FRESH page load (a new boot fetch, not the pre-save boot):
# the chips are exactly the 3 newest questions, newest first. # EXACTLY ONE chip — the chat's opener, and nothing else.
page.goto(app_url + "/") page.goto(app_url + "/")
chips = _chip_locator(page) chips = _chip_locator(page)
expect(chips.first).to_be_visible(timeout=30_000) expect(chips.first).to_be_visible(timeout=30_000)
expected = [new_q[1], new_q[0], old_q[2]] assert chips.count() == 1, "a 3-turn chat yields EXACTLY its opener as the single chip"
assert _chip_texts(page) == expected texts = _chip_texts(page)
assert _api_suggestions(page, app_url) == expected assert texts == [opener], "the single chip is the EXACT full opener text"
# The two older questions (and everything seed-shaped) are gone. assert follow_up_1 not in texts, "the 'What about …?' follow-up must never chip"
assert old_q[0] not in _chip_texts(page) assert follow_up_2 not in texts
assert old_q[1] not in _chip_texts(page) # ...and the endpoint itself holds the same contract (same row).
assert _api_suggestions(page, app_url) == [opener]
def test_partial_state_no_seed_topup(page: Page, app_url: str, db_ready: None) -> None: def test_three_openers_newest_first(page: Page, app_url: str, db_ready: None) -> None:
"""Exactly 2 saved questions deployment-wide → EXACTLY 2 chips """THREE saved chats, each multi-turn (opener + at least one
(newest first) — NO mixing/top-up with the seed (the A6 follow-up), DISTINCT ``updated_at`` (the API stamps them on save —
contract, visible in the UI).""" the test saves oldest→newest) → a fresh page load shows EXACTLY
the three openers, newest ``updated_at`` first; none of the chats'
FOLLOW-UPS appears anywhere in the row."""
page.set_default_timeout(30_000) page.set_default_timeout(30_000)
login(page, app_url, next="/") login(page, app_url, next="/")
a = "How do I rotate the WireGuard keys on the VPN node?" openers = [
b = "What cron schedule runs the restic prune?" "How did I install the GitLab runner on the Proxmox node?",
_save_chat(page, app_url, [_user(a), _brain()]) "What TLS termination does Traefik do for homelab.local?",
"Which provider is the primary DNS for reeseapps.com?",
]
follow_ups = [
"What about the runners' Docker socket access?",
"And does it terminate mTLS for the internal services?",
"What about the secondary DNS for the LAN?",
]
# Save oldest→newest: the API stamps ``updated_at`` (server
# now()), so save order IS walk order. The short pauses keep the
# three stamps strictly apart (and the assert below pins that the
# order the walk sees is the order the test intended).
stamps: list[str] = []
for opener, follow_up in zip(openers, follow_ups, strict=True):
body = _save_chat(
page,
app_url,
[
_user(opener), _brain(),
_user(follow_up), _brain(),
],
)
stamps.append(body["updated_at"])
time.sleep(0.05) time.sleep(0.05)
_save_chat(page, app_url, [_user(b), _brain()]) assert stamps == sorted(stamps) and len(set(stamps)) == 3, (
"the three API-stamped updated_at values must be strictly increasing"
)
# A FRESH page load (a new boot fetch, not the pre-save boot):
# exactly the three openers, newest first — the cap of 3 binds
# ACROSS chats, and every chip is a session's OPENER.
page.goto(app_url + "/")
chips = _chip_locator(page)
expect(chips.first).to_be_visible(timeout=30_000)
expected = list(reversed(openers))
texts = _chip_texts(page)
assert chips.count() == 3
assert texts == expected
assert _api_suggestions(page, app_url) == expected
for follow_up in follow_ups:
assert follow_up not in texts, "a chat's follow-up must never chip"
def test_partial_state_no_seed_topup(page: Page, app_url: str, db_ready: None) -> None:
"""Exactly 2 saved (multi-turn) chats → EXACTLY 2 chips (the two
openers, newest first) — NO mixing/top-up with the seed (the
phase-80 A6 contract, visible in the UI), and the follow-ups in
those chats do not pad the row."""
page.set_default_timeout(30_000)
login(page, app_url, next="/")
a_opener = "How do I rotate the WireGuard keys on the VPN node?"
a_follow_up = "What about the peers' allowed-ips?"
b_opener = "What cron schedule runs the restic prune?"
b_follow_up = "And where do the restic lock files live?"
_save_chat(
page,
app_url,
[_user(a_opener), _brain(), _user(a_follow_up), _brain()],
)
time.sleep(0.05)
_save_chat(
page,
app_url,
[_user(b_opener), _brain(), _user(b_follow_up), _brain()],
)
page.goto(app_url + "/") page.goto(app_url + "/")
chips = _chip_locator(page) chips = _chip_locator(page)
expect(chips.first).to_be_visible(timeout=30_000) expect(chips.first).to_be_visible(timeout=30_000)
assert chips.count() == 2, "exactly 2 chips — the row is never padded toward 3" assert chips.count() == 2, "exactly 2 chips — the row is never padded toward 3"
texts = _chip_texts(page) texts = _chip_texts(page)
assert texts == [b, a] assert texts == [b_opener, a_opener]
assert a_follow_up not in texts and b_follow_up not in texts
assert not (set(texts) & set(SEED)), "no seed text may appear once a question is saved" assert not (set(texts) & set(SEED)), "no seed text may appear once a question is saved"
def test_new_chat_refetches_the_chips(page: Page, app_url: str, db_ready: None) -> None: def test_new_chat_refetches_the_chips(page: Page, app_url: str, db_ready: None) -> None:
"""The row is never stale: boot with the seed chips → save a chat """The row is never stale: boot with the seed chips → save a
whose newest question is Q via the API → click New chat multi-turn chat (opener Q + a follow-up) via the API → click New
(``#new-chat-btn``) → the empty state comes back with the chat (``#new-chat-btn``) → the empty state comes back with the
REFETCHED row (exactly Q — the deployment now has one saved REFETCHED row (exactly Q — the deployment now has one saved
question), and the request log shows a SECOND OPENER; the chat's follow-up is never a chip), and the request log
``GET /api/suggestions`` (the boot fetch was the first).""" shows a SECOND ``GET /api/suggestions`` (the boot fetch was the
first)."""
page.set_default_timeout(30_000) page.set_default_timeout(30_000)
sugg_gets: list[float] = [] sugg_gets: list[float] = []
@@ -325,13 +388,21 @@ def test_new_chat_refetches_the_chips(page: Page, app_url: str, db_ready: None)
assert len(sugg_gets) == 1, "exactly one GET /api/suggestions at boot" assert len(sugg_gets) == 1, "exactly one GET /api/suggestions at boot"
q = "Which service fronts the Pi-hole DNS on the network?" q = "Which service fronts the Pi-hole DNS on the network?"
_save_chat(page, app_url, [_user(q), _brain()]) _save_chat(
page,
app_url,
[
_user(q), _brain(),
_user("What about the Pi-hole's DNSSEC settings?"), _brain(),
],
)
clicked_at = time.monotonic() clicked_at = time.monotonic()
page.click("#new-chat-btn") page.click("#new-chat-btn")
# The refetch re-renders #suggestions in place: the 4 seed chips # The refetch re-renders #suggestions in place: the 4 seed chips
# are replaced by exactly Q (the partial state, live). # are replaced by exactly Q (the partial state, live — the chat's
# follow-up does not pad the row).
expect(chips).to_have_count(1, timeout=15_000) expect(chips).to_have_count(1, timeout=15_000)
expect(chips.first).to_have_text(q, timeout=15_000) expect(chips.first).to_have_text(q, timeout=15_000)
assert len(sugg_gets) == 2, "New chat triggered the refetch" assert len(sugg_gets) == 2, "New chat triggered the refetch"
+186 -70
View File
@@ -1,12 +1,16 @@
"""Integration: the onboarding-chips endpoint (phase 80, task 01) — """Integration: the onboarding-chips endpoint (phase 103, task 01) —
the full state matrix of ``GET /api/suggestions``. the full state matrix of ``GET /api/suggestions``.
The chips are the **last 3 questions asked** — the three most recent The chips are the **session openers** — each saved chat contributes
user questions across ALL saved chats: chats are walked newest- AT MOST ONE chip: its first non-blank user message (the question that
``updated_at`` first (``created_at`` tiebreak), each chat's opened the session). Chats are walked newest-``updated_at`` first
``bor.chat.v1`` message list is walked newest-first, exact- (``created_at`` tiebreak), each chat's ``bor.chat.v1`` message list is
(case-sensitive) de-duplicated, capped at 3. A fresh deployment — walked FORWARD (oldest→newest, the record's conversational order),
zero saved questions — gets the SEED list instead openers are exact-(case-sensitive) de-duplicated, capped at 3 — the
cap binds ACROSS chats. Follow-up questions ("What about …?") can
NEVER surface: they are unanswerable without the session behind them
(owner 2026-09-12). Everything else is the phase-80 contract: a fresh
deployment — zero saved openers — gets the SEED list instead
(``get_settings().suggestions``: the ``BOR_SUGGESTIONS`` override or (``get_settings().suggestions``: the ``BOR_SUGGESTIONS`` override or
the built-in default). The override's JSON parsing is pinned at unit the built-in default). The override's JSON parsing is pinned at unit
level (``tests/unit/test_config.py``), so this suite stays level (``tests/unit/test_config.py``), so this suite stays
@@ -16,16 +20,27 @@ env-agnostic: the empty-DB contract is "exactly
Matrix (task item 2): Matrix (task item 2):
* empty DB → exactly ``get_settings().suggestions``; * empty DB → exactly ``get_settings().suggestions``;
* cap + order: 4 questions in ONE chat → the 3 newest, newest first; * one chat with 4 user questions → EXACTLY its opener (the first
* chat order: two chats with DISTINCT ``updated_at`` (stamped question); none of the 3 follow-ups appears;
explicitly) → the newer chat's questions outrank the older chat's * cap ACROSS chats: 4 multi-turn chats (distinct ``updated_at``) →
newest-LOOKING question; exactly the 3 NEWEST chats' openers, newest first; the oldest
* dedup: the same text asked in two chats → exactly once; a chat's opener is dropped by the cap; none of the chats' FOLLOW-UPS
differently-cased variant is KEPT (exact dedup); appears anywhere;
* partial: 1–2 saved questions deployment-wide → exactly those chips * chat order: two multi-turn chats with DISTINCT ``updated_at``
(NO seed top-up — the A6 contract); (stamped explicitly) → [newer chat's opener, older chat's opener];
* brain-only: all-``brain`` (or blank user texts) contribute nothing; the older chat's LAST (newest-looking) question is NOT in the
an all-brain deployment → the seed; chips;
* dedup: the SAME opener text as the first question of two chats →
exactly once (a verbatim re-ask as a FOLLOW-UP in the newer chat is
deduped too); a differently-cased OPENER variant → both kept (exact
dedup);
* partial: 2 multi-turn chats → 2 openers; 1 chat → 1 chip — the
follow-ups in those chats do NOT pad the row (NO seed top-up — the
phase-80 A6 contract);
* A3: a LEADING blank user entry does NOT disqualify the chat — the
first NON-BLANK user message is the opener;
* brain-only: all-``brain`` (or blank-user-only) chats contribute
nothing; an all-brain deployment → the seed;
* anonymous → 401 ``authentication required`` (the phase-79 contract, * anonymous → 401 ``authentication required`` (the phase-79 contract,
pinned here too). pinned here too).
@@ -51,13 +66,15 @@ from app.config import get_settings
from app.models import SavedChat from app.models import SavedChat
#: Fixed question texts — the matrix asserts EXACT chip lists, so the #: Fixed question texts — the matrix asserts EXACT chip lists, so the
#: texts are distinct per purpose. #: texts are distinct per purpose. The Q_* are opener-flavored;
#: FOLLOW_UP is the follow-up-flavored text (the owner's qwen example).
Q_ONE = "How did I install gitlab?" Q_ONE = "How did I install gitlab?"
Q_TWO = "Which node runs my Borg backups?" Q_TWO = "Which node runs my Borg backups?"
Q_THREE = "How do I prune deleted docs?" Q_THREE = "How do I prune deleted docs?"
Q_FOUR = "What proxy fronts reeseapps.com?" Q_FOUR = "What proxy fronts reeseapps.com?"
Q_FIVE = "How is my K3S cluster set up?" Q_FIVE = "How is my K3S cluster set up?"
Q_SIX = "How do I deploy a service?" Q_SIX = "How do I deploy a service?"
FOLLOW_UP = "What about qwen 3.6 35b?"
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -109,7 +126,7 @@ def _chips(admin_client: TestClient) -> list[str]:
def test_empty_db_returns_seed(admin_client: TestClient) -> None: def test_empty_db_returns_seed(admin_client: TestClient) -> None:
"""Zero saved questions → exactly the seed list — env-agnostic: """Zero saved openers → exactly the seed list — env-agnostic:
``get_settings().suggestions`` (the ``BOR_SUGGESTIONS`` override or ``get_settings().suggestions`` (the ``BOR_SUGGESTIONS`` override or
the built-in default, whatever the environment makes it).""" the built-in default, whatever the environment makes it)."""
r = admin_client.get("/api/suggestions") r = admin_client.get("/api/suggestions")
@@ -117,14 +134,15 @@ def test_empty_db_returns_seed(admin_client: TestClient) -> None:
assert r.json() == {"suggestions": get_settings().suggestions} assert r.json() == {"suggestions": get_settings().suggestions}
# ---------- cap + order within one chat ---------- # ---------- openers only: follow-ups never surface ----------
def test_cap_three_and_newest_first_within_a_chat( def test_a_chats_follow_ups_never_surface(admin_client: TestClient, db: Session) -> None:
admin_client: TestClient, db: Session """The phase-103 core pin: ONE chat with 4 user questions (brain
) -> None: replies between them, the 4th follow-up-flavored) → the chips hold
"""4 user questions (brain replies between them) in ONE chat → EXACTLY the chat's OPENER (its first question); none of the 3
exactly the 3 NEWEST, newest first.""" follow-ups appears (a follow-up like "What about …?" is
meaningless as a conversation starter without the session)."""
_add_chat( _add_chat(
db, db,
title="one long chat", title="one long chat",
@@ -132,97 +150,158 @@ def test_cap_three_and_newest_first_within_a_chat(
_user(Q_ONE), _brain("a1"), _user(Q_ONE), _brain("a1"),
_user(Q_TWO), _brain("a2"), _user(Q_TWO), _brain("a2"),
_user(Q_THREE), _brain("a3"), _user(Q_THREE), _brain("a3"),
_user(Q_FOUR), _brain("a4"), _user(FOLLOW_UP), _brain("a4"),
], ],
updated_at=datetime.now(UTC), updated_at=datetime.now(UTC),
) )
assert _chips(admin_client) == [Q_FOUR, Q_THREE, Q_TWO] chips = _chips(admin_client)
assert chips == [Q_ONE]
for follow_up in (Q_TWO, Q_THREE, FOLLOW_UP):
assert follow_up not in chips
# ---------- the cap binds ACROSS chats ----------
def test_cap_three_across_chats(admin_client: TestClient, db: Session) -> None:
"""FOUR multi-turn chats (opener + at least one follow-up each)
with DISTINCT explicit ``updated_at`` stamps → the chips are
EXACTLY the 3 NEWEST chats' openers, newest first; the oldest
chat's opener is dropped (the cap of 3 now binds ACROSS chats, not
within one chat); none of the four chats' FOLLOW-UPS appears
anywhere."""
base = datetime.now(UTC)
_add_chat(
db,
title="oldest",
messages=[_user(Q_FIVE), _brain("…"), _user(FOLLOW_UP), _brain("…")],
updated_at=base,
)
_add_chat(
db,
title="second",
messages=[_user(Q_THREE), _brain("…"), _user(Q_SIX), _brain("…")],
updated_at=base + timedelta(hours=1),
)
_add_chat(
db,
title="third",
messages=[_user(Q_TWO), _brain("…"), _user(Q_FOUR), _brain("…")],
updated_at=base + timedelta(hours=2),
)
_add_chat(
db,
title="newest",
messages=[_user(Q_ONE), _brain("…"), _user(FOLLOW_UP), _brain("…")],
updated_at=base + timedelta(hours=3),
)
chips = _chips(admin_client)
# Newest chat first: the 3 NEWEST openers; the oldest chat's
# opener (Q_FIVE) is dropped by the cap.
assert chips == [Q_ONE, Q_TWO, Q_THREE]
assert Q_FIVE not in chips
for follow_up in (Q_SIX, Q_FOUR, FOLLOW_UP):
assert follow_up not in chips
# ---------- chat order across chats ---------- # ---------- chat order across chats ----------
def test_newer_chat_walked_first(admin_client: TestClient, db: Session) -> None: def test_newer_chat_walked_first(admin_client: TestClient, db: Session) -> None:
"""Two chats with DISTINCT ``updated_at`` (stamped explicitly): """Two multi-turn chats with DISTINCT ``updated_at`` (stamped
the newer chat is walked FIRST — its single question outranks the explicitly): the newer chat is walked FIRST — its opener leads the
older chat's newest-LOOKING (last-in-conversation) question.""" older chat's opener; the older chat's LAST (newest-looking)
question — a follow-up — is NOT in the chips."""
base = datetime.now(UTC) base = datetime.now(UTC)
_add_chat( _add_chat(
db, db,
title="older chat", title="older chat",
messages=[_user(Q_FIVE), _brain("…"), _user(Q_SIX), _brain("…")], messages=[
_user(Q_FIVE), _brain("…"),
_user(Q_SIX), _brain("…"), # the older chat's LAST question
],
updated_at=base, updated_at=base,
) )
_add_chat( _add_chat(
db, db,
title="newer chat", title="newer chat",
messages=[_user(Q_ONE), _brain("…")], messages=[
_user(Q_ONE), _brain("…"),
_user(Q_TWO), _brain("…"), # a follow-up — never a chip
],
updated_at=base + timedelta(hours=2), updated_at=base + timedelta(hours=2),
) )
# Newer chat first (Q_ONE), then the older chat newest-first assert _chips(admin_client) == [Q_ONE, Q_FIVE]
# (Q_SIX — its LAST question — before Q_FIVE). # The older chat's LAST question and the newer chat's follow-up
assert _chips(admin_client) == [Q_ONE, Q_SIX, Q_FIVE] # must not surface.
assert Q_SIX not in _chips(admin_client)
assert Q_TWO not in _chips(admin_client)
# ---------- dedup ---------- # ---------- dedup (openers only) ----------
def test_verbatim_reask_counts_once_across_chats( def test_verbatim_reask_counts_once_across_chats(
admin_client: TestClient, db: Session admin_client: TestClient, db: Session
) -> None: ) -> None:
"""The SAME question text asked in two chats appears EXACTLY ONCE """The SAME opener text as the first question of two chats
in the chips.""" appears EXACTLY ONCE in the chips — and the newer chat's verbatim
re-ask AS A FOLLOW-UP stays deduped too (it is the same text as
the already-seen opener)."""
base = datetime.now(UTC) base = datetime.now(UTC)
_add_chat( _add_chat(
db, db,
title="older", title="older",
messages=[_user(Q_THREE), _brain("…")], messages=[_user(Q_ONE), _brain("…"), _user(Q_TWO), _brain("…")],
updated_at=base, updated_at=base,
) )
_add_chat( _add_chat(
db, db,
title="newer", title="newer",
messages=[ messages=[
_user(Q_ONE), _brain("…"), _user(Q_ONE), _brain("…"), # SAME opener as the older chat
_user(Q_THREE), _brain("…"), # verbatim re-ask (newer chat) _user(Q_TWO), _brain("…"), # verbatim re-ask AS A FOLLOW-UP
], ],
updated_at=base + timedelta(hours=2), updated_at=base + timedelta(hours=2),
) )
# Newest first: the re-ask (LAST message of the newer chat) leads —
# and it appears exactly once (the older chat's copy is deduped).
chips = _chips(admin_client) chips = _chips(admin_client)
assert chips == [Q_THREE, Q_ONE] assert chips == [Q_ONE]
assert chips.count(Q_THREE) == 1 assert chips.count(Q_ONE) == 1
assert Q_TWO not in chips
def test_dedup_is_exact_not_case_insensitive( def test_dedup_is_exact_not_case_insensitive(
admin_client: TestClient, db: Session admin_client: TestClient, db: Session
) -> None: ) -> None:
"""A differently-cased re-ask is a DIFFERENT question (exact, """A differently-cased OPENER variant is a DIFFERENT question
case-sensitive dedup — case-insensitive would drop it): both (exact, case-sensitive dedup — case-insensitive would drop it):
variants show, and the verbatim re-ask in the older chat still both variants show; the follow-ups in both chats do not."""
counts once."""
base = datetime.now(UTC) base = datetime.now(UTC)
lower_variant = Q_THREE.lower() lower_variant = Q_THREE.lower()
_add_chat( _add_chat(
db, db,
title="older", title="older",
messages=[_user(Q_THREE), _brain("…")], messages=[
_user(Q_THREE), _brain("…"), # opener
_user(Q_FOUR), _brain("…"), # follow-up
],
updated_at=base, updated_at=base,
) )
_add_chat( _add_chat(
db, db,
title="newer", title="newer",
messages=[ messages=[
_user(lower_variant), _brain("…"), _user(lower_variant), _brain("…"), # differently-cased opener
_user(Q_THREE), _brain("…"), _user(Q_ONE), _brain("…"), # follow-up
_user(Q_ONE), _brain("…"),
], ],
updated_at=base + timedelta(hours=2), updated_at=base + timedelta(hours=2),
) )
# Newer chat walked newest-first: Q_ONE, Q_THREE, lower_variant — chips = _chips(admin_client)
# all three kept (the case variant is NOT a duplicate). # Newer chat first: its opener, then the older chat's opener —
assert _chips(admin_client) == [Q_ONE, Q_THREE, lower_variant] # both kept (the case variant is NOT a duplicate).
assert chips == [lower_variant, Q_THREE]
assert Q_ONE not in chips
assert Q_FOUR not in chips
# ---------- partial: no seed top-up ---------- # ---------- partial: no seed top-up ----------
@@ -231,36 +310,52 @@ def test_dedup_is_exact_not_case_insensitive(
def test_exactly_two_questions_give_exactly_two_chips( def test_exactly_two_questions_give_exactly_two_chips(
admin_client: TestClient, db: Session admin_client: TestClient, db: Session
) -> None: ) -> None:
"""1–2 saved questions deployment-wide → EXACTLY those chips — NO """2 multi-turn chats → EXACTLY their 2 openers — NO
mixing/top-up with the seed (the A6 contract).""" mixing/top-up with the seed (the phase-80 A6 contract); the
follow-ups in those chats do not pad the row."""
base = datetime.now(UTC) base = datetime.now(UTC)
_add_chat( _add_chat(
db, db,
title="a", title="a",
messages=[_user(Q_TWO), _brain("…")], messages=[
_user(Q_TWO), _brain("…"),
_user(Q_THREE), _brain("…"), # follow-up — never a chip
],
updated_at=base, updated_at=base,
) )
_add_chat( _add_chat(
db, db,
title="b", title="b",
messages=[_user(Q_ONE), _brain("…")], messages=[
_user(Q_ONE), _brain("…"),
_user(FOLLOW_UP), _brain("…"), # follow-up — never a chip
],
updated_at=base + timedelta(hours=1), updated_at=base + timedelta(hours=1),
) )
assert _chips(admin_client) == [Q_ONE, Q_TWO] chips = _chips(admin_client)
assert chips == [Q_ONE, Q_TWO]
assert Q_THREE not in chips
assert FOLLOW_UP not in chips
def test_exactly_one_question_gives_exactly_one_chip( def test_exactly_one_question_gives_exactly_one_chip(
admin_client: TestClient, db: Session admin_client: TestClient, db: Session
) -> None: ) -> None:
"""The 1-question boundary of the same contract: exactly one chip, """The 1-chat boundary of the same contract: exactly one chip (the
never padded toward the cap or mixed with the seed.""" chat's opener), never padded toward the cap by the chat's own
follow-ups or mixed with the seed."""
_add_chat( _add_chat(
db, db,
title="a", title="a",
messages=[_user(Q_TWO), _brain("…")], messages=[
_user(Q_TWO), _brain("…"),
_user(Q_ONE), _brain("…"), # follow-up — never a chip
],
updated_at=datetime.now(UTC), updated_at=datetime.now(UTC),
) )
assert _chips(admin_client) == [Q_TWO] chips = _chips(admin_client)
assert chips == [Q_TWO]
assert Q_ONE not in chips
# ---------- brain-only / blank user texts ---------- # ---------- brain-only / blank user texts ----------
@@ -270,16 +365,15 @@ def test_brain_and_blank_user_texts_contribute_nothing(
admin_client: TestClient, db: Session admin_client: TestClient, db: Session
) -> None: ) -> None:
"""A chat whose messages are all ``who == "brain"`` (plus a blank """A chat whose messages are all ``who == "brain"`` (plus a blank
user text) contributes NOTHING: the chips hold exactly the one user text) contributes NOTHING: the chips hold exactly the opener
real question from the other chat — no brain text, no blank, no of the other chat — no brain text, no blank, no seed top-up."""
seed top-up."""
base = datetime.now(UTC) base = datetime.now(UTC)
_add_chat( _add_chat(
db, db,
title="brain only + blank user", title="brain only + blank user",
messages=[ messages=[
_brain("just brain talking"), _brain("just brain talking"),
_user(" "), # blank user text — skipped _user(" "), # blank user text — no non-blank user message
_brain("more brain"), _brain("more brain"),
], ],
updated_at=base, updated_at=base,
@@ -293,9 +387,31 @@ def test_brain_and_blank_user_texts_contribute_nothing(
assert _chips(admin_client) == [Q_FOUR] assert _chips(admin_client) == [Q_FOUR]
def test_leading_blank_user_entry_does_not_disqualify(
admin_client: TestClient, db: Session
) -> None:
"""A3: a LEADING blank user entry (the UI cannot produce one —
``handleSend`` trims and guards empty text) does NOT disqualify
the chat: the first NON-BLANK user message is the opener."""
_add_chat(
db,
title="leading blank",
messages=[
_user(" "), # leading blank user entry — skipped
_user(Q_TWO), # the first NON-BLANK user message = the opener
_brain("…"),
_user(FOLLOW_UP), # a follow-up — never a chip
],
updated_at=datetime.now(UTC),
)
chips = _chips(admin_client)
assert chips == [Q_TWO]
assert FOLLOW_UP not in chips
def test_all_brain_deployment_returns_seed(admin_client: TestClient, db: Session) -> None: def test_all_brain_deployment_returns_seed(admin_client: TestClient, db: Session) -> None:
"""A deployment with ONLY brain/blank conversations (zero saved """A deployment with ONLY brain/blank conversations (zero saved
questions) → the full seed list.""" openers) → the full seed list."""
_add_chat( _add_chat(
db, db,
title="all brain", title="all brain",