Fix ai-service Dockerfile: bake in drug_entities.json, override its path
This commit is contained in:
@@ -72,7 +72,12 @@ class BedrockClaudeAnswerGenerator:
|
||||
if self._client is None:
|
||||
from anthropic import AnthropicBedrockMantle
|
||||
|
||||
self._client = AnthropicBedrockMantle(aws_region=self._region)
|
||||
# max_retries: the SDK's own default (2) is lower than the retry
|
||||
# budget given the other Bedrock adapters (bedrock_converse.py,
|
||||
# embedding.py) after a real throttling burst measured live
|
||||
# 2026-08-07 — matched here for consistency, in case this
|
||||
# provider is ever selected instead of bedrock-converse.
|
||||
self._client = AnthropicBedrockMantle(aws_region=self._region, max_retries=4)
|
||||
return self._client
|
||||
|
||||
def generate(self, system: str, user: str, schema: dict) -> str:
|
||||
|
||||
@@ -94,7 +94,15 @@ class BedrockConverseAnswerGenerator:
|
||||
config=Config(
|
||||
connect_timeout=10,
|
||||
read_timeout=60,
|
||||
retries={"max_attempts": 3, "mode": "standard"},
|
||||
# "adaptive" was tried 2026-08-07 and reverted same day: its
|
||||
# client-side rate limiter remembers "throttled" across
|
||||
# requests and paces even unrelated, otherwise-healthy calls
|
||||
# down after a burst — turned a single answerable turn's
|
||||
# baseline ~9s into 1-5 MINUTES following this session's own
|
||||
# heavy adversarial test traffic. "standard" retries each
|
||||
# call independently, no shared state to get stuck in a bad
|
||||
# regime. max_attempts alone (3->4) is kept.
|
||||
retries={"max_attempts": 4, "mode": "standard"},
|
||||
),
|
||||
)
|
||||
return self._client
|
||||
@@ -168,7 +176,15 @@ class BedrockCohereReranker:
|
||||
config=Config(
|
||||
connect_timeout=10,
|
||||
read_timeout=30,
|
||||
retries={"max_attempts": 3, "mode": "standard"},
|
||||
# "adaptive" was tried 2026-08-07 and reverted same day: its
|
||||
# client-side rate limiter remembers "throttled" across
|
||||
# requests and paces even unrelated, otherwise-healthy calls
|
||||
# down after a burst — turned a single answerable turn's
|
||||
# baseline ~9s into 1-5 MINUTES following this session's own
|
||||
# heavy adversarial test traffic. "standard" retries each
|
||||
# call independently, no shared state to get stuck in a bad
|
||||
# regime. max_attempts alone (3->4) is kept.
|
||||
retries={"max_attempts": 4, "mode": "standard"},
|
||||
),
|
||||
)
|
||||
return self._client
|
||||
|
||||
@@ -74,7 +74,10 @@ class BedrockCohereQueryEmbedder:
|
||||
config=Config(
|
||||
connect_timeout=10,
|
||||
read_timeout=30,
|
||||
retries={"max_attempts": 3, "mode": "standard"},
|
||||
# "adaptive" was tried 2026-08-07 and reverted same day — see
|
||||
# the matching comment in adapters/bedrock_converse.py for the
|
||||
# measured 1-5-minute regression it caused. "standard" kept.
|
||||
retries={"max_attempts": 4, "mode": "standard"},
|
||||
),
|
||||
)
|
||||
return self._client
|
||||
|
||||
@@ -93,3 +93,59 @@ class PostgresTraceRepository:
|
||||
decision=row[4], reason=row[5], resolved_drug_id=row[6],
|
||||
citations=tuple(row[7]), created_at=row[8],
|
||||
)
|
||||
|
||||
|
||||
class PostgresConversationStore:
|
||||
"""Durable, cross-worker replacement for `RagAgent`'s in-process
|
||||
`dict[str, list[str]]` history — the F-08 gap named in ADR 0008: history
|
||||
was lost on restart and not shared across workers.
|
||||
|
||||
Append-only, one connection per call — the same tradeoffs as
|
||||
`PostgresTraceRepository` (F-09: real pooling is a further improvement
|
||||
not made here) and the same `connect_timeout=5` for the same reason
|
||||
(an unreachable-but-not-refusing host hangs on the OS TCP timeout
|
||||
otherwise, defeating a caller's fail-open try/except just as completely
|
||||
as no try/except at all).
|
||||
|
||||
Raises on any error rather than swallowing it — `RagAgent` is the
|
||||
caller that decides fail-open (lose this turn's memory, not the
|
||||
response), matching how `routers/rag.py` already wraps
|
||||
`PostgresTraceRepository.save()` rather than the repository hiding its
|
||||
own failures.
|
||||
"""
|
||||
|
||||
def __init__(self, dsn: str) -> None:
|
||||
self._dsn = dsn
|
||||
|
||||
def migrate(self, migration_path: Path) -> None:
|
||||
import psycopg
|
||||
|
||||
statement = migration_path.read_text(encoding="utf-8")
|
||||
with psycopg.connect(self._dsn, connect_timeout=5) as connection:
|
||||
connection.execute(statement)
|
||||
|
||||
def recent(self, conversation_id: str, limit: int) -> list[str]:
|
||||
"""The last `limit` lines, oldest first — matches the ordering the
|
||||
understanding LLM prompt already expects ("LỊCH SỬ HỘI THOẠI (cũ ->
|
||||
mới)")."""
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(self._dsn, connect_timeout=5) as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT line FROM rag_conversation_turn
|
||||
WHERE conversation_id = %s ORDER BY id DESC LIMIT %s
|
||||
""",
|
||||
(conversation_id, limit),
|
||||
).fetchall()
|
||||
return [row[0] for row in reversed(rows)]
|
||||
|
||||
def append(self, conversation_id: str, line: str) -> None:
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(self._dsn, connect_timeout=5) as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO rag_conversation_turn (conversation_id, line) "
|
||||
"VALUES (%s, %s)",
|
||||
(conversation_id, line),
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user