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
109 lines
4.0 KiB
Python
109 lines
4.0 KiB
Python
"""Steering notes API — tune how Brain answers (phase 15, story
|
||
``steering-notes``).
|
||
|
||
Admin-only CRUD under ``/api/steering`` (phase 16, A10 revised): notes
|
||
are owner instructions stored in Postgres (``steering_notes``) and read
|
||
into the system prompt of **every** chat turn as the ``<tuning>`` section
|
||
(see :func:`app.rag.prompts.build_steering_section` and
|
||
:func:`app.api.chat.chat`). Routes: ``GET`` (list, newest first),
|
||
``POST`` (create), ``PUT /{note_id}`` (update — phase 27; full
|
||
replacement of ``note``, ``created_at`` preserved), ``DELETE /{note_id}``.
|
||
The whole router sits behind :func:`app.core.auth.require_admin` —
|
||
anonymous callers get 403 on every steering route (the chat turn itself
|
||
reads the table in-process; since phase 79 that turn is user-gated
|
||
through ``require_user``); the PUT route adds no auth surface of its
|
||
own, it reuses that router-level dependency.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||
from sqlalchemy import select
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.auth import require_admin
|
||
from app.db import get_db
|
||
from app.models import SteeringNote
|
||
from app.schemas import SteeringNote as SteeringNoteOut
|
||
from app.schemas import SteeringNoteIn, SteeringNoteList, SteeringNoteUpdate
|
||
|
||
router = APIRouter(
|
||
prefix="/steering",
|
||
tags=["steering"],
|
||
dependencies=[Depends(require_admin)], # phase 16: tuning is admin-only
|
||
)
|
||
|
||
|
||
def load_steering_notes(db: Session) -> list[str]:
|
||
"""All steering notes, oldest first (the order they are numbered in the
|
||
``<tuning>`` prompt section). Used by the chat turn (``app.api.chat``)."""
|
||
rows = db.scalars(
|
||
select(SteeringNote).order_by(SteeringNote.created_at.asc(), SteeringNote.id.asc())
|
||
).all()
|
||
return [row.note for row in rows]
|
||
|
||
|
||
@router.get("", response_model=SteeringNoteList)
|
||
def list_steering_notes(
|
||
db: Session = Depends(get_db), # noqa: B008
|
||
) -> SteeringNoteList:
|
||
"""All notes, newest first (the UI panel's display order)."""
|
||
rows = db.scalars(
|
||
select(SteeringNote).order_by(SteeringNote.created_at.desc(), SteeringNote.id.desc())
|
||
).all()
|
||
return SteeringNoteList(
|
||
notes=[
|
||
SteeringNoteOut(id=row.id, note=row.note, created_at=row.created_at) for row in rows
|
||
]
|
||
)
|
||
|
||
|
||
@router.post("", response_model=SteeringNoteOut, status_code=201)
|
||
def create_steering_note(
|
||
payload: SteeringNoteIn,
|
||
db: Session = Depends(get_db), # noqa: B008
|
||
) -> SteeringNoteOut:
|
||
"""Store one tuning instruction (trimmed, 1–2000 chars — 422 otherwise)."""
|
||
row = SteeringNote(note=payload.note)
|
||
db.add(row)
|
||
db.commit()
|
||
db.refresh(row)
|
||
return SteeringNoteOut(id=row.id, note=row.note, created_at=row.created_at)
|
||
|
||
|
||
@router.put("/{note_id}", response_model=SteeringNoteOut)
|
||
def update_steering_note(
|
||
note_id: uuid.UUID,
|
||
payload: SteeringNoteUpdate,
|
||
db: Session = Depends(get_db), # noqa: B008
|
||
) -> SteeringNoteOut:
|
||
"""Replace a note's text in place (phase 27); 404 when the id is unknown.
|
||
|
||
``created_at`` is preserved — editing a note does not redate it, so the
|
||
list order (newest first) and the ``<tuning>`` numbering (oldest first)
|
||
stay stable across edits. The router-level ``require_admin`` dependency
|
||
gates this route like every other steering route.
|
||
"""
|
||
row = db.get(SteeringNote, note_id)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="steering note not found")
|
||
row.note = payload.note.strip()
|
||
db.commit()
|
||
db.refresh(row)
|
||
return SteeringNoteOut(id=row.id, note=row.note, created_at=row.created_at)
|
||
|
||
|
||
@router.delete("/{note_id}", status_code=204)
|
||
def delete_steering_note(
|
||
note_id: uuid.UUID,
|
||
db: Session = Depends(get_db), # noqa: B008
|
||
) -> Response:
|
||
"""Remove a note; 404 when the id is unknown."""
|
||
row = db.get(SteeringNote, note_id)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="steering note not found")
|
||
db.delete(row)
|
||
db.commit()
|
||
return Response(status_code=204)
|