"""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 ```` 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 and stays public); 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 ```` 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 ```` 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)