feat(rag): honest deflection gate with amber UI state and alternative-question chips

This commit is contained in:
2026-08-21 17:50:33 -04:00
parent 396e4d47fb
commit cbf8e39e63
11 changed files with 779 additions and 34 deletions
+71 -27
View File
@@ -1,33 +1,39 @@
"""POST /api/chat — a RAG chat turn streamed over SSE (PLAN §3/§4).
Flow (LOCKED A7/A15): embed the question → pgvector cosine top-K chunks →
distinct parent documents (full text, capped) → locked persona prompt
(PLAN §6) → ``turbo`` streamed as ``delta`` events → final ``done`` event
(``deflected``, ``sources``, ``suggestions``) + ``query_log`` row + the
per-turn log line (PLAN §9). Mid-stream failures become a structured
``error`` event; a pre-stream DB outage is a plain 503 JSON.
the **honesty gate** (A8: best score < ``BOR_RELEVANCE_THRESHOLD`` ⇒
deflection) → locked persona prompt (PLAN §6) → ``turbo`` streamed as
``delta`` events → final ``done`` event (``deflected``, ``sources``,
``suggestions``) + ``query_log`` row + the per-turn log line (PLAN §9).
Mid-stream failures become a structured ``error`` event; a pre-stream DB
outage is a plain 503 JSON.
The honesty gate (LOW relevance → deflection) lands in phase 04; every
turn in this phase is grounded (``deflected=false``).
Honesty gate: a weak retrieval (score strictly below the threshold — or
an empty KB) flips the turn to deflection mode: the LOW prompt carries
weak-hit *titles only* (never document content) plus deterministic
"Maybe try" chips, and the ``done`` event / ``query_log`` row record
``deflected=true`` with the weak score.
"""
from __future__ import annotations
import json
import logging
import time
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Sequence
from dataclasses import dataclass
from typing import Any
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy.orm import Session
from app.config import get_settings
from app.config import Settings, get_settings
from app.db import db_available, get_db
from app.models import QueryLog
from app.models import Document, QueryLog
from app.rag.llm import EmbeddingError, LLMClient, LLMError
from app.rag.prompts import build_high_prompt
from app.rag.retriever import retrieve, select_documents
from app.rag.prompts import build_deflect_prompt, build_high_prompt
from app.rag.retriever import RetrievedChunk, retrieve, select_documents, weak_hit_titles
from app.rag.suggestions import derive_suggestions
from app.schemas import ChatDoneEvent, ChatRequest, SourceRef
logger = logging.getLogger("app.chat")
@@ -52,6 +58,44 @@ def sse_event(payload: dict[str, Any]) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
@dataclass
class TurnPlan:
"""What one chat turn sends to the LLM and reports on ``done``."""
top_score: float
deflected: bool
system_prompt: str
docs: list[Document] # cited sources (weak hits when deflected)
suggestions: list[str] # "Maybe try" chips (deflected turns only)
def plan_turn(chunks: Sequence[RetrievedChunk], settings: Settings) -> TurnPlan:
"""Apply the honesty gate (A8) and assemble prompt + context for a turn.
* ``top_score >= threshold`` → grounded: HIGH prompt with the full
top-N documents, no suggestions. A score exactly at the threshold
is an answer — the gate is strict (``score < threshold``).
* ``top_score < threshold`` (or no hits at all) → deflected: LOW
prompt (``DEFLECT_MODE``) with weak-hit titles only — never document
content — plus deterministic alternative-question chips derived
from those titles.
"""
top_score = chunks[0].score if chunks else 0.0
if top_score >= settings.relevance_threshold:
docs = select_documents(
chunks, n=settings.top_n_docs, max_chars=settings.max_context_chars
)
return TurnPlan(top_score, False, build_high_prompt(docs), docs, [])
titles = weak_hit_titles(chunks)
return TurnPlan(
top_score,
True,
build_deflect_prompt(titles),
select_documents(chunks, n=settings.top_n_docs, max_chars=settings.max_context_chars),
derive_suggestions(titles, settings.suggestions),
)
@router.post("/chat")
async def chat(
request: ChatRequest,
@@ -88,10 +132,12 @@ async def chat(
return
embed_ms = int((time.monotonic() - t0) * 1000)
# 2. Retrieve top-K chunks → top-N full parent documents.
# 2. Retrieve top-K chunks, then the honesty gate (A8) picks the
# HIGH (grounded) or LOW (deflected) prompt + context.
settings = get_settings()
try:
chunks = retrieve(db, question_vec)
docs = select_documents(chunks)
plan = plan_turn(chunks, settings)
except Exception: # noqa: BLE001 — DB failure mid-turn
logger.exception("chat: retrieval failed question=%r", request.message)
yield sse_event(
@@ -101,15 +147,13 @@ async def chat(
}
)
return
top_score = chunks[0].score if chunks else 0.0
source_paths = [f"{d.source}/{d.path}" for d in docs]
source_paths = [f"{d.source}/{d.path}" for d in plan.docs]
messages = [
{"role": "system", "content": build_high_prompt(docs)},
{"role": "system", "content": plan.system_prompt},
{"role": "user", "content": request.message},
]
# 3. Stream the grounded answer.
# 3. Stream the answer (grounded, or an honest deflection).
try:
async for piece in llm.chat_stream(messages):
yield sse_event({"type": "delta", "text": piece})
@@ -126,9 +170,9 @@ async def chat(
db.add(
QueryLog(
question=request.message,
top_score=top_score,
top_score=plan.top_score,
chunk_hits=len(chunks),
deflected=False,
deflected=plan.deflected,
sources=", ".join(source_paths),
latency_ms=total_ms,
)
@@ -142,19 +186,19 @@ async def chat(
"sources=%r total_ms=%d",
request.message,
embed_ms,
top_score,
get_settings().relevance_threshold,
False,
plan.top_score,
settings.relevance_threshold,
plan.deflected,
source_paths,
total_ms,
)
yield sse_event(
ChatDoneEvent(
deflected=False,
deflected=plan.deflected,
sources=[
SourceRef(source=d.source, path=d.path, title=d.title) for d in docs
SourceRef(source=d.source, path=d.path, title=d.title) for d in plan.docs
],
suggestions=[],
suggestions=plan.suggestions,
).model_dump()
)
+16
View File
@@ -64,6 +64,22 @@ def retrieve(
]
def weak_hit_titles(chunks: Sequence[RetrievedChunk]) -> list[str]:
"""Distinct parent-document titles of *chunks*, best chunk score first.
Deflection mode (PLAN §6, A8) is built from these *titles only* — the
LOW prompt and the "Maybe try" chips never see document content.
"""
titles: list[str] = []
seen: set[uuid.UUID] = set()
for rc in sorted(chunks, key=lambda c: c.score, reverse=True):
if rc.document.id in seen:
continue
seen.add(rc.document.id)
titles.append(rc.document.title)
return titles
def select_documents(
chunks: Sequence[RetrievedChunk],
n: int | None = None,
+57
View File
@@ -0,0 +1,57 @@
"""Deflection suggestions — the "Maybe try" chips under a deflected answer.
v1 behavior (phase 04, honest-deflection story): chips are derived
deterministically from the weak-hit document titles, so Brain only ever
points the user at topics it actually has indexed — never invented ones.
A model-generated list could layer on top later; the title-derived path
is the shipped, testable one (PLAN §6: deflection offers 2-3 alternative
questions about things the docs DO cover).
"""
from __future__ import annotations
from collections.abc import Sequence
#: The ``done`` event carries at most this many alternative questions.
MAX_SUGGESTIONS = 3
def derive_suggestions(
titles: Sequence[str],
fallback: Sequence[str] = (),
max_n: int = MAX_SUGGESTIONS,
) -> list[str]:
"""Build the alternative-question chips for a deflected turn.
One chip per weak-hit title (*titles* arrive in best-chunk-score
order from :func:`app.rag.retriever.weak_hit_titles`), phrased as a
question the knowledge base can ground. If fewer than *max_n* titles
are available, *fallback* (the onboarding suggestions) tops the list
up so the user still gets 2-3 real options. Whitespace is normalized,
duplicates (case-insensitive) are dropped, and the result contains
only non-empty strings — at most *max_n* of them.
"""
out: list[str] = []
seen: set[str] = set()
def push(item: str) -> None:
item = " ".join(item.split())
if not item:
return
key = item.lower()
if key in seen:
return
seen.add(key)
out.append(item)
for title in titles:
if len(out) >= max_n:
break
title = " ".join(title.split())
if not title:
continue
push(f"What's in your notes about {title}?")
for question in fallback:
if len(out) >= max_n:
break
push(question)
return out