chore(agent): phase roadmap from TODO.md — 4 phases (77–80)
Protocol B append: navbar refresh + History refresh button (77, TODO L3), static background — glow layers removed (78, TODO L4), admin-issued API tokens with the in-app gate + browser caching, only shared chats stay anonymous (79, TODO L5), onboarding chips as the last 3 questions asked with the env seed only before the first (80, TODO L6). TODO.md cleared — its items now live in .agents/phases/todo/. Owner-confirmed assumptions recorded in each phase overview (A1–A7, chat 2026-09-06).
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
# Phase 80 — Onboarding chips: the last 3 questions asked (the env var seeds a fresh deployment)
|
||||
|
||||
**Source:** `TODO.md` L6 — "The chat suggestions (suggestion chips) should be the last 3 questions asked rather than supplied by env vars. The env var should be used to offer questions before any have been asked as a 'seed' for a new deployment."
|
||||
**Story:** n/a (TODO-derived — extends `.agents/user_stories/suggestion-chips.md` (phase 05))
|
||||
**Context:** `app/api/suggestions.py` (`GET /api/suggestions` → `settings.suggestions` — a static list, 15 lines today), `app/config.py` (`suggestions: list[str]` — the 4 built-in defaults + the `BOR_SUGGESTIONS` env override; `.env.example:55`; `README.md:782` env-table row), `app/models.py` (`SavedChat.messages` — the JSONB `bor.chat.v1` record: a list of `{who, text, sources?, …}` entries, conversational order oldest→newest; `updated_at` stamped on save), `app/rag/suggestions.py` (the deflection "Maybe try" chips — `derive_suggestions`, a SEPARATE contract, untouched by this phase), `frontend/assets/app.js` (`loadSuggestions()` at boot → `renderChips` into `#suggestions` (role="list") inside `#empty-state` (~line 159 of `index.html`); `startNewChat()` — the empty state comes back with STALE chips), `tests/e2e/test_suggestion_chips.py` (the phase-05 suite — pins the settings-defaults contract; REWRITTEN in task 04 per the phase-76 precedent), `tests/integration/test_api.py` (current `/api/suggestions` integration pins).
|
||||
|
||||
## Objective
|
||||
The onboarding chips reflect the deployment's recent activity: the 3 most recent questions actually asked (across saved chats), newest first. A brand-new deployment — zero saved questions — gets the seed list instead (`BOR_SUGGESTIONS`, or the built-in default while unset). The chips also refresh when the empty state comes back (New chat), so the row is never stale. The LLM "Maybe try" deflection chips are untouched.
|
||||
|
||||
## Owner decisions (chat, 2026-09-06 — recorded per AGENTS.md rule 3)
|
||||
- **A6 confirmed:** "the last 3 questions asked" = the 3 most recent USER questions across ALL `saved_chats` — chats walked newest-`updated_at` first, each chat's `messages` walked newest-first, collecting entries with `who == "user"` and a non-blank `text` — de-duplicated (exact, case-sensitive match; a verbatim re-ask counts once), cap 3 applied AFTER dedup. 1–2 saved questions → exactly those show (NO mixing with the seed). ZERO saved questions → the full seed list.
|
||||
- **A7 confirmed:** only the onboarding row (`#suggestions` in the empty state) changes. The deflection "Maybe try" chips (title-derived via `app.rag/suggestions.derive_suggestions`, carried in the chat response) keep their contract.
|
||||
- **Scope is deployment-wide:** saved chats have no per-user attribution and are created only by the admin (phase 79 keeps the save surface admin-only) — the chips reflect the admin's recent questions; there is no per-user question history to scope by (and no new attribution is added in this phase).
|
||||
|
||||
## Design (shared by all tasks)
|
||||
- **Extraction — `app/api/suggestions.py`:** the endpoint gains the `db` dependency and a module-level `last_questions(db, limit: int = 3) -> list[str]`: `SELECT … FROM saved_chats ORDER BY updated_at DESC, created_at DESC` (the tiebreak keeps the order deterministic when timestamps collide); for each chat, walk `chat.messages` (the JSONB column deserializes to a Python list of dicts — NO SQL JSON ops needed; the message counts are the `bor.chat.v1` conversation sizes) in REVERSE (newest first), collecting `m["text"].strip()` for entries where `m.get("who") == "user"`, skipping blanks, stopping once `limit` UNIQUE texts are collected (exact case-sensitive dedup — the docstring notes the choice: case-insensitive dedup would drop a legitimate differently-cased re-ask). Result order = encounter order (newest first).
|
||||
- **The endpoint:** `qs = last_questions(db)` → `SuggestionList(suggestions=qs if qs else get_settings().suggestions)` — the seed (`BOR_SUGGESTIONS` override or the built-in default) appears ONLY when the walk yields zero questions. The endpoint is `require_user`-gated by phase 79 (admin OR token user) — no auth change here; the tests sign in first.
|
||||
- **The deflection path is untouched:** `app/rag/suggestions.py` (`derive_suggestions` — used by the chat turn for the "Maybe try" row) does not call this endpoint and is not modified; its tests stay green.
|
||||
- **Frontend — the refetch (task 03):** `startNewChat()` (the `bor:new-chat` handler in `app.js`) gains a `loadSuggestions()` call when it restores the empty state — the existing function (fetch `/api/suggestions` → `renderChips` into `#suggestions`, replacing the previous chips in place; progressive enhancement, swallows its own failures). The boot fetch stays. No other frontend change: the chips' one-tap submit, the role="list" semantics, and the "Maybe try" row are all existing contracts.
|
||||
- **Docs (task 02):** `BOR_SUGGESTIONS` is documented as the SEED — shown only before any question has ever been saved — in the `config.py` docstring, `.env.example:55`, and the README (the env-table row + the chat feature description).
|
||||
|
||||
## Dependencies
|
||||
- `79_api_tokens` (todo) — `/api/suggestions` is `require_user`-gated there; this phase's tests sign in first and build on that contract (a token user ALSO sees the deployment-wide chips — consistent with A6's deployment-wide scope).
|
||||
|
||||
## Tasks
|
||||
1. `01_last_questions_endpoint.md` — the `last_questions` extraction + the seed fallback + the integration state matrix.
|
||||
2. `02_seed_semantics_docs.md` — the config / `.env.example` / README doc updates.
|
||||
3. `03_chips_new_chat_refetch.md` — the app.js refetch on New chat + the source pin.
|
||||
4. `04_e2e_suite_commit.md` — the story-suite rewrite to the new semantics + the full gate + the atomic commit.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: the full state matrix for `/api/suggestions` (empty DB → the built-in seed; `BOR_SUGGESTIONS` override → the override while empty; 4 questions in one chat → the 3 newest; two chats → the `updated_at` order respected; dedup; exactly 2 questions → 2 chips, no top-up; brain-only messages never picked).
|
||||
- E2E: the REWRITTEN `tests/e2e/test_suggestion_chips.py` run in isolation (the phase-76 precedent — a semantic change rewrites the story suite in place).
|
||||
- Coverage: **>90%** on `app/` (the delta is the ~30-line helper + endpoint in `app/api/suggestions.py` — every branch covered).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov` green in isolation.
|
||||
- [ ] The deflection chips are UNCHANGED (`derive_suggestions` + its suites green).
|
||||
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] One atomic `--no-gpg-sign` Conventional-Commits commit (e.g. `feat(chat): onboarding chips are the last 3 questions asked; the env seed only before the first`); phase dir moved to `.agents/phases/complete/`.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Task 01 — `/api/suggestions` returns the last 3 questions asked (the seed before the first)
|
||||
|
||||
**Phase:** `80_history_suggestion_chips` · **Source:** `TODO.md:6` — "The chat suggestions (suggestion chips) should be the last 3 questions asked rather than supplied by env vars. The env var should be used to offer questions before any have been asked as a 'seed' for a new deployment."
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
The onboarding chips become the 3 most recent user questions across saved chats (newest first, de-duplicated, cap 3); a fresh deployment with zero saved questions gets the seed list (`BOR_SUGGESTIONS` / the built-in default).
|
||||
|
||||
## Work
|
||||
1. `app/api/suggestions.py` — the endpoint gains `db: Session = Depends(get_db)` (import from `app.db` — the house pattern) and a module-level helper:
|
||||
```python
|
||||
def last_questions(db: Session, limit: int = 3) -> list[str]:
|
||||
```
|
||||
- query `SavedChat` ordered by `updated_at.desc(), created_at.desc()` (the tiebreak — deterministic when timestamps collide; `from app.models import SavedChat`);
|
||||
- for each chat, walk `chat.messages` in REVERSE (conversational order is oldest→newest — the `bor.chat.v1` shape) collecting `str(m.get("text", "")).strip()` for entries where `m.get("who") == "user"`; skip blanks;
|
||||
- de-dup EXACT (case-sensitive) against the collected window; stop once `limit` unique texts are collected; return in encounter order (newest first).
|
||||
- docstring: the JSONB column deserializes to a Python list of dicts (no SQL JSON ops — the `SavedChat.messages` model docstring says the record shape is the `bor.chat.v1` list); the exact-dedup choice is documented (case-insensitive would drop a legitimately differently-cased re-ask); the helper is pure-DB (unit-testable without the endpoint).
|
||||
- endpoint body: `qs = last_questions(db)` → `return SuggestionList(suggestions=qs if qs else get_settings().suggestions)` — update the module/endpoint docstring: this endpoint owns the "last-3-questions-or-seed" contract; the deflection "Maybe try" chips (`app.rag.suggestions.derive_suggestions`) are a separate contract, untouched.
|
||||
- note: phase 79 gates this endpoint with `require_user` — if phase 79 has NOT landed yet when this task runs (it is queued before this phase, so it has), the endpoint already carries the dependency; do not remove it.
|
||||
2. `tests/integration/test_suggestions_api.py` (new — the house integration pattern, admin sign-in first — the endpoint is authed):
|
||||
- **empty DB** (no saved chats) → exactly the built-in default list (assert equality with `get_settings().suggestions` — and `tests/unit/test_config.py` keeps pinning that default);
|
||||
- **seed override:** the `BOR_SUGGESTIONS` JSON parsing is ALREADY pinned at unit level by `tests/unit/test_config.py::test_suggestions_env_override_is_json_list` (`monkeypatch.setenv("BOR_SUGGESTIONS", json.dumps(…))`) — do NOT hack per-test env into the integration app; the integration contract is the env-agnostic one below it (empty DB → exactly `get_settings().suggestions`, whatever the environment makes that);
|
||||
- **cap + order:** one saved chat with 4 user questions q1..q4 (plus brain replies between them) → chips == `[q4, q3, q2]` (newest first, cap 3);
|
||||
- **chat order:** two saved chats with DISTINCT `updated_at` (set the timestamps explicitly on the rows) → the newer chat's questions are walked first — a question from the newer chat outranks a newer-LOOKING question from the older chat;
|
||||
- **dedup:** the same question text asked in two chats → appears exactly once;
|
||||
- **partial:** exactly 2 saved questions deployment-wide → exactly 2 chips (NO seed top-up — the A6 contract);
|
||||
- **brain-only:** a chat whose messages are all `who == "brain"` (or blank user texts) → contributes nothing; an all-brain deployment → the seed.
|
||||
- anonymous → 401 (phase-79 contract — one assertion so the auth state of this endpoint is pinned HERE too).
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: item 2 (every branch of `last_questions` + the fallback + the auth pin).
|
||||
- Coverage: **>90%** on the modified `app/api/suggestions.py` (the helper's every branch: empty, partial, cap, dedup, blank-skip, brain-skip).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `GET /api/suggestions` (authed) returns exactly the designed contract in every state of item 2.
|
||||
- [ ] `app/rag/suggestions.py` (`derive_suggestions`) is UNTOUCHED and its tests green; the deflection "Maybe try" E2E behavior unchanged.
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Task 02 — Document the seed semantics (config, `.env.example`, README)
|
||||
|
||||
**Phase:** `80_history_suggestion_chips` · **Source:** `TODO.md:6`
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
The docs describe `BOR_SUGGESTIONS` as what it now is: the pre-first-question SEED — not "the" onboarding chips.
|
||||
|
||||
## Work
|
||||
1. `app/config.py` — the comment block above `suggestions: list[str]` becomes: "Onboarding-chip SEED (phase 80, TODO.md L6): shown ONLY while no saved chat has ever asked a question — after that, `GET /api/suggestions` serves the last 3 questions asked (deployment-wide, newest first). `BOR_SUGGESTIONS` overrides this seed for a new deployment."
|
||||
2. `.env.example` (line ~55) — the comment becomes: `# BOR_SUGGESTIONS=["How is my Kubernetes cluster set up?"] # JSON seed chips — shown only before any question has been saved (phase 80)`
|
||||
3. `README.md` — the env-table row (`BOR_SUGGESTIONS`, ~line 782): "JSON seed for the onboarding chips — shown only before the first saved question; afterwards the chips are the last 3 questions asked (phase 80)".
|
||||
4. `README.md` — wherever the chat / suggestion-chip feature is described (the feature list row ~line 263 and the deflection note ~line 829 stay accurate — the deflection chips are UNCHANGED; add/adjust ONE line in the chat feature description: the onboarding chips follow the last 3 questions asked, seeding from `BOR_SUGGESTIONS` on a fresh deployment).
|
||||
|
||||
## Testing & Quality
|
||||
- Docs only — `tests/unit/test_config.py` and the stale-copy suites must stay green (no behavior change; the `suggestions` default list itself is UNTOUCHED).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] The three doc sites (config, `.env.example`, README) agree with the implemented contract.
|
||||
- [ ] Full suite green; `uv run ruff check . && uv run pyright` clean.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Task 03 — The chips refresh when the empty state comes back (New chat)
|
||||
|
||||
**Phase:** `80_history_suggestion_chips` · **Source:** `TODO.md:6`
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
The onboarding row is fetched once at boot; after "New chat" (or clearing a restored conversation) the empty state returns with STALE chips — the last-3 state moved on while the user was chatting. Refetch on `bor:new-chat` so the row always reflects the current last-3 state.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/app.js` — in `startNewChat()` (the `bor:new-chat` handler, ~line 1848): after `if (emptyState) emptyState.hidden = false;`, add `loadSuggestions();` — the existing function (fetch `/api/suggestions` → `renderChips` into `#suggestions`, which clears the previous chips in place; progressive enhancement — swallows its own failures, no error spam). The in-flight-turn guard at the top of `startNewChat` means the refetch only runs for a real new chat.
|
||||
- The boot path is UNCHANGED: `loadSuggestions()` still runs at shell boot (first paint of the empty state). The `/?chat=<id>` boot opens a saved chat (empty state hidden) — when the user then clicks New chat, this refetch covers it. No other frontend change: the chips' one-tap submit, `role="list"` semantics, and the "Maybe try" deflection row are existing contracts.
|
||||
2. Source pin (house pattern — read the JS source, no browser): in the app.js source-pin file that covers the new-chat flow (find where `bor:new-chat` / `startNewChat` is pinned today — `tests/unit/test_chat_persistence.py` or a `test_frontend_*.py` sibling; if none exists, add the pin to the most app.js-adjacent frontend source-pin file): `startNewChat` calls `loadSuggestions()` (the two literals, `startNewChat`'s function body containing the `loadSuggestions()` call — a containment assertion on the function's source slice, the house style).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: item 2 (the source pin).
|
||||
- E2E: covered by the task-04 suite (the new-chat refetch assertion).
|
||||
- Coverage: n/a (frontend) — the `app/` floor preserved.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] After New chat, `#suggestions` reflects a FRESH `/api/suggestions` response (the Playwright request log shows a second GET after the boot fetch).
|
||||
- [ ] The source pin is green; `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Task 04 — Rewrite the story suite to the new semantics + sweep + the commit
|
||||
|
||||
**Phase:** `80_history_suggestion_chips` · **Source:** `TODO.md:6`
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
`tests/e2e/test_suggestion_chips.py` pins the NEW contract (the phase-76 precedent — a semantic change rewrites the story suite in place), the full pipeline is green, and the phase closes.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_suggestion_chips.py` — REWRITE (keep the file name — one suite per story, run in isolation; the endpoint is authed, so sign in as admin first via `auth_helpers.login`):
|
||||
- **seed state:** fresh DB (no saved chats) → the chip texts equal the built-in default list — assert against a test-local constant copied from the `Settings.suggestions` default in `app/config.py` (the unit suite `test_config.py` pins only the SHAPE — ≥3 non-blank distinct strings — so this E2E literal is the pin for the exact seed list; keep it in sync with the config default); the chips render in `#suggestions` (role="list", the chip buttons) exactly as today.
|
||||
- **last-3 state:** as admin, save two chats via `POST /api/chats` with known distinct questions (5 user questions total; set `updated_at` apart so the order is deterministic — the API stamps it; save the older one first) → a fresh page load → the chips are EXACTLY the 3 newest questions, in newest-first order.
|
||||
- **partial state:** a DB with exactly 2 saved questions → exactly 2 chips (no seed top-up — the A6 contract, visible in the UI).
|
||||
- **new-chat refetch:** load the page with the seed chips visible → via the API save a chat whose newest question is Q → click New chat (`#new-chat-btn`) → the chips now include Q (the refetch happened — the request log shows the second `GET /api/suggestions`).
|
||||
- **carry over the story behavior** (unchanged semantics, same assertions as the old suite where they still hold): one-tap submit (chip click → composer filled → submitted — the mock-LLM brain bubble), Tab+Enter keyboard reachability of the chips (the original suite's keyboard-walk assertion).
|
||||
- Run in isolation until green: `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov`.
|
||||
2. Full gate: `uv run pytest --cov=app --cov-report=term-missing` (>90%), `uv run ruff check . && uv run pyright`; the deflection-related suites green UNCHANGED (their chips are the LLM/title path — find them: `test_honest_deflection.py` and any "Maybe try" assertions — they must not have been touched by this phase).
|
||||
3. ONE atomic `--no-gpg-sign` Conventional-Commits commit — e.g. `feat(chat): onboarding chips are the last 3 questions asked; the env seed only before the first` — body cites TODO.md L6 + the A6/A7 decisions (deployment-wide, exact dedup, no mixing, deflection untouched). Move the phase dir to `.agents/phases/complete/`.
|
||||
|
||||
## Testing & Quality
|
||||
- The full suite IS the test; coverage **>90%** on `app/`.
|
||||
- E2E: the rewritten story suite in isolation (AGENTS.md rule 9).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov` green in isolation — all four states (seed / last-3 / partial / refetch) + the carried-over story behavior.
|
||||
- [ ] Full suite green, coverage >90%, `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] Committed; phase dir in `.agents/phases/complete/`.
|
||||
Reference in New Issue
Block a user