"""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 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 last_questions(db: Session, limit: int = 3) -> list[str]: """The ``limit`` most recent user questions, across all saved chats. 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 in REVERSE (conversational order is oldest→newest), collecting the whitespace-trimmed ``text`` of every entry with ``who == "user"``. Blank texts are skipped. 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). Pure-DB helper (unit-testable without the endpoint); returns ``[]`` when no saved question 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() ) ): for m in reversed(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 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 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. """ qs = last_questions(db) return SuggestionList(suggestions=qs if qs else get_settings().suggestions)