feat(rag): honest deflection gate with amber UI state and alternative-question chips
This commit is contained in:
+71
-27
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user