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
+58 -29
View File
@@ -1,11 +1,20 @@
"""Suggested-question endpoint (drives the onboarding chips in the UI).
Phase 80: the chips are the **last 3 questions asked** — the three
most recent user questions across ALL saved chats (chats walked
newest-``updated_at`` first, each chat's messages walked newest-first,
exact de-duplicated, cap 3 — see :func:`last_questions`). A fresh
deployment — zero saved questions — gets the seed list instead
(``BOR_SUGGESTIONS`` override, or the built-in default).
Phase 103: the chips are the **session openers** — each saved chat
contributes AT MOST ONE chip: the first non-blank user message (the
question that OPENED the session; the chats are walked newest-
``updated_at`` first, exact de-duplicated, cap 3 — see
:func:`opening_questions`). Follow-up questions can NEVER surface:
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
surface (chat, suggestions, cited documents); the ONLY anonymous
@@ -31,26 +40,41 @@ from app.schemas import SuggestionList
router = APIRouter(tags=["chat"])
def last_questions(db: Session, limit: int = 3) -> list[str]:
"""The ``limit`` most recent user questions, across all saved chats.
def opening_questions(db: Session, limit: int = 3) -> list[str]:
"""The OPENING question of each saved chat, newest chat first.
Chats are walked ``updated_at DESC, created_at DESC`` (the
tiebreak keeps the order deterministic when timestamps collide);
each chat's ``messages`` (a JSONB column that deserializes to a
tiebreak keeps the order deterministic when timestamps collide).
Each chat's ``messages`` (a JSONB column that deserializes to a
plain Python list of ``bor.chat.v1`` dicts — NO SQL JSON ops
needed, the record shape is the ``SavedChat.messages`` model
docstring) is walked in REVERSE (conversational order is
oldest→newest), collecting the whitespace-trimmed ``text`` of
every entry with ``who == "user"``. Blank texts are skipped.
docstring) is walked FORWARD (conversational order is
oldest→newest): the FIRST entry with ``who == "user"`` whose
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
window: a verbatim re-ask counts once, while a legitimately
differently-cased re-ask is kept (case-insensitive dedup would
drop it). The walk stops once ``limit`` UNIQUE texts are
collected; the result is in encounter order (newest first).
drop it). The walk stops once ``limit`` UNIQUE openers are
collected (the cap binds ACROSS chats); the result is in
encounter order (newest chat first).
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).
"""
result: list[str] = []
@@ -60,16 +84,20 @@ def last_questions(db: Session, limit: int = 3) -> list[str]:
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":
continue
question = str(m.get("text", "")).strip()
if not question or question in seen:
continue
seen.add(question)
result.append(question)
if len(result) >= limit:
return result
if question:
opener = question
break
if opener is None or opener in seen:
continue
seen.add(opener)
result.append(opener)
if len(result) >= limit:
return result
return result
@@ -78,11 +106,12 @@ def suggestions(
_user: None = Depends(require_user), # noqa: B008 # phase 79: admin or live token
db: Session = Depends(get_db), # noqa: B008
) -> SuggestionList:
"""The onboarding chips (admin OR token user, else 401): the last 3
questions asked across saved chats — or, before any question has
ever been saved, the seed list (``BOR_SUGGESTIONS`` / the
built-in default). The deflection "Maybe try" chips are a separate
contract (``app.rag.suggestions.derive_suggestions``), untouched.
"""The onboarding chips (admin OR token user, else 401): the opening
questions of the 3 most recent saved chats — or, before any
question has ever been saved, the seed list (``BOR_SUGGESTIONS`` /
the built-in default). The deflection "Maybe try" chips are a
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)