Fix ai-service Dockerfile: bake in drug_entities.json, override its path

This commit is contained in:
2026-08-10 10:35:13 +07:00
parent a4b8e1c4db
commit 60b4397032
51 changed files with 4302 additions and 2087 deletions
+65
View File
@@ -32,6 +32,9 @@ class EvidencePolicy:
# A free-form question about a resolved drug otherwise hands the LLM the
# entire monograph; rerank trims it to the sections that actually answer.
rerank_top_k: int = 6
# symptom_to_drug: a common symptom can match far more drugs than is
# useful to show in one answer.
indication_candidate_limit: int = 8
class RetrievalService:
@@ -157,6 +160,47 @@ class RetrievalService:
return RetrievalResult(EvidenceDecision.ABSTAIN, "insufficient_retrieval_score")
return self._decide(self._hydrate(self._rerank(query, hits)))
def retrieve_by_indication(self, indication_text: str) -> RetrievalResult:
"""Reverse lookup: symptom/indication -> candidate drugs.
Keyword match first (deterministic, precise — nothing here can
fabricate a drug that doesn't genuinely mention the indication).
Dense-vector search over `chi_dinh` only is the fallback, tried
only when the keyword pass finds nothing, to catch a paraphrase the
book's own wording doesn't share. This is the one place in the live
path dense search is actually used — see ADR 0008.
"""
if not indication_text.strip():
return RetrievalResult(EvidenceDecision.ABSTAIN, "missing_indication")
find_by_indication = getattr(self._retriever, "find_by_indication", None)
hits = (
find_by_indication(indication_text, self._policy.indication_candidate_limit)
if find_by_indication is not None
else []
)
if not hits:
search_indication = getattr(self._retriever, "search_indication", None)
if search_indication is not None:
try:
hits = search_indication(
indication_text, self._policy.indication_candidate_limit
)
except QueryEmbeddingUnavailable:
hits = []
# Dense search always returns its nearest neighbours, even for
# an indication the corpus has nothing on — verified live: a
# made-up phrase still got 8 unrelated "matches". A weak top
# score means those neighbours aren't really about the
# question, so don't spend a generation call finding that out
# the slow way; abstain here, the same bar `retrieve()`'s own
# dense fallback already applies.
if hits and hits[0].score < self._policy.minimum_score:
hits = []
if not hits:
return RetrievalResult(EvidenceDecision.ABSTAIN, "no_indication_match")
return self._decide(self._hydrate(hits, limit=None))
@staticmethod
def _is_question(query: str) -> bool:
"""A bare drug name (one or two tokens) wants the whole monograph; more
@@ -206,8 +250,29 @@ class RetrievalService:
if match is None:
return None
hits = find_by_section(drug_id, match.section_key)
if match.section_key == "than_trong":
# A "thận trọng" question about a specific condition sometimes has
# its real answer filed under "chống chỉ định" instead — found live
# 2026-08-10: Aspirin's own "thận trọng" text never says "loét dạ
# dày", the fact only exists in its "chống chỉ định" text ("loét
# dạ dày hoặc tá tràng đang hoạt động"). The two are the closest
# pair of "is this safe for my patient" categories the book has,
# and chống chỉ định text is short — pooling it costs nothing on
# a drug where than_trong already answers, and prevents a false
# "not in this source" clarify/abstain on one where it doesn't.
hits = hits + find_by_section(drug_id, "chong_chi_dinh")
return hits or None
def decide(self, evidence: tuple[Evidence, ...]) -> RetrievalResult:
"""Public entry point for a caller that assembles its own evidence
pool across several `retrieve_framed` calls — e.g. `RagAgent`'s
2-drug interaction path — and needs the same quarantine/provenance
policy applied to the combined pool that a single call already gets.
Bypassing this (hand-rolling `RetrievalResult(ANSWERABLE, ...)`) is
exactly how the interaction path silently dropped a quarantined
drug's evidence instead of surfacing VERIFY_PDF for it."""
return self._decide(evidence)
def _decide(
self, evidence: tuple[Evidence, ...], is_drug_overview: bool = False
) -> RetrievalResult: