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()
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user