"""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)