phase: 103_suggestions_session_openers
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:
+58
-29
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user