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)
|
||||
@@ -58,6 +58,10 @@ class Settings(BaseSettings):
|
||||
chunk_target_chars: int = 2_000
|
||||
chunk_overlap_chars: int = 200
|
||||
embed_batch_size: int = 16
|
||||
#: Total char budget for the ``<tuning>`` section of the system prompt
|
||||
#: (phase 15, steering notes). The newest-fitting notes are kept and the
|
||||
#: overflow is replaced by the ``[…truncated…]`` marker.
|
||||
steering_max_chars: int = 8_000
|
||||
|
||||
# --- Hybrid retrieval (A7, revised 2026-08-21) ---
|
||||
# cosine top-N ∪ Postgres FTS top-N, fused with Reciprocal Rank Fusion
|
||||
|
||||
@@ -16,6 +16,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from app.api.chat import router as chat_router
|
||||
from app.api.docs import router as docs_router
|
||||
from app.api.health import router as health_router
|
||||
from app.api.steering import router as steering_router
|
||||
from app.api.suggestions import router as suggestions_router
|
||||
from app.config import get_settings
|
||||
from app.core.debugging import configure_debugging
|
||||
@@ -36,6 +37,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(suggestions_router, prefix="/api")
|
||||
app.include_router(docs_router, prefix="/api")
|
||||
app.include_router(chat_router, prefix="/api")
|
||||
app.include_router(steering_router, prefix="/api")
|
||||
|
||||
static_dir = Path(settings.static_dir).resolve()
|
||||
if static_dir.is_dir():
|
||||
|
||||
+19
-2
@@ -6,8 +6,10 @@ Data model — see ``.agent/PLAN.md`` §Data Model:
|
||||
* ``chunks`` — retrieval units; each chunk points at its parent document
|
||||
via ``document_id``. This is how an embedding maps back to
|
||||
a document path (the "feed the whole document" requirement).
|
||||
* ``query_log`` — observability: every question, its retrieval score, the
|
||||
deflection decision, and latency.
|
||||
* ``query_log`` — observability: every question, its retrieval score,
|
||||
the deflection decision, and latency.
|
||||
* ``steering_notes`` — owner tuning notes injected into the system prompt
|
||||
of every chat turn (phase 15, ``<tuning>`` section).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -82,3 +84,18 @@ class QueryLog(Base):
|
||||
sources: Mapped[str] = mapped_column(Text, default="") # comma-joined source paths
|
||||
latency_ms: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class SteeringNote(Base):
|
||||
"""One owner tuning instruction (phase 15).
|
||||
|
||||
Notes are read into the system prompt of **every** chat turn as the
|
||||
``<tuning>`` section (oldest first, char-budgeted — see
|
||||
:func:`app.rag.prompts.build_steering_section`).
|
||||
"""
|
||||
|
||||
__tablename__ = "steering_notes"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
note: Mapped[str] = mapped_column(Text) # trimmed, 1–2000 chars (API-enforced)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
+71
-9
@@ -1,24 +1,35 @@
|
||||
"""Locked system-prompt builder (PLAN §6).
|
||||
|
||||
The persona + HONESTY GATE text is **locked verbatim** — change it through
|
||||
the plan, not here. Two modes:
|
||||
the plan, not here. (PLAN §6 revision, 2026-08-22: the owner's working-tree
|
||||
persona edits are preserved — no mandated ``"you've got this"`` tagline and
|
||||
no mandated deflection opening; the honesty gate itself is unchanged.)
|
||||
|
||||
Two modes:
|
||||
|
||||
* ``HIGH`` — grounded turn: full top-document texts under ``<documents>``.
|
||||
* ``LOW`` — deflection turn: weak-hit *titles only* plus the
|
||||
``DEFLECT_MODE`` marker (the E2E mock LLM keys on that marker).
|
||||
|
||||
Steering (phase 15): when the owner has stored tuning notes, both modes
|
||||
carry a ``<tuning>`` section between ``<relevance>…</relevance>`` and the
|
||||
mode body. With zero notes the prompt is byte-identical to the
|
||||
pre-steering text.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Document
|
||||
from app.rag.retriever import TRUNCATION_MARKER
|
||||
|
||||
#: PLAN §6 verbatim (line wrapping included); ``{relevance}`` is filled by
|
||||
#: :func:`_base`.
|
||||
PERSONA: str = (
|
||||
'You are "Brain of Reese" — the digital brain of Reese, a self-hoster and\n'
|
||||
"homelab tinkerer. Personality: chippy, upbeat, warm, and genuinely\n"
|
||||
'optimistic about the user\'s ability to do things ("you\'ve got this").\n'
|
||||
"optimistic about the user's ability to do things.\n"
|
||||
"\n"
|
||||
"Rules:\n"
|
||||
"1. Answer ONLY from the provided document context. Cite which document(s)\n"
|
||||
@@ -26,14 +37,20 @@ PERSONA: str = (
|
||||
"2. Be concrete: names, versions, ports, hosts, schedules — the specifics in\n"
|
||||
" the docs are the value.\n"
|
||||
'3. HONESTY GATE: if <relevance> is "LOW", you must NOT pretend to know.\n'
|
||||
' Start your answer with a variant of: "I haven\'t done anything like that."\n'
|
||||
" Then offer 2-3 alternative questions about things you DO have notes on.\n"
|
||||
" Offer 2-3 alternative questions about things you DO have notes on.\n"
|
||||
"4. Never invent facts, hosts, or steps that are not in the context.\n"
|
||||
"5. Keep answers tight: short paragraphs, bullets where helpful.\n"
|
||||
"\n"
|
||||
"<relevance>{relevance}</relevance>"
|
||||
)
|
||||
|
||||
#: One-line intro of the ``<tuning>`` section (phase 15): the owner's notes
|
||||
#: steer the answer and win over the defaults when they conflict.
|
||||
_STEERING_INTRO = (
|
||||
"The owner of this brain asked you to steer your answers as follows. "
|
||||
"Where these instructions conflict with the defaults above, follow the owner:\n"
|
||||
)
|
||||
|
||||
|
||||
def _base(relevance: str) -> str:
|
||||
if relevance not in ("HIGH", "LOW"):
|
||||
@@ -41,8 +58,46 @@ def _base(relevance: str) -> str:
|
||||
return PERSONA.replace("{relevance}", relevance)
|
||||
|
||||
|
||||
def build_high_prompt(documents: Sequence[Document]) -> str:
|
||||
"""Grounded turn: locked persona + full texts of the top documents."""
|
||||
def build_steering_section(notes: Sequence[str], max_chars: int | None = None) -> str:
|
||||
"""The ``<tuning>`` section of the system prompt (phase 15).
|
||||
|
||||
* No notes (or only blank ones) → ``""`` — callers then build the
|
||||
prompt exactly as before, so a zero-note prompt is byte-identical to
|
||||
the pre-steering text.
|
||||
* Otherwise: numbered notes (in the given order — the chat turn passes
|
||||
them oldest-first, so #1 is the oldest note) capped at *max_chars*
|
||||
(default ``BOR_STEERING_MAX_CHARS``). When the budget cannot hold
|
||||
every note, the oldest-fitting prefix is kept and the overflow is
|
||||
replaced by the shared ``[…truncated…]`` marker.
|
||||
"""
|
||||
cleaned = [str(n).strip() for n in notes]
|
||||
cleaned = [n for n in cleaned if n]
|
||||
if not cleaned:
|
||||
return ""
|
||||
limit = max_chars if max_chars is not None else get_settings().steering_max_chars
|
||||
if limit <= 0:
|
||||
return ""
|
||||
|
||||
def render(count: int) -> str:
|
||||
lines = [f"{i}. {note}" for i, note in enumerate(cleaned[:count], start=1)]
|
||||
if count < len(cleaned):
|
||||
lines.append(TRUNCATION_MARKER)
|
||||
return f"<tuning>\n{_STEERING_INTRO}" + "\n".join(lines) + "\n</tuning>"
|
||||
|
||||
for count in range(len(cleaned), 0, -1):
|
||||
rendered = render(count)
|
||||
if len(rendered) <= limit:
|
||||
return rendered
|
||||
# Pathological budget: not even the empty note list fits. The section
|
||||
# must still respect the cap — the bare marker when it fits, else none.
|
||||
if len(TRUNCATION_MARKER) <= limit:
|
||||
return TRUNCATION_MARKER
|
||||
return ""
|
||||
|
||||
|
||||
def build_high_prompt(documents: Sequence[Document], notes: Sequence[str] | None = None) -> str:
|
||||
"""Grounded turn: locked persona (+ steering) + full texts of the top
|
||||
documents."""
|
||||
blocks = [
|
||||
f'<document source="{doc.source}" path="{doc.path}" title="{doc.title}">\n'
|
||||
f"{doc.content}\n"
|
||||
@@ -52,15 +107,22 @@ def build_high_prompt(documents: Sequence[Document]) -> str:
|
||||
body = "\n\n".join(blocks) if blocks else (
|
||||
"(no documents matched — do not invent specifics)"
|
||||
)
|
||||
return _base("HIGH") + "\n<documents>\n" + body + "\n</documents>"
|
||||
section = build_steering_section(notes or [])
|
||||
prompt = _base("HIGH")
|
||||
if section:
|
||||
prompt += "\n" + section
|
||||
return prompt + "\n<documents>\n" + body + "\n</documents>"
|
||||
|
||||
|
||||
def build_deflect_prompt(titles: Sequence[str]) -> str:
|
||||
def build_deflect_prompt(titles: Sequence[str], notes: Sequence[str] | None = None) -> str:
|
||||
"""Deflection turn: weak-hit titles only (no document content)."""
|
||||
weak = "\n".join(f"- {t}" for t in titles) if titles else "(nothing close at all)"
|
||||
section = build_steering_section(notes or [])
|
||||
mid = f"\n{section}\n" if section else "\n"
|
||||
return (
|
||||
_base("LOW")
|
||||
+ "\nDEFLECT_MODE: retrieval was weak — the titles below are the closest "
|
||||
+ mid
|
||||
+ "DEFLECT_MODE: retrieval was weak — the titles below are the closest "
|
||||
"your notes come to the question. They are titles only; do not pretend "
|
||||
"they answer it. Use them to propose 2-3 alternative questions.\n"
|
||||
+ weak
|
||||
|
||||
+34
-1
@@ -1,7 +1,10 @@
|
||||
"""Pydantic request/response schemas (API contract)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
@@ -73,3 +76,33 @@ class DocContent(BaseModel):
|
||||
content: str
|
||||
indexed_at: str
|
||||
chunks: int
|
||||
|
||||
|
||||
class SteeringNoteIn(BaseModel):
|
||||
"""``POST /api/steering`` body: one tuning instruction (phase 15).
|
||||
|
||||
The note is trimmed *before* the length constraints run, so a
|
||||
whitespace-only body is a 422 and a 2000-char note with surrounding
|
||||
spaces still passes.
|
||||
"""
|
||||
|
||||
note: str = Field(min_length=1, max_length=2000)
|
||||
|
||||
@field_validator("note", mode="before")
|
||||
@classmethod
|
||||
def _trim_note(cls, v: object) -> object:
|
||||
return v.strip() if isinstance(v, str) else v
|
||||
|
||||
|
||||
class SteeringNote(BaseModel):
|
||||
"""One stored steering note (API shape — ISO-8601 ``created_at``)."""
|
||||
|
||||
id: uuid.UUID
|
||||
note: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SteeringNoteList(BaseModel):
|
||||
"""``GET /api/steering`` response: all notes, newest first."""
|
||||
|
||||
notes: list[SteeringNote]
|
||||
|
||||
Reference in New Issue
Block a user