Single consolidated commit for four completed, validated phases (77, 78, 79, 80). The pipeline run left all work uncommitted because the harness commits only with PHASE_COMMIT=1 while child executors are forbidden from committing; the phases themselves all passed validation and moved to .agents/phases/complete/. Phase 77 — navbar view refresh - router.js dispatches bor:view-refresh on re-show / active re-click / popstate (gated on wasMounted; first show and boot exempt) - History / RAG / Sources / Tuning re-fetch on refresh (admin branch); Chat deliberately excluded (stream survival) - History "Refresh" button (admin-only, in-flight disable + status line) - New story suite tests/e2e/test_navbar_refresh.py (7 tests) Phase 78 — static background - Removed the animated glow layers; static 44px grid over the flat --bg canvas; default and reduced-motion renders byte-identical - Updated background/theme E2E suites; removed bg-glow test pins Phase 79 — API tokens - api_tokens model + migration 0012; hash-only token service - Admin tokens API + Tokens admin view; POST /api/token-auth; live-revoking require_user on chat / suggestions / document content - Frontend token gate with localStorage cache; anonymous E2E suites migrated to token login - New story suite tests/e2e/test_api_tokens.py (9 tests) Phase 80 — history suggestion chips - last_questions() endpoint with SEED fallback; startNewChat() refetch - Seed-semantics docs (config.py, .env.example, README) - Integration state matrix + E2E suite rewritten to the 4 chip states Also included: phase-76 report artifacts and the repo restore-test-db skill (previously untracked), scripts/* ruff fixes from phase 77. Final gate state (phase 80 final pass, covers everything above): - uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99% - uv run ruff check . && uv run pyright → clean, 0 errors - Per-phase story E2E suites green in isolation
89 lines
3.6 KiB
Python
89 lines
3.6 KiB
Python
"""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)
|