Wire the guarded conversational RAG answer layer end-to-end
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
from .models import EvidenceDecision, RetrievalResult
|
||||
from .service import EvidencePolicy, RetrievalService
|
||||
|
||||
__all__ = ["EvidenceDecision", "EvidencePolicy", "RetrievalResult", "RetrievalService"]
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 đó", "nó")
|
||||
|
||||
# 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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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: ...
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user