Wire the guarded conversational RAG answer layer end-to-end

This commit is contained in:
2026-08-05 14:33:13 +07:00
parent 834d9e51b0
commit ef08b4929e
127 changed files with 37921 additions and 169 deletions
+4
View File
@@ -0,0 +1,4 @@
from .models import EvidenceDecision, RetrievalResult
from .service import EvidencePolicy, RetrievalService
__all__ = ["EvidenceDecision", "EvidencePolicy", "RetrievalResult", "RetrievalService"]
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
import json
from dataclasses import dataclass, replace
from . import grounding, metrics as metric_names
from .metrics import Metrics, NullMetrics
from .models import EvidenceDecision, QueryIntent, RetrievalResult, SubjectScope
from .ports import AnswerGenerationUnavailable, AnswerGenerator
from .prompt import build_request
from .routing import QueryRoutingService
@dataclass(frozen=True)
class Citation:
chunk_id: str
printed_page_start: int
printed_page_end: int
physical_page: int
block_id: str | None = None
bbox: tuple[float, float, float, float] | None = None
source_crop: str | None = None
attachment: str | None = None
@dataclass(frozen=True)
class GroundedAnswer:
result: RetrievalResult
answer: str | None
citations: tuple[Citation, ...] = ()
generated: bool = False
class GroundedAnswerService:
"""Retrieval decides what is true; generation only decides how it reads.
When a generator is configured, its output replaces the extractive text
**only** if `grounding.verify` confirms every figure and citation in it
traces back to the retrieved evidence. Anything else — an unsupported
number, a citation to nothing, a provider outage, malformed output — falls
back to quoting the source verbatim, which is always available because it
was computed first.
"""
def __init__(
self,
routing: QueryRoutingService,
generator: AnswerGenerator | None = None,
metrics: Metrics | None = None,
) -> None:
self._routing = routing
self._generator = generator
self._metrics = metrics or NullMetrics()
def answer(
self,
query: str,
subject_scope: SubjectScope,
intent: QueryIntent,
) -> GroundedAnswer:
result = self._routing.retrieve(query, subject_scope, intent)
if result.decision == EvidenceDecision.ABSTAIN:
self._metrics.increment(metric_names.ABSTENTION, reason=result.reason)
return GroundedAnswer(result, None)
citations = self._citations(result)
if citations is None:
return GroundedAnswer(
replace(
result,
decision=EvidenceDecision.ABSTAIN,
reason="missing_printed_page_provenance",
evidence=(),
),
None,
)
if result.decision == EvidenceDecision.VERIFY_PDF:
# Never generated over. A quarantined table or formula is exactly
# the evidence whose numbers were not reliably reconstructed, so
# rephrasing it is the one case where fluency could invent a dose.
return GroundedAnswer(
result,
"Nguồn có bảng hoặc công thức cần đối chiếu trực tiếp với ảnh PDF; "
"không tự động trích số liệu.",
citations,
)
evidence_texts = tuple(item.text for item in result.evidence)
extractive = "\n\n".join(
f"{text} [{index}]" for index, text in enumerate(evidence_texts, start=1)
)
generated = self._generate(query, evidence_texts)
if generated is None:
self._metrics.increment(metric_names.ANSWER_EXTRACTIVE)
return GroundedAnswer(result, extractive, citations)
self._metrics.increment(metric_names.GENERATION_SERVED)
return GroundedAnswer(result, generated, citations, generated=True)
def _generate(self, query: str, evidence_texts: tuple[str, ...]) -> str | None:
"""A verified generation, or None to fall back to the source text."""
if self._generator is None or not evidence_texts:
return None
request = build_request(query, evidence_texts)
try:
raw = self._generator.generate(request.system, request.user, request.schema)
except AnswerGenerationUnavailable:
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="provider_unavailable"
)
return None
try:
payload = json.loads(raw)
answer = payload["answer"]
sufficient = payload["evidence_sufficient"]
except (ValueError, TypeError, KeyError):
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="malformed_output"
)
return None
if not isinstance(answer, str) or not isinstance(sufficient, bool):
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="malformed_output"
)
return None
if not sufficient:
# The model says the evidence does not answer the question. Showing
# the retrieved section verbatim lets the clinician judge that.
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="evidence_insufficient"
)
return None
report = grounding.verify(answer, evidence_texts)
if not report.grounded:
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason=report.reason
)
return None
return answer
@staticmethod
def _citations(result: RetrievalResult) -> tuple[Citation, ...] | None:
citations = []
for evidence in result.evidence:
if not evidence.source_refs:
return None
for source in evidence.source_refs:
printed_range = source.printed_page_range
if printed_range is not None:
start, end = printed_range
elif source.printed_page is not None:
start = end = source.printed_page
else:
return None
citations.append(Citation(
chunk_id=evidence.matched_doc_id,
printed_page_start=int(start),
printed_page_end=int(end),
physical_page=source.physical_page,
block_id=source.block_id,
bbox=source.bbox,
source_crop=source.source_crop,
# Backward-compatible compact attachment identifier. A
# real crop path wins; otherwise the block id plus the
# structured page/bbox fields is enough to render later.
attachment=source.source_crop or source.block_id,
))
return tuple(citations)
+86
View File
@@ -0,0 +1,86 @@
from __future__ import annotations
import json
from pathlib import Path
from .models import ParentDocument, RetrievalDocument, SourceRef
def _read_jsonl(path: Path) -> list[dict]:
with path.open(encoding="utf-8") as handle:
return [json.loads(line) for line in handle if line.strip()]
def _source_ref(raw: dict) -> SourceRef:
bbox = raw.get("bbox")
page_range = raw.get("page_range")
printed_page_range = raw.get("printed_page_range")
return SourceRef(
physical_page=int(raw["physical_page"]),
precision=raw["precision"],
block_id=raw.get("block_id"),
bbox=tuple(bbox) if bbox else None,
source_crop=raw.get("source_crop"),
page_range=tuple(page_range) if page_range else None,
printed_page=raw.get("printed_page"),
printed_page_range=(
tuple(printed_page_range) if printed_page_range else None
),
)
def load_documents(path: Path) -> list[RetrievalDocument]:
documents = []
for raw in _read_jsonl(path):
documents.append(RetrievalDocument(
doc_id=raw["doc_id"],
drug_id=raw["drug_id"],
kind=raw["kind"],
text=raw["text"],
section_key=raw["section_key"],
source_refs=tuple(_source_ref(item) for item in raw.get("source_refs", [])),
parent_id=raw.get("parent_id"),
requires_visual_check=raw.get("requires_visual_check", False),
drug_name=raw.get("drug_name"),
))
return documents
def load_parents(path: Path) -> list[ParentDocument]:
parents = []
for raw in _read_jsonl(path):
parents.append(ParentDocument(
parent_id=raw["logical_table_id"],
kind=raw["kind"],
text=raw["markdown"],
source_refs=tuple(_source_ref(item) for item in raw.get("source_refs", [])),
requires_visual_check=raw.get("requires_visual_check", False),
))
return parents
def load_aliases(path: Path | None) -> dict[str, set[str]]:
if path is None:
return {}
raw = json.loads(path.read_text(encoding="utf-8"))
if isinstance(raw, dict) and "entities" in raw:
return {
entity["drug_id"]: set(entity["aliases"])
for entity in raw["entities"]
}
return {drug_id: set(aliases) for drug_id, aliases in raw.items()}
def build_drug_catalog(
documents: list[RetrievalDocument],
extra_aliases: dict[str, set[str]] | None = None,
) -> dict[str, set[str]]:
catalog: dict[str, set[str]] = {}
for document in documents:
aliases = catalog.setdefault(document.drug_id, set())
aliases.add(document.drug_id.replace("_", " "))
if document.drug_name:
aliases.add(document.drug_name)
for drug_id, aliases in (extra_aliases or {}).items():
catalog.setdefault(drug_id, set()).update(aliases)
return catalog
+24
View File
@@ -0,0 +1,24 @@
"""Deterministic clinical calculators.
Audit §7: a dose calculation or unit conversion must be a tested function, never
an LLM. Body surface area replaces Appendix 1's lookup table (Dược thư 2018,
printed page 1499) with the book's own DuBois formula, so a BSA-based dose is
*computed and traceable*, not read off a quarantined table crop.
"""
from __future__ import annotations
# Dược thư 2018, Phụ lục 1 (printed 1499), DuBois & DuBois (Arch Intern Med
# 1916;17:863-71): S(cm²) = W^0.425 × H^0.725 × 71.84, W in kg, H in cm.
_DUBOIS_COEFFICIENT = 71.84
def body_surface_area_m2(weight_kg: float, height_cm: float) -> float:
"""Body surface area in m² by the DuBois formula the formulary prints.
Raises ValueError on a non-positive input: a BSA from a zero or negative
weight/height is a data error, not a number to return silently.
"""
if weight_kg <= 0 or height_cm <= 0:
raise ValueError("weight_kg and height_cm must be positive")
area_cm2 = (weight_kg ** 0.425) * (height_cm ** 0.725) * _DUBOIS_COEFFICIENT
return area_cm2 / 10_000
+347
View File
@@ -0,0 +1,347 @@
"""Conversation state, and the rules for carrying context across turns.
Pure domain. Everything here works without an LLM, which is deliberate: the
part of "understanding a follow-up" that matters clinically — *which drug is
this still about* — must be deterministic and testable, not inferred.
Two structures with different jobs:
`Focus` is structured and drives routing. It is what makes "còn trẻ em thì
sao?" resolvable at all.
`summary` is prose for the generator. It records **what was discussed**, never
clinical content: a dose restated from a summary carries no citation and could
not be grounding-verified, because that check compares against retrieved
evidence and a summary is not evidence.
"""
from __future__ import annotations
from dataclasses import dataclass, field, replace
from typing import Literal, Protocol
# A drug named six turns ago is not context, it is a hazard: conversations
# drift, and inheriting a stale drug produces a confident answer about the
# wrong medicine.
FOCUS_TTL_TURNS = 6
# Three exchanges kept verbatim; older turns are folded into the summary.
RECENT_TURNS = 6
Role = Literal["user", "assistant"]
Verbosity = Literal["concise", "detailed"]
@dataclass(frozen=True)
class Turn:
role: Role
text: str
at: str
drug_id: str | None = None
section_key: str | None = None
# Storing what answered a turn is what lets the planner reuse evidence
# instead of retrieving the same section again.
evidence_ids: tuple[str, ...] = ()
@dataclass(frozen=True)
class Focus:
"""The entities a follow-up may inherit, each with the turn that set it."""
drug_id: str | None = None
drug_name: str | None = None
section_key: str | None = None
population: str | None = None
verbosity: Verbosity | None = None
set_at_turn: dict[str, int] = field(default_factory=dict)
def age_of(self, name: str, turn_count: int) -> int | None:
set_at = self.set_at_turn.get(name)
return None if set_at is None else turn_count - set_at
def is_fresh(self, name: str, turn_count: int, ttl: int = FOCUS_TTL_TURNS) -> bool:
age = self.age_of(name, turn_count)
return age is not None and age <= ttl
def with_field(self, name: str, value, turn: int) -> "Focus":
stamps = dict(self.set_at_turn)
stamps[name] = turn
return replace(self, **{name: value}, set_at_turn=stamps)
def expire(self, turn_count: int, ttl: int = FOCUS_TTL_TURNS) -> "Focus":
"""Drops every field older than the TTL, stamps included."""
kept = {
name: getattr(self, name)
for name in ("drug_id", "drug_name", "section_key", "population", "verbosity")
if self.is_fresh(name, turn_count, ttl)
}
stamps = {
name: at for name, at in self.set_at_turn.items() if name in kept
}
return Focus(**kept, set_at_turn=stamps)
@dataclass(frozen=True)
class ConversationState:
conversation_id: str
recent: tuple[Turn, ...] = ()
summary: str = ""
focus: Focus = field(default_factory=Focus)
turn_count: int = 0
def append(self, turn: Turn, window: int = RECENT_TURNS) -> "ConversationState":
"""Adds a turn and evicts the oldest beyond the window.
Eviction returns the dropped turns to the caller's summariser via
`overflow`, rather than discarding them here — this type does not
decide what a summary says.
"""
recent = (*self.recent, turn)[-window:]
return replace(
self,
recent=recent,
turn_count=self.turn_count + 1,
)
def overflow(self, window: int = RECENT_TURNS) -> tuple[Turn, ...]:
return self.recent[:-window] if len(self.recent) > window else ()
def inherited(self, name: str):
"""A focus value only if it is still fresh; otherwise None."""
return getattr(self.focus, name) if self.focus.is_fresh(name, self.turn_count) else None
# --- follow-up resolution -----------------------------------------------------
# Phrases that mean "same question, different population". Longest-first for the
# same reason `sections.py` sorts that way: "phụ nữ cho con bú" must be tested
# before "phụ nữ", or the more specific reading is never reached.
POPULATION_PHRASES: dict[str, str] = {
"phụ nữ cho con bú": "phu_nu_cho_con_bu",
"người cao tuổi": "nguoi_cao_tuoi",
"phụ nữ có thai": "phu_nu_co_thai",
"người suy thận": "suy_than",
"người suy gan": "suy_gan",
"trẻ sơ sinh": "tre_so_sinh",
"người lớn": "nguoi_lon",
"bà bầu": "phu_nu_co_thai",
"trẻ nhỏ": "tre_em",
"trẻ em": "tre_em",
"người già": "nguoi_cao_tuoi",
}
VERBOSITY_PHRASES: dict[str, Verbosity] = {
"giải thích kỹ hơn": "detailed",
"nói rõ hơn": "detailed",
"chi tiết hơn": "detailed",
"ngắn gọn": "concise",
"tóm tắt": "concise",
}
# A turn that is only a qualifier — no drug, no attribute — is a follow-up by
# construction. These are the openers that mark one.
FOLLOWUP_MARKERS = ("còn", "thế còn", "vậy còn", "so với", "thuốc vừa", "cái đó", "")
# Greetings, thanks, farewells and bare acknowledgements. A turn made up only of
# these is social, not a failed drug lookup: answering "Chưa xác định được
# thuốc" to "chào bạn" reads as broken. Longest-first so "cảm ơn nhiều" is
# stripped before "cảm ơn".
SMALLTALK_PHRASES = (
"xin chào", "chào bạn", "chào ad", "cảm ơn nhiều", "cảm ơn bạn", "cám ơn",
"cảm ơn", "tạm biệt", "hay quá", "tuyệt vời", "hiểu rồi", "được rồi",
"chào", "hello", "hi", "alo", "thanks", "thank", "ok", "oke", "okie",
"", "uh", "haha", "hihi", "bye",
)
def is_smalltalk(text: str) -> bool:
"""True when a turn carries nothing but social phrases.
Deliberately conservative: it strips every known social phrase and returns
True only if what remains is empty. "chào bạn, liều paracetamol?" keeps
"liều paracetamol" after stripping, so it is treated as a real question —
a greeting must never swallow the medical part of a turn.
"""
remainder = _normalise(text).strip(" .,!?;:")
for phrase in sorted(SMALLTALK_PHRASES, key=len, reverse=True):
# Space-pad both sides so a short phrase ("hi", "ok") matches a whole
# word only, never a substring of "chi" or "block".
remainder = f" {remainder} ".replace(f" {phrase} ", " ").strip(" .,!?;:")
return not remainder
def _normalise(text: str) -> str:
return " ".join(text.casefold().split())
def _longest_first(phrases: dict[str, str]) -> list[tuple[str, str]]:
return sorted(phrases.items(), key=lambda item: -len(item[0]))
def detect_population(text: str) -> str | None:
normalised = _normalise(text)
for phrase, tag in _longest_first(POPULATION_PHRASES):
if phrase in normalised:
return tag
return None
def detect_verbosity(text: str) -> Verbosity | None:
normalised = _normalise(text)
for phrase, level in _longest_first(VERBOSITY_PHRASES):
if phrase in normalised:
return level
return None
def looks_like_followup(text: str) -> bool:
normalised = _normalise(text)
return any(normalised.startswith(marker) for marker in FOLLOWUP_MARKERS)
@dataclass(frozen=True)
class ResolvedQuestion:
"""What this turn is asking, after the conversation is taken into account."""
text: str
drug_id: str | None
section_key: str | None
population: str | None
verbosity: Verbosity | None
inherited_drug: bool
inherited_section: bool
@property
def needs_carry_over_notice(self) -> bool:
"""Whether the answer must name what it inherited.
An inherited drug that is wrong is a wrong-drug answer, so the answer
has to say which drug it decided this was about.
"""
return self.inherited_drug
def resolve_against(
state: ConversationState,
text: str,
drug_id: str | None,
section_key: str | None,
) -> ResolvedQuestion:
"""Fills gaps in this turn from conversation focus, freshness permitting.
`drug_id` and `section_key` are what this turn resolved on its own — the
existing resolvers decide those, unchanged. Only what the turn left blank
is inherited, so an explicit mention always wins over context.
"""
inherited_drug = False
inherited_section = False
if drug_id is None:
carried = state.inherited("drug_id")
if carried is not None:
drug_id, inherited_drug = carried, True
if section_key is None:
carried = state.inherited("section_key")
if carried is not None:
section_key, inherited_section = carried, True
population = detect_population(text) or state.inherited("population")
verbosity = detect_verbosity(text) or state.inherited("verbosity")
return ResolvedQuestion(
text=text,
drug_id=drug_id,
section_key=section_key,
population=population,
verbosity=verbosity,
inherited_drug=inherited_drug,
inherited_section=inherited_section,
)
def update_focus(
state: ConversationState,
resolved: ResolvedQuestion,
) -> Focus:
"""Focus after this turn, stamped with the current turn index."""
focus = state.focus.expire(state.turn_count)
turn = state.turn_count
for name, value in (
("drug_id", resolved.drug_id),
("section_key", resolved.section_key),
("population", resolved.population),
("verbosity", resolved.verbosity),
):
if value is not None:
focus = focus.with_field(name, value, turn)
return focus
# --- persistence and summary --------------------------------------------------
#
# Protocol + no-LLM default co-located, matching how `reasoning.py` ships
# `SufficiencyAssessor`/`DeterministicAssessor` and `metrics.py` ships
# `Metrics`/`NullMetrics`. The Postgres-backed store lives in `adapters/`.
class ConversationStore(Protocol):
"""Loads and persists one conversation's state.
`load` returns a fresh empty state for an unknown id rather than raising: a
first turn has no prior state, and that is not an error.
"""
def load(self, conversation_id: str) -> "ConversationState": ...
def save(self, state: "ConversationState") -> None: ...
class InMemoryConversationStore:
"""Reference implementation and the offline/test default."""
def __init__(self) -> None:
self._states: dict[str, ConversationState] = {}
def load(self, conversation_id: str) -> ConversationState:
return self._states.get(conversation_id, ConversationState(conversation_id))
def save(self, state: ConversationState) -> None:
self._states[state.conversation_id] = state
class Summariser(Protocol):
"""Folds turns evicted from the recent window into rolling prose.
Contract, load-bearing for safety: the summary records *what was discussed*,
never a clinical value. A dose copied into a summary carries no citation and
cannot be grounding-verified — the check compares against retrieved
evidence, and a summary is not evidence.
"""
def fold(self, prev_summary: str, dropped: tuple["Turn", ...]) -> str: ...
class DeterministicSummariser:
"""No-LLM default: one topic line per evicted user turn, capped.
Records only the drug and section a turn was *about* — labels, never cell
values — so the no-clinical-content rule holds by construction rather than
by trusting a generator not to leak a dose.
"""
MAX_CHARS = 1600 # ~400 tokens, per ADR 0007 §2
def fold(self, prev_summary: str, dropped: tuple[Turn, ...]) -> str:
lines = [prev_summary] if prev_summary else []
for turn in dropped:
if turn.role != "user":
continue
drug = turn.drug_id or "thuốc chưa xác định"
section = turn.section_key or "thông tin chung"
lines.append(f"- đã hỏi {section} của {drug}")
text = "\n".join(lines)
# Keep the most recent topics when over budget: drop oldest lines, not
# mid-line characters, so the summary never ends on a fragment.
while len(text) > self.MAX_CHARS and len(lines) > 1:
lines.pop(0)
text = "\n".join(lines)
return text
+381
View File
@@ -0,0 +1,381 @@
"""Orchestration: turns a stateless single-turn engine into a conversation.
This is the glue ADR 0007 specified and nothing yet called. It owns no rules of
its own — inheritance lives in `conversation.py`, the bounded loop in
`reasoning.py`, grounding in `grounding.py`. Its whole job is the sequence:
load state
→ resolve this turn, then inherit gaps from focus
→ derive clarify signals from resolver state (never a model score)
→ run the bounded loop (retrieve / generate / verify)
→ update focus, append turns, summarise overflow, save
→ name any inherited drug in the answer
Everything here runs with no LLM and no live service: the collaborators are
protocols, so a turn can be exercised end-to-end with fakes.
"""
from __future__ import annotations
from dataclasses import dataclass, replace
from typing import Protocol
from . import metrics as metric_names
from .answer import GroundedAnswer, GroundedAnswerService
from .conversation import (
ConversationState,
ConversationStore,
Summariser,
Turn,
is_smalltalk,
resolve_against,
update_focus,
)
from .metrics import Metrics, NullMetrics
from .models import EvidenceDecision, QueryIntent, SubjectScope
from .reasoning import (
BudgetExhausted,
Clarification,
ClarifyReason,
DeterministicAssessor,
Generate,
LoopOutcome,
MAX_RETRIEVAL_ROUNDS,
Retrieve,
SufficiencyAssessor,
TurnBudget,
clarify_for,
run_turn,
)
from .routing import CatalogDrugResolver, DrugResolutionStatus
from .sections import SECTION_PHRASES, SectionResolver
SUMMARY_EVERY = 4 # regenerate the summary at most every S turns, per ADR 0007 §2
@dataclass(frozen=True)
class TurnResolution:
"""What one turn resolved on its own, before conversation is considered.
`drug_status` is the resolver's verdict — resolved / not_found / ambiguous —
kept distinct from `drug_id` so an ambiguous turn (asks which drug) reads
differently from a bare follow-up (inherits the drug).
"""
drug_id: str | None
section_key: str | None
drug_status: str
class TurnResolverPort(Protocol):
def resolve_turn(self, text: str) -> TurnResolution: ...
@dataclass(frozen=True)
class TurnResponse:
answer: str | None
clarification: Clarification | None
evidence_texts: tuple[str, ...]
stopped_because: str
inherited_drug: str | None
generated: bool
class ConversationalRagService:
def __init__(
self,
store: ConversationStore,
summariser: Summariser,
resolver: TurnResolverPort,
retrieve: Retrieve,
generate: Generate,
metrics: Metrics | None = None,
summary_every: int = SUMMARY_EVERY,
) -> None:
self._store = store
self._summariser = summariser
self._resolver = resolver
self._retrieve = retrieve
self._generate = generate
self._metrics = metrics or NullMetrics()
self._summary_every = summary_every
def answer(
self, conversation_id: str, text: str, budget: TurnBudget | None = None
) -> TurnResponse:
state = self._store.load(conversation_id)
turn = self._resolver.resolve_turn(text)
resolved = resolve_against(state, text, turn.drug_id, turn.section_key)
signals = self._clarify_signals(resolved, turn)
if resolved.inherited_drug:
self._metrics.increment(metric_names.FOLLOWUP_INHERITED)
outcome = run_turn(
state,
resolved,
self._retrieve,
self._generate,
clarify_signals=signals,
budget=budget or TurnBudget(),
metrics=self._metrics,
)
self._persist(state, resolved, outcome)
answer = outcome.answer
inherited = resolved.drug_id if resolved.needs_carry_over_notice else None
if answer is not None and inherited is not None:
# An inherited drug that is wrong is a wrong-drug answer, so the
# answer has to say which drug it decided this was about.
answer = f"Về {inherited}: {answer}"
return TurnResponse(
answer=answer,
clarification=outcome.clarification,
evidence_texts=outcome.evidence_texts,
stopped_because=outcome.stopped_because,
inherited_drug=inherited,
generated=outcome.generated,
)
@staticmethod
def _clarify_signals(resolved, turn: TurnResolution) -> tuple[str, ...]:
"""Resolver states that should ask instead of guess.
Only fires when the drug is *still* unknown after inheritance: a
follow-up like "còn trẻ em thì sao?" names no drug but inherits one, and
must not be turned into a clarify.
"""
if resolved.drug_id is None:
return (ClarifyReason.AMBIGUOUS_DRUG,)
return ()
def _persist(
self, state: ConversationState, resolved, outcome: LoopOutcome
) -> None:
focus = update_focus(state, resolved)
state = ConversationState(
conversation_id=state.conversation_id,
recent=state.recent,
summary=state.summary,
focus=focus,
turn_count=state.turn_count,
)
state = state.append(
Turn("user", resolved.text, _now(), resolved.drug_id, resolved.section_key)
)
if outcome.answer is not None:
state = state.append(
Turn(
"assistant",
outcome.answer,
_now(),
resolved.drug_id,
resolved.section_key,
evidence_ids=tuple(str(i) for i in range(len(outcome.evidence_texts))),
)
)
if state.turn_count % self._summary_every == 0 and state.overflow():
summary = self._summariser.fold(state.summary, state.overflow())
state = ConversationState(
conversation_id=state.conversation_id,
recent=state.recent,
summary=summary,
focus=state.focus,
turn_count=state.turn_count,
)
self._store.save(state)
def _now() -> str:
# Timestamps are provenance, not logic; the domain never branches on them,
# so a monotonic placeholder keeps this module free of wall-clock coupling.
return ""
# --- live chat core -----------------------------------------------------------
#
# The deployable multi-turn path. The loop is what *understands and clarifies*
# a turn; retrieval, citation, VERIFY_PDF and grounding stay inside
# GroundedAnswerService, untouched — so clarify + refine are added *around* the
# safe engine, never inside it.
SMALLTALK_REPLY = (
"Mình tra cứu Dược thư Quốc gia Việt Nam. Bạn muốn hỏi về thuốc nào, "
"hoặc thuộc tính nào (liều dùng, chống chỉ định, tương tác…)?"
)
@dataclass(frozen=True)
class ConversationTurnResult:
answer: str | None
clarification: Clarification | None
grounded: GroundedAnswer | None
smalltalk: bool
inherited_drug: str | None
reason: str
class ConversationalLoopService:
def __init__(
self,
answers: GroundedAnswerService,
resolver: CatalogDrugResolver,
section_resolver: SectionResolver,
store: ConversationStore,
assessor: SufficiencyAssessor | None = None,
summariser: Summariser | None = None,
metrics: Metrics | None = None,
) -> None:
self._answers = answers
self._resolver = resolver
self._section_resolver = section_resolver
self._store = store
self._assessor = assessor or DeterministicAssessor()
self._summariser = summariser
self._metrics = metrics or NullMetrics()
def answer(
self,
conversation_id: str,
query: str,
subject_scope: SubjectScope,
intent: QueryIntent,
budget: TurnBudget | None = None,
) -> ConversationTurnResult:
state = self._store.load(conversation_id)
resolution = self._resolver.resolve(query)
# Only an EXACT name is auto-accepted. A fuzzy match (score < 1.0) is a
# guess, and a formulary must not silently answer about a *different*
# drug than the one meant — a typo is asked about ("did you mean…?"),
# never resolved on a similarity threshold. Autocomplete at input is the
# first line; this is the backstop when a wrong name is still submitted.
is_exact = resolution.status == DrugResolutionStatus.RESOLVED and (
resolution.score is None or resolution.score >= 0.999
)
drug_self = resolution.drug_id if is_exact else None
# Social turn that names no drug: answer as a person, not a failed lookup.
if drug_self is None and is_smalltalk(query):
self._append_user(state, query, None, None)
return ConversationTurnResult(
SMALLTALK_REPLY, None, None, True, None, "smalltalk"
)
section = self._section_resolver.resolve(query)
section_self = section.section_key if section else None
resolved = resolve_against(state, query, drug_self, section_self)
# Clarify beats guessing: no drug even after inheritance. If the text is
# 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)
if suggestions:
names = [self._drug_name(drug_id) for drug_id, _ in suggestions]
reason = "did_you_mean"
clarification = Clarification(
reason=reason,
question=f"Ý bạn là: {', '.join(names)}?",
options=tuple(names),
)
else:
reason = "drug_not_supported"
clarification = Clarification(
reason=reason,
question=(
"Không có thuốc này trong Dược thư Quốc gia. Vui lòng kiểm "
"tra lại tên, hoặc gõ vài ký tự để chọn từ gợi ý."
),
options=(),
)
self._metrics.increment(metric_names.CLARIFY_ASKED, reason=reason)
self._persist(state, resolved, None)
return ConversationTurnResult(None, clarification, None, False, None, reason)
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)
grounded: GroundedAnswer | None = self._answers.answer(
effective, subject_scope, intent
)
answer = grounded.answer if grounded else None
inherited = resolved.drug_id if resolved.needs_carry_over_notice else None
if answer is not None and inherited is not None:
answer = f"Về {inherited}: {answer}"
if grounded is not None:
grounded = replace(grounded, answer=answer)
self._persist(state, resolved, grounded)
return ConversationTurnResult(
answer,
None,
grounded,
False,
inherited,
grounded.result.reason if grounded else "no_answer",
)
def complete(self, prefix: str, k: int = 8) -> list[str]:
"""Display names matching a typed prefix, for input autocomplete."""
return [self._drug_name(drug_id) for drug_id in self._resolver.complete(prefix, k)]
@staticmethod
def _drug_name(drug_id: str) -> str:
"""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)
def _persist(self, state, resolved, grounded) -> None:
focus = update_focus(state, resolved)
state = replace(state, focus=focus)
state = state.append(
Turn("user", resolved.text, _now(), resolved.drug_id, resolved.section_key)
)
if grounded is not None and grounded.answer is not None:
state = state.append(
Turn(
"assistant",
grounded.answer,
_now(),
resolved.drug_id,
resolved.section_key,
)
)
if (
self._summariser is not None
and state.turn_count % SUMMARY_EVERY == 0
and state.overflow()
):
summary = self._summariser.fold(state.summary, state.overflow())
state = replace(state, summary=summary)
self._store.save(state)
+93
View File
@@ -0,0 +1,93 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from .models import SubjectScope
class CaseOrigin(StrEnum):
EXPERT = "expert"
MANUAL_ADVERSARIAL = "manual_adversarial"
SOURCE_DERIVED = "source_derived"
@dataclass(frozen=True)
class EvaluationCase:
case_id: str
query: str
expected_drug_id: str | None
expected_id: str | None
origin: CaseOrigin
subject_scope: SubjectScope
@dataclass(frozen=True)
class EvaluationOutcome:
case: EvaluationCase
retrieved_ids: tuple[str, ...]
resolved_drug_id: str | None = None
drug_resolution_status: str = "not_attempted"
@property
def passed(self) -> bool:
if self.case.expected_id is None:
return not self.retrieved_ids
return self.case.expected_id in self.retrieved_ids
def summarize(outcomes: list[EvaluationOutcome]) -> dict:
def metrics(rows: list[EvaluationOutcome]) -> dict:
positive = [row for row in rows if row.case.expected_id is not None]
negative = [row for row in rows if row.case.expected_id is None]
resolution_rows = [
row for row in rows
if row.case.expected_drug_id is not None
and row.case.subject_scope == SubjectScope.HUMAN
]
return {
"cases": len(rows),
"positive_cases": len(positive),
"negative_cases": len(negative),
"recall_at_1": _recall_at(positive, 1),
"recall_at_3": _recall_at(positive, 3),
"drug_resolution_accuracy": (
round(sum(
row.resolved_drug_id == row.case.expected_drug_id
for row in resolution_rows
) / len(resolution_rows), 4)
if resolution_rows else None
),
"drug_resolution_status_counts": {
status: sum(
row.drug_resolution_status == status for row in resolution_rows
)
for status in ("resolved", "ambiguous", "not_found", "invalid_state")
},
"negative_abstain_rate": (
round(sum(row.passed for row in negative) / len(negative), 4)
if negative else None
),
}
return {
"expert_release_gate": metrics([
row for row in outcomes if row.case.origin == CaseOrigin.EXPERT
]),
"manual_routing_diagnostic": metrics([
row for row in outcomes if row.case.origin == CaseOrigin.MANUAL_ADVERSARIAL
]),
"source_derived_diagnostic": metrics([
row for row in outcomes if row.case.origin == CaseOrigin.SOURCE_DERIVED
]),
}
def _recall_at(rows: list[EvaluationOutcome], limit: int) -> float | None:
if not rows:
return None
matched = sum(
row.case.expected_id in row.retrieved_ids[:limit]
for row in rows
)
return round(matched / len(rows), 4)
+83
View File
@@ -0,0 +1,83 @@
"""Checks a generated answer against the evidence it was built from.
The answer layer may only rephrase retrieved text. This module is what makes
that a checkable property rather than a promise in a prompt: it recomputes,
from the evidence alone, whether every number and every citation in a
generated answer can be traced back to the source. A generation that fails is
discarded, never shown.
Numbers are compared **character for character**, deliberately. "7,5" and
"7.5" are not treated as equal, and no attempt is made to parse either into a
quantity. Parsing invites the one error that matters most here: `1.500` is
1500 under one reading and 1.5 under another, and a normaliser that strips
separators maps "7,5" and "75" to the same key — a tenfold dose error scored
as a match. The model is told to copy figures verbatim, so an exact match is
achievable, and every deviation from it is refused rather than interpreted.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
# A digit run with internal separators kept: "500", "7,5", "1.000".
# Ranges ("4 - 6 giờ") yield two tokens, and each is checked on its own.
_NUMBER = re.compile(r"\d+(?:[.,]\d+)*")
# Citation markers are stripped before number extraction so that "[2]" is
# never mistaken for the quantity 2.
_CITATION = re.compile(r"\[(\d+)\]")
@dataclass(frozen=True)
class GroundingReport:
grounded: bool
unsupported_numbers: tuple[str, ...]
invalid_citations: tuple[int, ...]
cited_indices: tuple[int, ...]
@property
def reason(self) -> str:
if self.unsupported_numbers:
return "ungrounded_number"
if self.invalid_citations:
return "invalid_citation"
return "grounded"
def numbers_in(text: str) -> tuple[str, ...]:
"""Numeric tokens, with citation markers removed first."""
return tuple(_NUMBER.findall(_CITATION.sub(" ", text)))
def citations_in(text: str) -> tuple[int, ...]:
return tuple(int(marker) for marker in _CITATION.findall(text))
def verify(answer: str, evidence_texts: tuple[str, ...]) -> GroundingReport:
"""Whether `answer` states only figures and sources present in evidence.
`evidence_texts` is positional: citation `[n]` refers to
`evidence_texts[n - 1]`, so an out-of-range marker is a defect even when
the prose around it is faithful — a citation nobody can follow is not a
citation.
"""
source_numbers = set()
for text in evidence_texts:
source_numbers.update(numbers_in(text))
unsupported = tuple(
token for token in numbers_in(answer) if token not in source_numbers
)
invalid = tuple(
index
for index in citations_in(answer)
if not 1 <= index <= len(evidence_texts)
)
cited = tuple(sorted({index for index in citations_in(answer)} - set(invalid)))
return GroundingReport(
grounded=not unsupported and not invalid,
unsupported_numbers=unsupported,
invalid_citations=invalid,
cited_indices=cited,
)
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
import re
import unicodedata
from collections import Counter
from math import log
from .models import ParentDocument, RetrievalDocument, SearchHit
WORD_RE = re.compile(r"\w+", re.UNICODE)
def _normalized(text: str) -> str:
return " ".join(WORD_RE.findall(unicodedata.normalize("NFKC", text).casefold()))
def _terms(text: str) -> set[str]:
return set(_normalized(text).split())
def _char_ngrams(text: str, size: int = 3) -> set[str]:
normalized = _normalized(text)
if len(normalized) <= size:
return {normalized} if normalized else set()
return {
normalized[index:index + size]
for index in range(len(normalized) - size + 1)
}
class InMemoryLexicalRetriever:
"""Deterministic test/fallback retriever, not the production neural backend."""
def __init__(self, documents: list[RetrievalDocument]) -> None:
self._documents = tuple(documents)
self._term_counts = {
document.doc_id: Counter(_normalized(document.text).split())
for document in self._documents
}
self._average_length = (
sum(sum(counts.values()) for counts in self._term_counts.values())
/ max(1, len(self._term_counts))
)
document_frequency: Counter[str] = Counter()
for counts in self._term_counts.values():
document_frequency.update(counts.keys())
self._document_frequency = document_frequency
def search(self, query: str, drug_id: str, limit: int) -> list[SearchHit]:
query_terms = _terms(query)
query_ngrams = _char_ngrams(query)
candidates = []
for document in self._documents:
if document.drug_id != drug_id:
continue
counts = self._term_counts[document.doc_id]
bm25 = self._bm25(query_terms, counts)
ngrams = _char_ngrams(document.text)
char_score = len(query_ngrams & ngrams) / max(1, len(query_ngrams))
if bm25 > 0 or char_score > 0:
candidates.append((document, bm25, char_score))
max_bm25 = max((row[1] for row in candidates), default=0.0)
hits = [
SearchHit(
document=document,
score=0.8 * (bm25 / max_bm25 if max_bm25 else 0.0) + 0.2 * char_score,
)
for document, bm25, char_score in candidates
]
return sorted(hits, key=lambda hit: (-hit.score, hit.document.doc_id))[:limit]
def _bm25(self, query_terms: set[str], counts: Counter[str]) -> float:
total_documents = len(self._documents)
document_length = sum(counts.values())
score = 0.0
for term in query_terms:
frequency = counts.get(term, 0)
if not frequency:
continue
document_frequency = self._document_frequency[term]
inverse_frequency = log(
1 + (total_documents - document_frequency + 0.5)
/ (document_frequency + 0.5)
)
denominator = frequency + 1.5 * (
1 - 0.75 + 0.75 * document_length / max(1.0, self._average_length)
)
score += inverse_frequency * frequency * 2.5 / denominator
return score
class InMemoryParentStore:
def __init__(self, parents: list[ParentDocument]) -> None:
self._parents = {parent.parent_id: parent for parent in parents}
def get(self, parent_id: str) -> ParentDocument | None:
return self._parents.get(parent_id)
+56
View File
@@ -0,0 +1,56 @@
"""Domain counters, defined here so the numbers on a dashboard are the
numbers the domain actually decided.
Kept behind a tiny protocol rather than importing `prometheus_client` into
`rag/`: the domain records that a generation was refused for an ungrounded
number, and the process that happens to expose Prometheus does the exporting.
`NullMetrics` is the default, so tests and any deployment without a metrics
stack run unchanged.
The counter that matters is `generation_rejected` — it is the measured form of
the claim that the answer layer cannot state a figure the book does not.
"""
from __future__ import annotations
from typing import Protocol
class Metrics(Protocol):
def increment(self, name: str, **labels: str) -> None: ...
class NullMetrics:
def increment(self, name: str, **labels: str) -> None: # noqa: ARG002
# Deliberately inert: the default when no metrics stack is configured.
return None
class InMemoryMetrics:
"""Reference implementation of the contract; also what tests assert on."""
def __init__(self) -> None:
self.counts: dict[tuple[str, tuple[tuple[str, str], ...]], int] = {}
def increment(self, name: str, **labels: str) -> None:
key = (name, tuple(sorted(labels.items())))
self.counts[key] = self.counts.get(key, 0) + 1
def total(self, name: str, **labels: str) -> int:
if labels:
return self.counts.get((name, tuple(sorted(labels.items()))), 0)
return sum(count for (n, _), count in self.counts.items() if n == name)
RETRIEVAL_ROUTE = "duocthu_retrieval_route_total"
ABSTENTION = "duocthu_abstention_total"
GENERATION_REJECTED = "duocthu_generation_rejected_total"
GENERATION_SERVED = "duocthu_generation_served_total"
ANSWER_EXTRACTIVE = "duocthu_answer_extractive_total"
# Conversational loop. `CLARIFY_ASKED` is the counter that shows the system
# asking instead of guessing — the behaviour a reviewer will probe first.
CLARIFY_ASKED = "duocthu_clarify_asked_total"
LOOP_ROUNDS = "duocthu_loop_retrieval_rounds_total"
LOOP_REFINED = "duocthu_loop_refined_total"
LOOP_REPAIRED = "duocthu_loop_repaired_total"
FOLLOWUP_INHERITED = "duocthu_followup_inherited_total"
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
class EvidenceDecision(StrEnum):
ANSWERABLE = "answerable"
VERIFY_PDF = "verify_pdf"
ABSTAIN = "abstain"
class SubjectScope(StrEnum):
HUMAN = "human"
NON_HUMAN = "non_human"
UNKNOWN = "unknown"
class QueryIntent(StrEnum):
FACT_LOOKUP = "fact_lookup"
RECOMMENDATION = "recommendation"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class SourceRef:
physical_page: int
precision: str
block_id: str | None = None
bbox: tuple[float, float, float, float] | None = None
source_crop: str | None = None
page_range: tuple[int, int] | None = None
printed_page: int | None = None
printed_page_range: tuple[int, int] | None = None
@dataclass(frozen=True)
class RetrievalDocument:
doc_id: str
drug_id: str
kind: str
text: str
section_key: str
source_refs: tuple[SourceRef, ...]
parent_id: str | None = None
requires_visual_check: bool = False
drug_name: str | None = None
@dataclass(frozen=True)
class ParentDocument:
parent_id: str
kind: str
text: str
source_refs: tuple[SourceRef, ...]
requires_visual_check: bool = False
@dataclass(frozen=True)
class SearchHit:
document: RetrievalDocument
score: float
@dataclass(frozen=True)
class Evidence:
evidence_id: str
matched_doc_id: str
kind: str
text: str
score: float
source_refs: tuple[SourceRef, ...]
hydrated_from_parent: bool
requires_visual_check: bool
@dataclass(frozen=True)
class RetrievalResult:
decision: EvidenceDecision
reason: str
evidence: tuple[Evidence, ...] = field(default_factory=tuple)
resolved_drug_id: str | None = None
drug_resolution_status: str = "not_attempted"
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from typing import Protocol
from .models import ParentDocument, SearchHit
class QueryEmbeddingUnavailable(RuntimeError):
"""The similarity route's embedding provider could not be reached.
Raised by an adapter and caught by the domain, which abstains. It exists so
a provider outage refuses to answer instead of returning a 500: an
unreachable embedder means the question was never actually searched, and an
error page hides that from the caller just as effectively as a wrong answer
would. The domain catches this without importing any SDK.
"""
class AnswerGenerationUnavailable(RuntimeError):
"""The answer generator could not be reached.
Same contract as `QueryEmbeddingUnavailable`: the adapter translates its
SDK's failure into this, and the domain degrades to the extractive answer
rather than returning an error. Generation is a presentation improvement
over quoting the source; losing it must never lose the answer.
"""
class AnswerGenerator(Protocol):
"""Rewrites retrieved evidence into prose. Never a source of facts.
Whatever it returns is checked by `rag.grounding.verify` before a caller
sees it, so this port carries no trust: an implementation that fabricates
a dose produces a discarded generation, not a wrong answer.
"""
def generate(self, system: str, user: str, schema: dict) -> str: ...
class Retriever(Protocol):
def search(self, query: str, drug_id: str, limit: int) -> list[SearchHit]: ...
class SectionRetriever(Protocol):
"""Exact retrieval of one whole section, with no similarity involved.
Separate from `Retriever` so a store that cannot filter by payload is still
a valid `Retriever` (interface segregation). `find_by_section` must return
**every** part of the section: a partial contraindication list reads as a
complete one, which is worse than returning nothing.
"""
def find_by_section(self, drug_id: str, section_key: str) -> list[SearchHit]: ...
class ParentStore(Protocol):
def get(self, parent_id: str) -> ParentDocument | None: ...
+77
View File
@@ -0,0 +1,77 @@
"""The answer contract given to the generator, and the schema it must fill.
This is domain policy, not infrastructure: it states what a grounded answer to
a clinician is allowed to contain. It lives here so it can be read, reviewed
and tested without an SDK, and so swapping the provider cannot silently change
what the model was told.
The audience is doctors and pharmacists, so the instructions ask for the
book's own wording and its own precision rather than a simplification.
"""
from __future__ import annotations
from dataclasses import dataclass
SYSTEM_PROMPT = """\
Bạn trình bày lại nội dung Dược thư Quốc gia Việt Nam cho bác sĩ và dược sĩ.
Bạn KHÔNG phải nguồn tri thức. Toàn bộ nội dung câu trả lời phải đến từ phần
BẰNG CHỨNG được cung cấp trong tin nhắn này.
Quy tắc bắt buộc:
1. Chỉ dùng thông tin có trong BẰNG CHỨNG. Không thêm kiến thức y khoa từ
bên ngoài, kể cả khi bạn chắc chắn nó đúng.
2. Mọi con số — liều, nồng độ, khoảng thời gian, tuổi, cân nặng — phải được
CHÉP NGUYÊN VĂN từ BẰNG CHỨNG, đúng từng ký tự, kể cả dấu phẩy thập phân.
Không làm tròn, không đổi đơn vị, không quy đổi.
3. Mỗi ý phải gắn số nguồn dạng [n], với n là số thứ tự đoạn bằng chứng.
4. Nếu BẰNG CHỨNG không đủ để trả lời, nói rõ là không đủ. Đó là câu trả lời
hợp lệ, không phải thất bại.
5. Giữ nguyên thuật ngữ chuyên môn của sách. Không diễn giải cho người
không chuyên.
Viết gọn. Trả lời đúng điều được hỏi, không mở rộng phạm vi."""
ANSWER_SCHEMA = {
"type": "object",
"properties": {
"answer": {
"type": "string",
"description": (
"Câu trả lời cho bác sĩ/dược sĩ, mỗi ý gắn [n] chỉ nguồn. "
"Mọi con số chép nguyên văn từ bằng chứng."
),
},
"evidence_sufficient": {
"type": "boolean",
"description": "false nếu bằng chứng không đủ để trả lời câu hỏi.",
},
},
"required": ["answer", "evidence_sufficient"],
"additionalProperties": False,
}
@dataclass(frozen=True)
class GenerationRequest:
system: str
user: str
schema: dict
def build_request(question: str, evidence_texts: tuple[str, ...]) -> GenerationRequest:
"""The prompt for one question over one ordered evidence list.
Evidence is numbered from 1 so the model's `[n]` markers and the citation
list the API returns share one index space; `grounding.verify` rejects any
marker outside it.
"""
if not evidence_texts:
raise ValueError("cannot build a grounded prompt with no evidence")
blocks = "\n\n".join(
f"[{index}] {text}" for index, text in enumerate(evidence_texts, start=1)
)
user = f"BẰNG CHỨNG:\n\n{blocks}\n\nCÂU HỎI: {question}"
return GenerationRequest(system=SYSTEM_PROMPT, user=user, schema=ANSWER_SCHEMA)
+305
View File
@@ -0,0 +1,305 @@
"""The bounded reasoning loop.
Understand → plan → retrieve → assess → refine → generate → verify → repair.
Every edge is bounded, and every budget is decremented **before** the call it
pays for, so exhaustion degrades to the best answer so far rather than to an
error.
Two rules hold across every path and are the reason this can be added to a
formulary at all:
- `grounding.verify` still gates every generated answer. Reasoning chooses what
to look up and how to phrase it; it is never a source of facts.
- A clarify signal bypasses the loop entirely. Asking beats guessing, and the
signals are resolver states — ambiguous drug, unresolved attribute — not a
model's confidence score.
"""
from __future__ import annotations
from dataclasses import dataclass, field, replace
from typing import Protocol
from . import metrics as metric_names
from .conversation import ConversationState, ResolvedQuestion
from .metrics import Metrics, NullMetrics
MAX_RETRIEVAL_ROUNDS = 2
MAX_REPAIRS = 1
MAX_LLM_CALLS = 4
MAX_WALL_CLOCK_MS = 20_000
class BudgetExhausted(RuntimeError):
"""Raised only inside the loop, never surfaced; the loop catches it."""
@dataclass
class TurnBudget:
"""Mutable on purpose: one budget is threaded through one turn."""
llm_calls: int = MAX_LLM_CALLS
retrieval_rounds: int = MAX_RETRIEVAL_ROUNDS
repairs: int = MAX_REPAIRS
wall_clock_ms: int = MAX_WALL_CLOCK_MS
elapsed_ms: int = 0
def spend_llm(self) -> None:
if self.llm_calls <= 0:
raise BudgetExhausted("llm_calls")
self.llm_calls -= 1
def spend_retrieval(self) -> None:
if self.retrieval_rounds <= 0:
raise BudgetExhausted("retrieval_rounds")
self.retrieval_rounds -= 1
def spend_repair(self) -> None:
if self.repairs <= 0:
raise BudgetExhausted("repairs")
self.repairs -= 1
def out_of_time(self) -> bool:
return self.elapsed_ms >= self.wall_clock_ms
class ClarifyReason:
AMBIGUOUS_DRUG = "ambiguous_drug"
NO_ATTRIBUTE = "no_attribute"
MULTI_ATTRIBUTE = "multi_attribute"
STILL_INSUFFICIENT = "still_insufficient"
@dataclass(frozen=True)
class Clarification:
reason: str
question: str
options: tuple[str, ...] = ()
@dataclass(frozen=True)
class Sufficiency:
"""The assessor's verdict on retrieved evidence.
`missing` must name something specific — a section, a population, a second
drug. "Feels incomplete" does not buy a retrieval round; a round is only
spent when there is a concrete thing to go and fetch.
"""
sufficient: bool
missing: str | None = None
refined_query: str | None = None
class SufficiencyAssessor(Protocol):
def assess(
self, resolved: ResolvedQuestion, evidence_texts: tuple[str, ...]
) -> Sufficiency: ...
class DeterministicAssessor:
"""The no-LLM default, and the reference for what the port must do.
Runs offline and is what the loop uses until a provider is enabled. It only
reports insufficiency it can *demonstrate* — a population was asked for and
no retrieved text mentions it — so it can never spin the loop on a feeling.
"""
POPULATION_TERMS = {
"nguoi_lon": ("người lớn",),
"tre_em": ("trẻ em", "trẻ nhỏ", "trẻ "),
"tre_so_sinh": ("sơ sinh",),
"phu_nu_co_thai": ("thai", "mang thai"),
"phu_nu_cho_con_bu": ("cho con bú", "sữa mẹ"),
"nguoi_cao_tuoi": ("người cao tuổi", "người già"),
"suy_than": ("suy thận", "clcr"),
"suy_gan": ("suy gan",),
}
def assess(
self, resolved: ResolvedQuestion, evidence_texts: tuple[str, ...]
) -> Sufficiency:
if not evidence_texts:
return Sufficiency(False, missing="no_evidence")
if resolved.population is None:
return Sufficiency(True)
terms = self.POPULATION_TERMS.get(resolved.population, ())
haystack = " ".join(evidence_texts).casefold()
if any(term in haystack for term in terms):
return Sufficiency(True)
return Sufficiency(
False,
missing=f"population:{resolved.population}",
refined_query=f"{resolved.text} {terms[0] if terms else ''}".strip(),
)
@dataclass(frozen=True)
class LoopOutcome:
"""What one turn produced, plus what it cost."""
answer: str | None
clarification: Clarification | None
evidence_texts: tuple[str, ...]
retrieval_rounds_used: int
repairs_used: int
stopped_because: str
generated: bool = False
@dataclass
class LoopTrace:
"""Ordered record of stages, for the dashboard and for debugging."""
stages: list[str] = field(default_factory=list)
def enter(self, stage: str) -> None:
self.stages.append(stage)
def clarify_for(
reason: str, options: tuple[str, ...] = ()
) -> Clarification:
questions = {
ClarifyReason.NO_ATTRIBUTE: (
"Anh/chị muốn tra thuộc tính nào của thuốc này?"
),
ClarifyReason.AMBIGUOUS_DRUG: (
"Câu hỏi có thể ứng với nhiều thuốc. Anh/chị muốn tra thuốc nào?"
),
ClarifyReason.MULTI_ATTRIBUTE: (
"Câu hỏi nhắc tới nhiều mục. Anh/chị muốn xem mục nào trước?"
),
ClarifyReason.STILL_INSUFFICIENT: (
"Chưa tìm đủ căn cứ trong Dược thư cho ý này. "
"Anh/chị có thể nêu rõ hơn điều cần tra không?"
),
}
return Clarification(reason, questions[reason], options)
class Retrieve(Protocol):
def __call__(self, resolved: ResolvedQuestion) -> tuple[str, ...]: ...
class Generate(Protocol):
def __call__(
self, resolved: ResolvedQuestion, evidence: tuple[str, ...], state: ConversationState
) -> str | None: ...
def run_turn(
state: ConversationState,
resolved: ResolvedQuestion,
retrieve: Retrieve,
generate: Generate,
clarify_signals: tuple[str, ...] = (),
assessor: SufficiencyAssessor | None = None,
budget: TurnBudget | None = None,
metrics: Metrics | None = None,
trace: LoopTrace | None = None,
) -> LoopOutcome:
"""One conversational turn through the bounded loop.
`clarify_signals` comes from the existing resolvers — ambiguous drug,
unresolved section, multi-attribute. They short-circuit before any spend,
because a question worth asking is cheaper and safer than a guess.
"""
budget = budget or TurnBudget()
assessor = assessor or DeterministicAssessor()
metrics = metrics or NullMetrics()
trace = trace or LoopTrace()
trace.enter("understand")
if clarify_signals:
reason = clarify_signals[0]
metrics.increment(metric_names.CLARIFY_ASKED, reason=reason)
trace.enter("clarify")
return LoopOutcome(
answer=None,
clarification=clarify_for(reason),
evidence_texts=(),
retrieval_rounds_used=0,
repairs_used=0,
stopped_because="clarify_signal",
)
evidence: tuple[str, ...] = ()
rounds_used = 0
stopped = "sufficient"
while True:
try:
budget.spend_retrieval()
except BudgetExhausted:
stopped = "retrieval_budget"
break
trace.enter("retrieve")
evidence = retrieve(resolved)
rounds_used += 1
trace.enter("assess")
verdict = assessor.assess(resolved, evidence)
if verdict.sufficient:
break
if budget.retrieval_rounds <= 0 or budget.out_of_time():
stopped = "retrieval_budget"
break
# A round is spent only on a named gap with a genuinely new query.
if not verdict.missing or not verdict.refined_query:
stopped = "no_actionable_gap"
break
if verdict.refined_query == resolved.text:
stopped = "query_unchanged"
break
trace.enter("refine")
metrics.increment(metric_names.LOOP_REFINED, missing=verdict.missing)
resolved = replace(resolved, text=verdict.refined_query)
metrics.increment(metric_names.LOOP_ROUNDS, rounds=str(rounds_used))
if not evidence:
trace.enter("clarify")
metrics.increment(
metric_names.CLARIFY_ASKED, reason=ClarifyReason.STILL_INSUFFICIENT
)
return LoopOutcome(
answer=None,
clarification=clarify_for(ClarifyReason.STILL_INSUFFICIENT),
evidence_texts=(),
retrieval_rounds_used=rounds_used,
repairs_used=0,
stopped_because="no_evidence",
)
repairs_used = 0
answer: str | None = None
while True:
trace.enter("generate")
try:
budget.spend_llm()
except BudgetExhausted:
stopped = "llm_budget"
break
answer = generate(resolved, evidence, state)
if answer is not None:
break
# `generate` returning None means verification already refused it.
try:
budget.spend_repair()
except BudgetExhausted:
stopped = "repair_budget"
break
repairs_used += 1
trace.enter("repair")
metrics.increment(metric_names.LOOP_REPAIRED)
return LoopOutcome(
answer=answer,
clarification=None,
evidence_texts=evidence,
retrieval_rounds_used=rounds_used,
repairs_used=repairs_used,
stopped_because=stopped,
generated=answer is not None,
)
+260
View File
@@ -0,0 +1,260 @@
from __future__ import annotations
import re
from dataclasses import dataclass, replace
from difflib import SequenceMatcher
from enum import StrEnum
from .models import EvidenceDecision, QueryIntent, RetrievalResult, SubjectScope
from .service import RetrievalService
from .text import WORD_RE, normalize_name
__all__ = [
"WORD_RE",
"CatalogDrugResolver",
"DrugResolution",
"DrugResolutionStatus",
"QueryRoutingService",
"normalize_name",
]
class DrugResolutionStatus(StrEnum):
RESOLVED = "resolved"
NOT_FOUND = "not_found"
AMBIGUOUS = "ambiguous"
@dataclass(frozen=True)
class DrugResolution:
status: DrugResolutionStatus
drug_id: str | None = None
score: float | None = None
candidate_drug_ids: tuple[str, ...] = ()
class CatalogDrugResolver:
def __init__(
self,
catalog: dict[str, set[str]],
fuzzy_threshold: float = 0.84,
ambiguity_margin: float = 0.04,
) -> None:
self._catalog = {
drug_id: {
normalized for alias in aliases if (normalized := normalize_name(alias))
}
for drug_id, aliases in catalog.items()
}
self._aliases = [
(drug_id, normalized)
for drug_id, aliases in catalog.items()
for alias in aliases
if (normalized := normalize_name(alias))
]
self._fuzzy_threshold = fuzzy_threshold
self._ambiguity_margin = ambiguity_margin
def resolve(self, query: str) -> DrugResolution:
normalized_query = normalize_name(query)
query_tokens = normalized_query.split()
exact = [
(drug_id, alias, match.start(1), match.end(1))
for drug_id, alias in self._aliases
for match in [
re.search(rf"(?:^| )({re.escape(alias)})(?:$| )", normalized_query)
]
if match
]
if exact:
maximal = [
row for row in exact
if not any(
other[2] <= row[2] and row[3] <= other[3]
and (other[2], other[3]) != (row[2], row[3])
for other in exact
)
]
drug_ids = {drug_id for drug_id, _, _, _ in maximal}
if len(drug_ids) == 1:
return DrugResolution(
DrugResolutionStatus.RESOLVED, next(iter(drug_ids)), 1.0,
)
return DrugResolution(
DrugResolutionStatus.AMBIGUOUS,
candidate_drug_ids=tuple(sorted(drug_ids)),
)
scores: dict[str, float] = {}
for drug_id, alias in self._aliases:
width = len(alias.split())
if width > len(query_tokens):
continue
spans = (
" ".join(query_tokens[start:start + width])
for start in range(len(query_tokens) - width + 1)
)
score = max(
(SequenceMatcher(None, alias, span).ratio() for span in spans),
default=0.0,
)
scores[drug_id] = max(scores.get(drug_id, 0.0), score)
ranked = sorted(scores.items(), key=lambda item: (-item[1], item[0]))
if not ranked or ranked[0][1] < self._fuzzy_threshold:
return DrugResolution(DrugResolutionStatus.NOT_FOUND)
if len(ranked) > 1 and ranked[0][1] - ranked[1][1] < self._ambiguity_margin:
return DrugResolution(
DrugResolutionStatus.AMBIGUOUS,
candidate_drug_ids=(ranked[0][0], ranked[1][0]),
)
return DrugResolution(DrugResolutionStatus.RESOLVED, *ranked[0])
def aliases_for(self, drug_id: str) -> set[str]:
return self._catalog.get(drug_id, set())
def complete(self, prefix: str, k: int = 8) -> list[str]:
"""Drug ids whose alias contains `prefix`, for as-you-type autocomplete.
Substring match on the normalized alias, ranked prefix-first then by
alias length, so "para" surfaces "paracetamol" ahead of a drug that only
contains "para" mid-word. Distinct drug ids, best first.
"""
needle = normalize_name(prefix)
if not needle:
return []
matches: list[tuple[tuple[int, int], str]] = []
for drug_id, alias in self._aliases:
position = alias.find(needle)
if position < 0:
continue
matches.append(((0 if position == 0 else 1, len(alias)), drug_id))
matches.sort()
ordered: list[str] = []
seen: set[str] = set()
for _, drug_id in matches:
if drug_id in seen:
continue
seen.add(drug_id)
ordered.append(drug_id)
if len(ordered) >= k:
break
return ordered
def suggest(
self, query: str, k: int = 3, min_score: float = 0.5
) -> list[tuple[str, float]]:
"""Closest drug ids by fuzzy score, for a 'did you mean' on a miss.
Uses the same windowed SequenceMatcher scoring as `resolve`, but returns
the top-k *below* the resolution threshold too, so a typo that does not
confidently resolve ("metfomin") can still be offered as a suggestion.
`min_score` keeps a genuinely non-drug query ("cái này thế nào") from
surfacing spurious suggestions.
"""
query_tokens = normalize_name(query).split()
if not query_tokens:
return []
scores: dict[str, float] = {}
for drug_id, alias in self._aliases:
width = len(alias.split())
if width > len(query_tokens):
continue
spans = (
" ".join(query_tokens[start:start + width])
for start in range(len(query_tokens) - width + 1)
)
score = max(
(SequenceMatcher(None, alias, span).ratio() for span in spans),
default=0.0,
)
scores[drug_id] = max(scores.get(drug_id, 0.0), score)
ranked = sorted(scores.items(), key=lambda item: (-item[1], item[0]))
return [(drug_id, score) for drug_id, score in ranked[:k] if score >= min_score]
class QueryRoutingService:
def __init__(
self,
retrieval: RetrievalService,
resolver: CatalogDrugResolver,
) -> None:
self._retrieval = retrieval
self._resolver = resolver
def retrieve(
self,
query: str,
subject_scope: SubjectScope = SubjectScope.UNKNOWN,
intent: QueryIntent = QueryIntent.UNKNOWN,
) -> RetrievalResult:
# Scope comes from the API/policy layer. Unknown is deliberately
# fail-closed; retrieval must not infer clinical scope from keywords.
if subject_scope == SubjectScope.NON_HUMAN:
return RetrievalResult(EvidenceDecision.ABSTAIN, "out_of_scope_non_human")
if subject_scope == SubjectScope.UNKNOWN:
return RetrievalResult(EvidenceDecision.ABSTAIN, "subject_scope_unknown")
if intent == QueryIntent.RECOMMENDATION:
return RetrievalResult(EvidenceDecision.ABSTAIN, "recommendation_out_of_scope")
if intent == QueryIntent.UNKNOWN:
return RetrievalResult(EvidenceDecision.ABSTAIN, "query_intent_unknown")
resolution = self._resolver.resolve(query)
if resolution.status == DrugResolutionStatus.NOT_FOUND:
return RetrievalResult(
EvidenceDecision.ABSTAIN,
"drug_not_resolved",
drug_resolution_status=resolution.status,
)
if resolution.status == DrugResolutionStatus.AMBIGUOUS:
disambiguated = self._disambiguate_with_evidence(
query, resolution.candidate_drug_ids,
)
if disambiguated is not None:
drug_id, result = disambiguated
return replace(
result,
resolved_drug_id=drug_id,
drug_resolution_status=DrugResolutionStatus.RESOLVED,
)
return RetrievalResult(
EvidenceDecision.ABSTAIN, "drug_resolution_ambiguous",
drug_resolution_status=resolution.status,
)
if resolution.drug_id is None:
return RetrievalResult(
EvidenceDecision.ABSTAIN,
"drug_resolution_invalid_state",
drug_resolution_status="invalid_state",
)
result = self._retrieval.retrieve(query, resolution.drug_id)
return replace(
result,
resolved_drug_id=resolution.drug_id,
drug_resolution_status=resolution.status,
)
def _disambiguate_with_evidence(
self,
query: str,
candidate_ids: tuple[str, ...],
) -> tuple[str, RetrievalResult] | None:
"""Resolve subject-vs-component ambiguity through asymmetric evidence.
A candidate wins only when its retrieved evidence explicitly contains
every other mentioned entity, while the reverse direction does not.
This keeps genuine multi-drug questions ambiguous.
"""
if len(candidate_ids) < 2:
return None
winners = []
for candidate_id in candidate_ids:
result = self._retrieval.retrieve(query, candidate_id)
if result.decision == EvidenceDecision.ABSTAIN:
continue
evidence_text = normalize_name(" ".join(item.text for item in result.evidence))
others = [item for item in candidate_ids if item != candidate_id]
if all(any(
re.search(rf"(?:^| ){re.escape(alias)}(?:$| )", evidence_text)
for alias in self._resolver.aliases_for(other)
) for other in others):
winners.append((candidate_id, result))
return winners[0] if len(winners) == 1 else None
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
from .artifacts import build_drug_catalog, load_aliases, load_documents, load_parents
from .evaluation import CaseOrigin, EvaluationCase, EvaluationOutcome, summarize
from .in_memory import InMemoryLexicalRetriever, InMemoryParentStore
from .models import EvidenceDecision, QueryIntent, SubjectScope
from .routing import CatalogDrugResolver, QueryRoutingService
from .service import EvidencePolicy, RetrievalService
def read_cases(path: Path) -> list[EvaluationCase]:
with path.open(encoding="utf-8") as handle:
return [
EvaluationCase(
case_id=raw["case_id"],
query=raw["query"],
expected_drug_id=raw.get("expected_drug_id"),
expected_id=raw.get("expected_id"),
origin=CaseOrigin(raw["origin"]),
subject_scope=SubjectScope(raw.get("subject_scope", "human")),
)
for line in handle
if line.strip()
for raw in [json.loads(line)]
]
def run(
cases_path: Path,
documents_path: Path,
parents_path: Path,
aliases_path: Path | None = None,
) -> dict:
documents = load_documents(documents_path)
retrieval = RetrievalService(
InMemoryLexicalRetriever(documents),
InMemoryParentStore(load_parents(parents_path)),
EvidencePolicy(),
)
service = QueryRoutingService(
retrieval,
CatalogDrugResolver(build_drug_catalog(documents, load_aliases(aliases_path))),
)
outcomes = []
details = []
for case in read_cases(cases_path):
result = service.retrieve(
case.query,
case.subject_scope,
QueryIntent.FACT_LOOKUP,
)
retrieved = (
tuple(item.evidence_id for item in result.evidence)
if result.decision != EvidenceDecision.ABSTAIN
else ()
)
outcome = EvaluationOutcome(
case=case,
retrieved_ids=retrieved,
resolved_drug_id=result.resolved_drug_id,
drug_resolution_status=result.drug_resolution_status,
)
outcomes.append(outcome)
details.append({
"case_id": case.case_id,
"passed": outcome.passed,
"decision": result.decision,
"reason": result.reason,
"expected_id": case.expected_id,
"retrieved_ids": retrieved,
"expected_drug_id": case.expected_drug_id,
"resolved_drug_id": result.resolved_drug_id,
"drug_resolution_status": result.drug_resolution_status,
})
return {**summarize(outcomes), "details": details}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--cases", type=Path, required=True)
parser.add_argument("--documents", type=Path, required=True)
parser.add_argument("--parents", type=Path, required=True)
parser.add_argument("--aliases", type=Path)
args = parser.parse_args()
print(json.dumps(
run(args.cases, args.documents, args.parents, args.aliases),
ensure_ascii=False,
indent=2,
))
if __name__ == "__main__":
main()
+210
View File
@@ -0,0 +1,210 @@
"""Resolve an attribute question to the monograph section that answers it.
Measured 2026-08-04: letting vector similarity choose the section gives
hit@1 0.544 overall and **0.05 on `chong_chi_dinh`**, because
`duoc_ly_va_co_che_tac_dung` is the largest section and describes the drug in
general terms, so it sits close to almost any question about that drug. A
question that names its own attribute does not need similarity to guess.
Two rules make this safe:
**Longest phrase wins.** "chống chỉ định" and "chỉ định" differ by one prefix
word and mean opposite things clinically. Ordering by phrase length means the
contraindication phrase is tested first and the indication phrase can never
capture it. The same rule keeps "quá liều" from being read as "liều" and
"hướng dẫn xử trí ADR" from being read as "tác dụng phụ".
**No match is not a guess.** An unrecognised question returns `None` and the
caller falls back to similarity search. This layer never picks a section it is
not sure of.
Adding a section or a phrasing means adding an entry to `SECTION_PHRASES` —
never editing the matching code (CLAUDE.md, open/closed).
"""
from __future__ import annotations
from dataclasses import dataclass
from .text import normalize_name
# Phrases a clinician would actually type. Order within a list does not matter;
# the resolver sorts every phrase by length across all sections.
SECTION_PHRASES: dict[str, tuple[str, ...]] = {
"chong_chi_dinh": (
"chống chỉ định",
"không được dùng cho",
"không được dùng khi",
"cấm dùng",
),
"chi_dinh": (
"chỉ định",
"dùng để điều trị",
"dùng trong trường hợp nào",
"điều trị bệnh gì",
"dùng khi nào",
),
"lieu_luong_va_cach_dung": (
"liều lượng và cách dùng",
"liều lượng",
"liều dùng",
"cách dùng",
"dùng liều",
"uống bao nhiêu",
"tiêm bao nhiêu",
# Bare "liều" is safe only because longer phrases are tested first:
# "quá liều" and "xử trí quá liều" both contain it and both win.
# Measured need: 4 of 16 human-written golden questions say just
# "Liều Metformin cho người lớn?".
"liều",
),
"than_trong": (
"thận trọng",
"cần lưu ý gì",
"lưu ý khi dùng",
),
"tac_dung_khong_mong_muon": (
"tác dụng không mong muốn",
"tác dụng phụ",
"phản ứng có hại",
"tác dụng ngoại ý",
),
"huong_dan_xu_tri_adr": (
"hướng dẫn xử trí adr",
"xử trí tác dụng phụ",
"xử trí phản ứng có hại",
"xử trí adr",
),
"qua_lieu_va_xu_tri": (
"quá liều và xử trí",
"xử trí quá liều",
"quá liều",
"ngộ độc",
),
"tuong_tac_thuoc": (
"tương tác thuốc",
"tương tác với",
"tương tác",
),
"tuong_ky": (
"tương kỵ",
),
"thoi_ky_mang_thai": (
"thời kỳ mang thai",
"phụ nữ có thai",
"phụ nữ mang thai",
"mang thai",
"có thai",
"thai kỳ",
# Colloquial, and clinicians type it: one golden question asks
# "Bà bầu dùng Ibuprofen được không?".
"bà bầu",
"phụ nữ mang bầu",
),
"thoi_ky_cho_con_bu": (
"thời kỳ cho con bú",
"phụ nữ cho con bú",
"cho con bú",
"đang cho bú",
"thời kỳ bú mẹ",
),
"duoc_ly_va_co_che_tac_dung": (
"dược lý và cơ chế tác dụng",
"cơ chế tác dụng",
"dược lý",
"cơ chế",
),
"dang_thuoc_va_ham_luong": (
"dạng thuốc và hàm lượng",
"dạng bào chế",
"dạng thuốc",
"hàm lượng",
),
"do_on_dinh_va_bao_quan": (
"độ ổn định và bảo quản",
"độ ổn định",
"bảo quản",
),
"ten_chung_quoc_te": (
"tên chung quốc tế",
"tên quốc tế",
),
"ten_thuong_mai": (
"tên thương mại",
"biệt dược",
),
"loai_thuoc": (
"loại thuốc",
"nhóm thuốc",
"thuộc nhóm",
),
"ma_atc": (
"mã atc",
),
"thong_tin_quy_che": (
"thông tin quy chế",
"quy chế",
),
}
# Book order of monograph sections (Hướng dẫn sử dụng, printed page 39). Used to
# present a whole-drug overview when the query names the drug but no attribute —
# typing "PARACETAMOL" should return the monograph, never a "specify an
# attribute" dead-end.
SECTION_ORDER: tuple[str, ...] = (
"ten_chung_quoc_te",
"ma_atc",
"loai_thuoc",
"dang_thuoc_va_ham_luong",
"duoc_ly_va_co_che_tac_dung",
"chi_dinh",
"chong_chi_dinh",
"than_trong",
"thoi_ky_mang_thai",
"thoi_ky_cho_con_bu",
"tac_dung_khong_mong_muon",
"huong_dan_xu_tri_adr",
"lieu_luong_va_cach_dung",
"tuong_tac_thuoc",
"do_on_dinh_va_bao_quan",
"tuong_ky",
"qua_lieu_va_xu_tri",
"thong_tin_quy_che",
)
@dataclass(frozen=True)
class SectionMatch:
section_key: str
phrase: str
def _index(phrases: dict[str, tuple[str, ...]]) -> tuple[tuple[str, str, str], ...]:
"""(normalized_phrase, section_key, original_phrase), longest first."""
rows = [
(normalized, section_key, phrase)
for section_key, section_phrases in phrases.items()
for phrase in section_phrases
if (normalized := normalize_name(phrase))
]
# Length first so a superstring is always tested before its substring;
# the phrase text breaks ties so the order is deterministic.
rows.sort(key=lambda row: (-len(row[0]), row[0]))
return tuple(rows)
class SectionResolver:
"""Maps a question to a `section_key`, or to nothing at all."""
def __init__(self, phrases: dict[str, tuple[str, ...]] | None = None) -> None:
self._index = _index(phrases if phrases is not None else SECTION_PHRASES)
def resolve(self, query: str) -> SectionMatch | None:
normalized_query = normalize_name(query)
if not normalized_query:
return None
padded = f" {normalized_query} "
for normalized_phrase, section_key, phrase in self._index:
if f" {normalized_phrase} " in padded:
return SectionMatch(section_key, phrase)
return None
+149
View File
@@ -0,0 +1,149 @@
from __future__ import annotations
from dataclasses import dataclass
from .models import Evidence, EvidenceDecision, RetrievalResult, SearchHit
from .ports import ParentStore, QueryEmbeddingUnavailable, Retriever
from .sections import SectionResolver
@dataclass(frozen=True)
class EvidencePolicy:
minimum_score: float = 0.12
candidate_limit: int = 5
evidence_limit: int = 3
class RetrievalService:
"""Section-filtered retrieval when the question names its attribute.
Similarity is the fallback, not the default. Measured 2026-08-04, letting
similarity choose the section answers "chống chỉ định" correctly 1 time in
20, because the largest section (`duoc_ly_va_co_che_tac_dung`) sits close
to any question about the drug. When the question says which section it
wants, filtering answers it exactly.
"""
def __init__(
self,
retriever: Retriever,
parent_store: ParentStore,
policy: EvidencePolicy | None = None,
section_resolver: SectionResolver | None = None,
) -> None:
self._retriever = retriever
self._parent_store = parent_store
self._policy = policy or EvidencePolicy()
self._section_resolver = section_resolver
def retrieve(self, query: str, drug_id: str) -> RetrievalResult:
if not query.strip() or not drug_id.strip():
return RetrievalResult(EvidenceDecision.ABSTAIN, "missing_query_or_drug")
section_hits = self._section_hits(query, drug_id)
if section_hits is not None:
# No `evidence_limit` here: the whole section is the answer, and a
# truncated list of contraindications reads as a complete one.
return self._decide(self._hydrate(section_hits, limit=None))
# Drug resolved but no attribute named ("PARACETAMOL"): show the whole
# monograph, in book order, rather than dead-ending on "specify an
# attribute". A drug reference answers a drug name with the drug.
overview_hits = self._drug_overview(drug_id)
if overview_hits is not None:
return self._decide(self._hydrate(overview_hits, limit=None))
try:
hits = self._retriever.search(
query=query,
drug_id=drug_id,
limit=self._policy.candidate_limit,
)
except QueryEmbeddingUnavailable:
# Fail closed. The section route needs no embedder, so this only
# ever narrows the fallback: the caller is told nothing was found
# rather than being shown an error page or, worse, an answer built
# from a search that never ran.
return RetrievalResult(
EvidenceDecision.ABSTAIN, "query_embedding_unavailable"
)
if not hits or hits[0].score < self._policy.minimum_score:
return RetrievalResult(EvidenceDecision.ABSTAIN, "insufficient_retrieval_score")
return self._decide(self._hydrate(hits))
def _drug_overview(self, drug_id: str) -> list[SearchHit] | None:
"""Every prose section of the drug, or None if the store cannot scroll."""
find_by_drug = getattr(self._retriever, "find_by_drug", None)
if find_by_drug is None:
return None
hits = find_by_drug(drug_id)
return hits or None
def _section_hits(self, query: str, drug_id: str) -> list[SearchHit] | None:
"""Hits for an explicitly named section, or None to fall back.
Returns None — not an empty list — when this route does not apply, so
"no section named" stays distinguishable from "section named but empty".
"""
if self._section_resolver is None:
return None
find_by_section = getattr(self._retriever, "find_by_section", None)
if find_by_section is None:
return None
match = self._section_resolver.resolve(query)
if match is None:
return None
hits = find_by_section(drug_id, match.section_key)
return hits or None
def _decide(self, evidence: tuple[Evidence, ...]) -> RetrievalResult:
if not evidence:
return RetrievalResult(EvidenceDecision.ABSTAIN, "parent_hydration_failed")
if any(not item.source_refs for item in evidence):
return RetrievalResult(EvidenceDecision.ABSTAIN, "missing_provenance")
if any(item.requires_visual_check for item in evidence):
return RetrievalResult(EvidenceDecision.VERIFY_PDF, "visual_verification_required", evidence)
return RetrievalResult(EvidenceDecision.ANSWERABLE, "grounded_evidence_available", evidence)
def _hydrate(
self, hits: list[SearchHit], limit: int | None = -1
) -> tuple[Evidence, ...]:
output: list[Evidence] = []
seen: set[str] = set()
for hit in hits:
document = hit.document
evidence_id = document.parent_id or document.doc_id
if evidence_id in seen:
continue
seen.add(evidence_id)
if document.parent_id:
parent = self._parent_store.get(document.parent_id)
if parent is None:
continue
output.append(Evidence(
evidence_id=parent.parent_id,
matched_doc_id=document.doc_id,
kind=parent.kind,
text=parent.text,
score=hit.score,
source_refs=parent.source_refs,
hydrated_from_parent=True,
requires_visual_check=(
document.requires_visual_check or parent.requires_visual_check
),
))
else:
output.append(Evidence(
evidence_id=document.doc_id,
matched_doc_id=document.doc_id,
kind=document.kind,
text=document.text,
score=hit.score,
source_refs=document.source_refs,
hydrated_from_parent=False,
requires_visual_check=document.requires_visual_check,
))
cap = self._policy.evidence_limit if limit == -1 else limit
if cap is not None and len(output) >= cap:
break
return tuple(output)
+23
View File
@@ -0,0 +1,23 @@
"""Vietnamese text normalisation shared by drug and section resolution.
Lives here rather than in `routing.py` because `sections.py` needs it too, and
importing it from `routing` made `service -> sections -> routing -> service` a
cycle. It is a text utility with no knowledge of drugs or sections.
"""
from __future__ import annotations
import re
import unicodedata
WORD_RE = re.compile(r"\w+", re.UNICODE)
def normalize_name(text: str) -> str:
"""Casefold, strip diacritics, collapse to space-separated word tokens.
`đ` is replaced before decomposition because it is a distinct letter rather
than a base letter plus a combining mark, so NFKD leaves it intact.
"""
decomposed = unicodedata.normalize("NFKD", text.casefold()).replace("đ", "d")
without_marks = "".join(char for char in decomposed if not unicodedata.combining(char))
return " ".join(WORD_RE.findall(without_marks))