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
+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