Files
brain-of-reese/app/api/suggestions.py
T
ducoterra 1f1c01c9f7
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_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/`).
2026-09-12 16:37:50 -04:00

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)