feat(rag): honest deflection gate with amber UI state and alternative-question chips
This commit is contained in:
@@ -32,6 +32,7 @@ from app.rag.llm import EmbeddingError, LLMError
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "docs"
|
||||
QUESTION = "How is my Kubernetes cluster set up?"
|
||||
OFF_TOPIC = "How do I bake sourdough bread?"
|
||||
DIM = 768
|
||||
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
||||
|
||||
@@ -164,11 +165,51 @@ def test_chat_writes_query_log_row(client, db, seeded_kb: FakeRagLLM) -> None:
|
||||
total_chunks = db.scalar(select(func.count()).select_from(Chunk))
|
||||
assert row.chunk_hits == min(get_settings().top_k_chunks, total_chunks)
|
||||
assert row.top_score > 0.0 # genuine token-overlap cosine, best hit
|
||||
assert row.top_score >= get_settings().relevance_threshold # why the gate answered
|
||||
assert row.top_score <= 1.0
|
||||
assert "docs/homelab/kubernetes.md" in row.sources
|
||||
assert row.latency_ms >= 0
|
||||
|
||||
|
||||
def test_off_topic_question_deflects_honestly(client, db, seeded_kb: FakeRagLLM) -> None:
|
||||
"""Phase 04 contract: weak retrieval ⇒ honest deflection, no fake answer."""
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||
try:
|
||||
_, _, frames = _stream_chat(client, OFF_TOPIC)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
assert not any(f.get("type") == "error" for f in frames)
|
||||
deltas = [f for f in frames if f.get("type") == "delta"]
|
||||
assert len(deltas) >= 2 # the LLM is still called (voice stays chippy)
|
||||
done = frames[-1]
|
||||
assert done["type"] == "done"
|
||||
assert done["deflected"] is True
|
||||
# 2-3 alternative chips, all non-empty, derived from real titles/topics.
|
||||
assert 2 <= len(done["suggestions"]) <= 3
|
||||
assert all(s.strip() for s in done["suggestions"])
|
||||
assert any(
|
||||
"Deploying a New Service" in s for s in done["suggestions"]
|
||||
), "the best weak-hit title must be offered as a chip"
|
||||
assert done["sources"], "weak hits are still reported as the closest sources"
|
||||
|
||||
# The LLM saw the LOW prompt: DEFLECT_MODE + titles, never doc content.
|
||||
(system, user) = seeded_kb.seen_messages[0][0], seeded_kb.seen_messages[0][1]
|
||||
assert user["content"] == OFF_TOPIC
|
||||
assert "<relevance>LOW</relevance>" in system["content"]
|
||||
assert "DEFLECT_MODE" in system["content"]
|
||||
assert "HONESTY GATE" in system["content"]
|
||||
assert "Talos Linux" not in system["content"] # full doc content never sent
|
||||
assert "<documents>" not in system["content"]
|
||||
|
||||
# Durable record: deflected=true + the weak top_score.
|
||||
row = db.scalars(select(QueryLog)).one()
|
||||
assert row.question == OFF_TOPIC
|
||||
assert row.deflected is True
|
||||
assert 0.0 < row.top_score < get_settings().relevance_threshold
|
||||
assert row.chunk_hits >= 1
|
||||
|
||||
|
||||
def test_chat_empty_kb_streams_empty_sources(client, db) -> None:
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||
db.commit()
|
||||
@@ -179,11 +220,17 @@ def test_chat_empty_kb_streams_empty_sources(client, db) -> None:
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
# Nothing retrieved ⇒ nothing to pretend to know: honest deflection.
|
||||
done = frames[-1]
|
||||
assert done["type"] == "done"
|
||||
assert done["deflected"] is False
|
||||
assert done["deflected"] is True
|
||||
assert done["sources"] == []
|
||||
assert 2 <= len(done["suggestions"]) <= 3 # onboarding fallback chips
|
||||
(system, _user) = llm.seen_messages[0][0], llm.seen_messages[0][1]
|
||||
assert "DEFLECT_MODE" in system["content"]
|
||||
assert "nothing close at all" in system["content"]
|
||||
row = db.scalars(select(QueryLog)).one()
|
||||
assert row.deflected is True
|
||||
assert row.top_score == 0.0
|
||||
assert row.chunk_hits == 0
|
||||
assert row.sources == ""
|
||||
|
||||
Reference in New Issue
Block a user