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/`).
118 lines
5.1 KiB
Python
118 lines
5.1 KiB
Python
"""Suggested-question endpoint (drives the onboarding chips in the UI).
|
|
|
|
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
|
|
content is the shared chats (the phase-16 "everything but the admin
|
|
surface is open" stance is superseded).
|
|
|
|
The deflection "Maybe try" chips (``app.rag.suggestions.
|
|
derive_suggestions``) are a SEPARATE contract (title-derived, carried
|
|
in the chat response) and are untouched by this endpoint.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.config import get_settings
|
|
from app.core.auth import require_user
|
|
from app.db import get_db
|
|
from app.models import SavedChat
|
|
from app.schemas import SuggestionList
|
|
|
|
router = APIRouter(tags=["chat"])
|
|
|
|
|
|
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
|
|
plain Python list of ``bor.chat.v1`` dicts — NO SQL JSON ops
|
|
needed, the record shape is the ``SavedChat.messages`` model
|
|
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 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 opener exists (the caller then falls back
|
|
to the seed list).
|
|
"""
|
|
result: list[str] = []
|
|
seen: set[str] = set()
|
|
for chat in db.scalars(
|
|
select(SavedChat).order_by(
|
|
SavedChat.updated_at.desc(), SavedChat.created_at.desc()
|
|
)
|
|
):
|
|
opener: str | None = None
|
|
for m in chat.messages or []:
|
|
if m.get("who") != "user":
|
|
continue
|
|
question = str(m.get("text", "")).strip()
|
|
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
|
|
|
|
|
|
@router.get("/suggestions", response_model=SuggestionList)
|
|
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 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 = opening_questions(db)
|
|
return SuggestionList(suggestions=qs if qs else get_settings().suggestions)
|