fix: a greeting is not a query, so nothing is searched for one

"hello" came back with four confident citations and a list of topics the
learner might like, drawn from questions about caustic ingestion. Retrieval had
done nothing wrong: reciprocal-rank fusion always returns an order, and the
similarity gate that exists to catch this is a threshold — "hi" embeds at 0.46
against a corpus of clinical prose and is caught, "ok" at 0.51 and "good
morning" at 0.50 are not.

So the decision is made before any measuring: a message made entirely of
pleasantries, or asking what the assistant is, gets mode "chat" — no retrieval
at all, no shortlist to cite from, and a prompt that says what it can do
without claiming to know what is in the library, because it has not looked.

The vocabulary is closed rather than a length rule, so "croup dose?" is still a
query. A missed greeting costs a slightly odd reply; a swallowed question costs
an answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 19:03:30 +02:00
parent 177df84817
commit ce8074cbe3
3 changed files with 124 additions and 10 deletions

View file

@ -168,14 +168,21 @@ async def ask(conversation_id: int, data: AskIn, db: Session = Depends(get_db),
if not model_id:
raise HTTPException(503, "No AI model is configured. Ask an admin to set one up.")
sources = ai_mode_service.retrieve(db, current_user, question)
# How close the nearest thing in the library actually is, which decides
# which of the three answers this question gets. The shortlist alone cannot
# tell you: reciprocal-rank fusion throws the distances away and returns an
# order that is never empty, so a question about photosynthesis came back
# with six paediatric sources and an instruction to answer only from them.
similarity = ai_mode_service.closeness(db, question)
mode = ai_mode_service.answer_mode(similarity, sources)
# A greeting is not a query, and searching a clinical corpus for one comes
# back full of confident nonsense. Decided before retrieval so nothing is
# searched for, rather than searched for and then thrown away.
if ai_mode_service.is_small_talk(question):
sources, similarity, mode = [], None, "chat"
else:
sources = ai_mode_service.retrieve(db, current_user, question)
# How close the nearest thing in the library actually is, which decides
# which of the three answers this question gets. The shortlist alone
# cannot tell you: reciprocal-rank fusion throws the distances away and
# returns an order that is never empty, so a question about
# photosynthesis came back with six paediatric sources and an
# instruction to answer only from them.
similarity = ai_mode_service.closeness(db, question)
mode = ai_mode_service.answer_mode(similarity, sources, question)
if mode == "open":
# Nothing to cite, so nothing is offered for citation — the shortlist is
# not passed to a model that has just been told the library does not

View file

@ -300,8 +300,72 @@ def closeness(db: Session, query: str) -> float | None:
return None
def answer_mode(similarity: float | None, sources: list[dict]) -> str:
"""Which of the three answers this question gets: sourced, adjacent, or open.
#: Words a message can be made entirely of and still not be a question about
#: anything. Kept as a closed list rather than a length rule: "croup dose?" is
#: two words and is very much a query, while "ok thanks" is two words and is
#: not.
SMALL_TALK_WORDS = {
"hi", "hello", "hey", "yo", "hiya", "greetings", "howdy", "morning",
"afternoon", "evening", "good", "day", "night", "sup", "hallo",
"thanks", "thank", "thx", "ty", "cheers", "appreciated", "much", "you",
"ok", "okay", "okey", "k", "sure", "cool", "nice", "great", "perfect",
"yes", "yeah", "yep", "no", "nope", "nah", "please", "sorry", "welcome",
"bye", "goodbye", "later", "see", "ya", "ciao",
"lol", "haha", "hmm", "hm", "oh", "ah", "wow", "test", "testing",
}
#: Questions about the assistant itself. The library has nothing to say about
#: these by construction, and searching it for them produces exactly the
#: nonsense this guard exists to stop: a greeting answered with four citations.
ABOUT_ASSISTANT_RE = re.compile(
r"^\s*(who\s+(are|r)\s+(you|u)|what\s+(are|r)\s+(you|u)|"
r"what\s+(can|do)\s+(you|u)\s+(do|help)|how\s+(do|does)\s+(this|it)\s+work|"
r"what\s+is\s+this|help)\b",
re.IGNORECASE,
)
def is_small_talk(text: str) -> bool:
"""Whether this message is not a query at all.
A greeting is not a low-scoring question, and the difference matters:
reciprocal-rank fusion always returns an order, so "hello" comes back with
six paediatric sources ranked confidently against nothing. The similarity
gate below catches most of that, but it is a threshold, and "ok" and "good
morning" happen to land the wrong side of it — a greeting embedded into the
same space as a corpus of clinical prose scores wherever it scores. So this
is decided before any measuring is done, on the text itself.
Deliberately narrow. Anything with a word in it that is not pleasantry
falls through to retrieval, because the cost of a missed greeting is a
slightly odd reply and the cost of a swallowed question is an unanswered
one.
"""
stripped = (text or "").strip()
if not stripped:
return True
if ABOUT_ASSISTANT_RE.match(stripped):
return True
words = re.findall(r"[a-z]+", stripped.lower())
if not words or len(words) > 5:
return False
return all(word in SMALL_TALK_WORDS for word in words)
CHAT_PROMPT = (
ROLE +
"The learner has not asked a question yet — this turn is a greeting, a "
"thank-you, or a question about you rather than about medicine.\n\n"
"Reply in one or two short sentences. Say what you can do: answer from the "
"articles, questions and cards in their library, and point them at "
"questions to practise. Do not list topics, do not cite anything, and do "
"not invent what their library contains — you have not looked."
)
def answer_mode(similarity: float | None, sources: list[dict],
question: str | None = None) -> str:
"""Which answer this turn gets: chat, sourced, adjacent, or open.
Decided by a number rather than by asking the model to work out which
situation it is in. Classification written as prose in a prompt is the part
@ -312,6 +376,8 @@ def answer_mode(similarity: float | None, sources: list[dict]) -> str:
because the ruler is missing would silently drop every citation on a
deployment where semantic search happens to be unavailable.
"""
if question is not None and is_small_talk(question):
return "chat"
if not sources:
return "open"
if similarity is None or similarity >= STRONG_MATCH:
@ -320,6 +386,11 @@ def answer_mode(similarity: float | None, sources: list[dict]) -> str:
def build_prompt(sources: list[dict], mode: str = "sourced") -> str:
if mode == "chat":
# No sources, and — unlike "open" — no announcement that the library
# does not cover it either. Nobody who says hello is waiting to be told
# what their library lacks.
return CHAT_PROMPT
if mode == "open" or not sources:
# Nothing in the library is close, so the honest answer is to say that
# and then help anyway. Refusing outright was the old behaviour and it

View file

@ -90,6 +90,42 @@ class CitationContractTests(unittest.TestCase):
# dropping every citation because the ruler is missing would be worse.
self.assertEqual(ai_mode_service.answer_mode(None, sources), "sourced")
def test_a_greeting_is_not_a_query(self):
"""Whatever it scores, "hello" is not a question about anything.
This is the bug in the screenshot: a greeting came back with four
confident citations, because reciprocal-rank fusion always returns an
order and "ok" happens to embed at 0.51 against a corpus of clinical
prose the wrong side of the adjacency threshold.
"""
sources = self.sources()
for greeting in ["hi", "Hello", "hello!", " thanks ", "ok thanks",
"good morning", "Thank you!", "bye", "",
"who are you?", "what can you do"]:
self.assertTrue(ai_mode_service.is_small_talk(greeting), greeting)
self.assertEqual(
ai_mode_service.answer_mode(0.9, sources, greeting), "chat", greeting)
def test_a_short_clinical_question_is_still_a_query(self):
# The guard is a closed vocabulary rather than a length rule, precisely
# so that these keep reaching retrieval.
sources = self.sources()
for query in ["croup dose?", "ok to give ibuprofen at 3 months?",
"no stridor now what", "hi flow nasal cannula",
"thanks to which vaccine has Hib fallen?"]:
self.assertFalse(ai_mode_service.is_small_talk(query), query)
self.assertEqual(
ai_mode_service.answer_mode(0.72, sources, query), "sourced", query)
def test_the_chat_prompt_offers_nothing_and_claims_nothing(self):
prompt = ai_mode_service.build_prompt(self.sources(), "chat")
# No shortlist reaches a turn that was never a query, so there is
# nothing for the model to cite even if it tries.
self.assertNotIn("[[section:7#abc]]", prompt)
self.assertNotIn("SOURCES", prompt)
# And unlike "open", it does not announce a gap nobody asked about.
self.assertNotIn("Nothing in this learner's library", prompt)
class _AiModeBase(unittest.TestCase):
"""Fixtures shared by the route and retrieval cases; holds no tests itself."""