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
+112
View File
@@ -233,6 +233,118 @@ class QdrantRetriever:
hits.append(SearchHit(_document(payload), 1.0))
return hits
def find_by_indication(self, indication_text: str, limit: int) -> list[SearchHit]:
"""Reverse lookup: every drug whose `chi_dinh` text mentions the given
symptom/indication, keyword-matched. Deterministic, no fabrication
risk — the same "exact match wins, no-match-means-None" philosophy
`find_by_section` already uses, applied across drugs instead of
within one. `limit` caps how many DRUGS are returned (one hit per
drug, first match wins), not how many chunks are scanned — a common
symptom can match far more drugs than is useful to show.
Prose only: a `block_descriptor` chunk carries no real `chi_dinh`
text (its text is built only from metadata per the quarantine
contract), so keyword-matching it would be meaningless.
`indication_text`, normalized, must appear as a CONTIGUOUS,
word-boundary-anchored phrase in the chunk's text — not a plain
substring (risks a false positive inside an unrelated longer word
after diacritic-stripping) and not a scattered bag-of-words match
either. Found live 2026-08-07: a token-SUBSET match (every word
present *somewhere*, any order) let a long nonsense phrase built
from common filler words ("bệnh chưa từng ghi nhận trong sách…")
false-positive against real chi_dinh text, since words that common
appear scattered through nearly everything — it reached generation
before being caught, instead of failing here where it's cheap. A
genuine paraphrase that doesn't share the book's exact wording is
`search_indication`'s job (semantic), not this one's (lexical).
"""
import re
from qdrant_client.models import FieldCondition, Filter, MatchValue
from rag.text import normalize_name
needle = normalize_name(indication_text)
if not needle:
return []
needle_pattern = re.compile(rf"(?:^| ){re.escape(needle)}(?:$| )")
scroll_filter = Filter(
must=[
FieldCondition(key="section_key", match=MatchValue(value="chi_dinh")),
FieldCondition(key="chunk_kind", match=MatchValue(value="prose")),
]
)
hits: list[SearchHit] = []
seen_drugs: set[str] = set()
offset = None
while True:
points, offset = self._client.scroll(
collection_name=self._collection_name,
scroll_filter=scroll_filter,
limit=256,
offset=offset,
with_payload=True,
)
for point in points:
payload = dict(point.payload or {})
drug_id = payload.get("drug_id")
if drug_id in seen_drugs:
continue
text = normalize_name(payload.get("text", ""))
if not needle_pattern.search(f" {text} "):
continue
seen_drugs.add(drug_id)
hits.append(SearchHit(_document(payload), 1.0))
if len(hits) >= limit:
return hits
if offset is None:
break
return hits
def search_indication(self, query: str, limit: int) -> list[SearchHit]:
"""Dense-vector fallback for `find_by_indication` when no exact
keyword phrase match exists — catches paraphrases ("sốt cao" vs
"thân nhiệt tăng") a literal phrase match cannot. Deliberately narrow
(`section_key=chi_dinh` only, never the whole corpus) so this stays
a targeted fallback for one specific gap, not a return to unranked
similarity search — see ADR 0008 on why the live path otherwise
avoids `search()`. One hit per drug, highest-scoring chunk kept
(Qdrant returns points pre-sorted by score)."""
from qdrant_client.models import FieldCondition, Filter, MatchValue
vector = list(self._embedder.embed_query(query))
if len(vector) != self._embedder.dimensions:
raise ValueError(
f"query vector has {len(vector)} dimensions; "
f"expected {self._embedder.dimensions}"
)
points = self._client.search(
collection_name=self._collection_name,
query_vector=vector,
query_filter=Filter(
must=[
FieldCondition(key="section_key", match=MatchValue(value="chi_dinh")),
FieldCondition(key="chunk_kind", match=MatchValue(value="prose")),
]
),
limit=limit * 4,
with_payload=True,
)
hits: list[SearchHit] = []
seen_drugs: set[str] = set()
for point in points:
payload = dict(point.payload or {})
drug_id = payload.get("drug_id")
if drug_id in seen_drugs:
continue
seen_drugs.add(drug_id)
hits.append(SearchHit(_document(payload), float(point.score)))
if len(hits) >= limit:
break
return hits
class QdrantParentStore:
def __init__(self, client: Any, collection_name: str) -> None: