feat(rag): steering notes — tune how Brain answers, stored in Postgres and injected into every system prompt
This commit is contained in:
+35
-7
@@ -16,6 +16,11 @@ gets a grounded answer. Deflection mode carries weak-hit *titles only*
|
||||
(never document content) plus deterministic "Maybe try" chips, and the
|
||||
``done`` event / ``query_log`` row record ``deflected=true``, the weak
|
||||
score and the ``fts_hits`` count.
|
||||
|
||||
Steering (phase 15): the owner's stored tuning notes are loaded per turn
|
||||
(oldest first) and injected into the system prompt as a ``<tuning>``
|
||||
section — both the HIGH and the LOW prompt carry it. The per-turn log
|
||||
line records ``tuning=N`` (the number of injected notes).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -30,6 +35,7 @@ from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.steering import load_steering_notes
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import db_available, get_db
|
||||
from app.models import Document, QueryLog
|
||||
@@ -71,9 +77,14 @@ class TurnPlan:
|
||||
system_prompt: str
|
||||
docs: list[Document] # cited sources (weak hits when deflected)
|
||||
suggestions: list[str] # "Maybe try" chips (deflected turns only)
|
||||
tuning_count: int = 0 # steering notes injected into the system prompt
|
||||
|
||||
|
||||
def plan_turn(chunks: Sequence[RetrievedChunk], settings: Settings) -> TurnPlan:
|
||||
def plan_turn(
|
||||
chunks: Sequence[RetrievedChunk],
|
||||
settings: Settings,
|
||||
notes: Sequence[str] | None = None,
|
||||
) -> TurnPlan:
|
||||
"""Apply the honesty gate (A8, revised) and assemble prompt + context.
|
||||
|
||||
* **HIGH (grounded)** when ``best_cosine >= threshold`` **or**
|
||||
@@ -88,22 +99,36 @@ def plan_turn(chunks: Sequence[RetrievedChunk], settings: Settings) -> TurnPlan:
|
||||
``top_score`` (stored in ``query_log``) is the best cosine, so the
|
||||
gate input is always a pure vector-similarity number; the lexical
|
||||
signal is recorded separately as ``fts_hits``.
|
||||
|
||||
*notes* are the owner's steering notes (phase 15, oldest first):
|
||||
when non-empty, both the HIGH and the LOW prompt carry the
|
||||
``<tuning>`` section; with no notes the prompts are unchanged.
|
||||
"""
|
||||
steering = list(notes or [])
|
||||
best_cosine = max((c.cosine for c in chunks), default=0.0)
|
||||
fts_hits = sum(1 for c in chunks if c.fts_hit)
|
||||
if best_cosine >= settings.relevance_threshold or fts_hits > 0:
|
||||
docs = select_documents(
|
||||
chunks, n=settings.top_n_docs, max_chars=settings.max_context_chars
|
||||
)
|
||||
return TurnPlan(best_cosine, fts_hits, False, build_high_prompt(docs), docs, [])
|
||||
return TurnPlan(
|
||||
best_cosine,
|
||||
fts_hits,
|
||||
False,
|
||||
build_high_prompt(docs, notes=steering),
|
||||
docs,
|
||||
[],
|
||||
len(steering),
|
||||
)
|
||||
titles = weak_hit_titles(chunks)
|
||||
return TurnPlan(
|
||||
best_cosine,
|
||||
fts_hits,
|
||||
True,
|
||||
build_deflect_prompt(titles),
|
||||
build_deflect_prompt(titles, notes=steering),
|
||||
select_documents(chunks, n=settings.top_n_docs, max_chars=settings.max_context_chars),
|
||||
derive_suggestions(titles, settings.suggestions),
|
||||
len(steering),
|
||||
)
|
||||
|
||||
|
||||
@@ -150,12 +175,14 @@ async def chat(
|
||||
return
|
||||
embed_ms = int((time.monotonic() - t0) * 1000)
|
||||
|
||||
# 2. Retrieve top-K chunks, then the honesty gate (A8) picks the
|
||||
# HIGH (grounded) or LOW (deflected) prompt + context.
|
||||
# 2. Retrieve top-K chunks, load the owner's steering notes
|
||||
# (phase 15), then the honesty gate (A8) picks the HIGH
|
||||
# (grounded) or LOW (deflected) prompt + context.
|
||||
settings = get_settings()
|
||||
try:
|
||||
steering_notes = load_steering_notes(db)
|
||||
chunks = retrieve(db, request.message, question_vec)
|
||||
plan = plan_turn(chunks, settings)
|
||||
plan = plan_turn(chunks, settings, notes=steering_notes)
|
||||
except Exception: # noqa: BLE001 — DB failure mid-turn
|
||||
logger.exception(
|
||||
"chat: retrieval failed question=%r total_ms=%d",
|
||||
@@ -211,12 +238,13 @@ async def chat(
|
||||
logger.exception("chat: failed to write query_log question=%r", request.message)
|
||||
|
||||
logger.info(
|
||||
"question=%r embed_ms=%d top_score=%.3f fts_hits=%d threshold=%.2f "
|
||||
"question=%r embed_ms=%d top_score=%.3f fts_hits=%d tuning=%d threshold=%.2f "
|
||||
"deflected=%s sources=%r total_ms=%d",
|
||||
request.message,
|
||||
embed_ms,
|
||||
plan.top_score,
|
||||
plan.fts_hits,
|
||||
plan.tuning_count,
|
||||
settings.relevance_threshold,
|
||||
plan.deflected,
|
||||
source_paths,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Steering notes API — tune how Brain answers (phase 15, story
|
||||
``steering-notes``).
|
||||
|
||||
Stateless CRUD under ``/api/steering`` (A10): 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`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import get_db
|
||||
from app.models import SteeringNote
|
||||
from app.schemas import SteeringNote as SteeringNoteOut
|
||||
from app.schemas import SteeringNoteIn, SteeringNoteList
|
||||
|
||||
router = APIRouter(prefix="/steering", tags=["steering"])
|
||||
|
||||
|
||||
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.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)
|
||||
Reference in New Issue
Block a user