Fix live multi-turn: pass the resolved drug, stop did-you-mean garbage

This commit is contained in:
2026-08-05 16:54:35 +07:00
parent ef08b4929e
commit 1e8cbdb586
29 changed files with 2013 additions and 83 deletions
+46 -28
View File
@@ -46,8 +46,22 @@ from .reasoning import (
clarify_for,
run_turn,
)
from .routing import CatalogDrugResolver, DrugResolutionStatus
from .sections import SECTION_PHRASES, SectionResolver
from .routing import CatalogDrugResolver, DrugResolutionStatus, normalize_name
from .sections import SectionResolver
# Turns that only confirm a prior suggestion. They resolve no drug and must not
# be fuzzy-matched against the catalog (which returns garbage like terbinafin).
# Stored normalised (normalize_name strips diacritics: "đúng" -> "dung"), or the
# lookup below never matches.
_CONFIRMATION_WORDS = (
"đúng", "đúng rồi", "đúng vậy", "phải", "phải rồi", "chuẩn", "chuẩn rồi",
"chính xác", "", "uh", "ok", "oke", "yes", "vâng",
)
_CONFIRMATIONS = frozenset(normalize_name(word) for word in _CONFIRMATION_WORDS)
def _is_confirmation(text: str) -> bool:
return normalize_name(text) in _CONFIRMATIONS
SUMMARY_EVERY = 4 # regenerate the summary at most every S turns, per ADR 0007 §2
@@ -272,11 +286,30 @@ class ConversationalLoopService:
# a near-miss for real drug names, offer them ("did you mean") rather
# than a bare "which drug?" — a typo should not dead-end.
if resolved.drug_id is None:
# Only genuinely-close names are offered. A far match (Arginin for
# "metfomin") is noise, not a suggestion — so the bar is high, and
# when nothing clears it the honest answer is "not in the formulary",
# never a padded list of unrelated drugs.
suggestions = self._resolver.suggest(query, k=3, min_score=0.72)
# A bare confirmation ("đúng") with no drug in context is not a drug
# lookup — never fuzzy-match it (that returned terbinafin/tretinoin).
if _is_confirmation(query):
reason = "confirm_without_context"
clarification = Clarification(
reason=reason,
question="Bạn muốn xác nhận thuốc nào? Vui lòng gõ tên thuốc để mình tra cứu.",
options=(),
)
self._metrics.increment(metric_names.CLARIFY_ASKED, reason=reason)
self._persist(state, resolved, None)
return ConversationTurnResult(None, clarification, None, False, None, reason)
# Only offer "did you mean" for a SHORT, drug-name-shaped miss (a
# typo). Fuzzy-matching a whole sentence ("EPO điều trị thiếu máu…")
# or a confirmation ("đúng") against 684 aliases returns confident
# garbage — that is the did-you-mean loop the reviewer hit. A long or
# confirming turn that resolves no drug is answered honestly, not
# with a list of unrelated drugs.
looks_like_name = len(normalize_name(query).split()) <= 4
suggestions = (
self._resolver.suggest(query, k=3, min_score=0.72)
if looks_like_name and not _is_confirmation(query)
else []
)
if suggestions:
names = [self._drug_name(drug_id) for drug_id, _ in suggestions]
reason = "did_you_mean"
@@ -301,16 +334,13 @@ class ConversationalLoopService:
if resolved.inherited_drug:
self._metrics.increment(metric_names.FOLLOWUP_INHERITED)
# One call to the safe engine with the self-contained (rewritten) query.
# A multi-round retrieval-refine loop was tried and removed: refining an
# already-answerable whole-section result cannot fetch more (the section
# is complete) and, worse, the refined query drops the inherited drug and
# abstains — discarding a good answer. Refinement belongs to the
# similarity path, not here. Clarify + inheritance are the loop's value,
# and both happen above this line.
effective = self._rewrite(query, resolved)
# One call to the safe engine. The drug is passed already-resolved (incl.
# an inherited follow-up drug), so the engine does NOT re-resolve it from
# the turn text — that double-resolution is what abstained follow-ups as
# "ambiguous". The turn's own text drives section routing; when it names
# no attribute the drug-overview + rerank path finds the relevant part.
grounded: GroundedAnswer | None = self._answers.answer(
effective, subject_scope, intent
query, subject_scope, intent, drug_id=resolved.drug_id
)
answer = grounded.answer if grounded else None
@@ -339,18 +369,6 @@ class ConversationalLoopService:
"""A readable display name from a drug id ('paracetamol_acetaminophen')."""
return drug_id.replace("_", " ").title()
@staticmethod
def _rewrite(query: str, resolved) -> str:
parts: list[str] = []
if resolved.inherited_drug and resolved.drug_id:
parts.append(resolved.drug_id)
if resolved.inherited_section and resolved.section_key:
phrases = SECTION_PHRASES.get(resolved.section_key)
if phrases:
parts.append(phrases[0])
parts.append(query)
return " ".join(parts)
def _append_user(self, state, text, drug_id, section_key) -> None:
state = state.append(Turn("user", text, _now(), drug_id, section_key))
self._store.save(state)