feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s

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
This commit is contained in:
2026-09-07 12:39:01 -04:00
parent 495d042a98
commit 7fce6572d0
215 changed files with 10142 additions and 1643 deletions
+78 -4
View File
@@ -1,14 +1,88 @@
"""Suggested-question endpoint (drives the onboarding chips in the UI)."""
"""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
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() -> SuggestionList:
return SuggestionList(suggestions=get_settings().suggestions)
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)