195 lines
8.2 KiB
Python
195 lines
8.2 KiB
Python
"""The new RAG orchestrator — LLM understanding in, grounded answer out.
|
|
|
|
Replaces the old front-end wholesale:
|
|
- `CatalogDrugResolver` (fuzzy) + `SectionResolver` (keyword) -> `understanding.py`
|
|
- `ConversationalLoopService` + `conversation.py` (Focus / Summariser / manual
|
|
follow-up inheritance) -> the LLM reads a plain turn history and resolves
|
|
"thuốc đó" / "còn liều thì sao" itself.
|
|
|
|
What is deliberately KEPT because it is the safety spine, not the brittle part:
|
|
- `RetrievalService.retrieve_framed` (Qdrant section/overview retrieval, whole
|
|
section, provenance, quarantine `VERIFY_PDF`),
|
|
- `GroundedAnswerService.answer_from_result` (`grounding.verify` + entailment
|
|
on every generated claim; a configured generator that fails abstains rather
|
|
than degrading to a raw source dump).
|
|
|
|
This module owns routing only; it states no medical fact of its own.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Protocol
|
|
|
|
from .answer import Citation, GroundedAnswerService
|
|
from .models import EvidenceDecision, RetrievalResult
|
|
from .policy import looks_non_human
|
|
from .service import RetrievalService
|
|
from .understanding import QueryFrame, QueryUnderstander
|
|
|
|
TUONG_TAC = "tuong_tac_thuoc"
|
|
HISTORY_TURNS = 6
|
|
|
|
|
|
class AutocompleteSource(Protocol):
|
|
"""As-you-type suggestion, kept deterministic and independent of the LLM
|
|
understander — a prefix match needs no model call."""
|
|
|
|
def complete(self, prefix: str, k: int) -> list[str]: ...
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AgentReply:
|
|
decision: str # answerable | abstain | clarify | verify_pdf
|
|
reason: str
|
|
answer: str | None = None
|
|
clarification: str | None = None
|
|
citations: tuple[Citation, ...] = ()
|
|
drugs: tuple[str, ...] = ()
|
|
turn_type: str = ""
|
|
generated: bool = False
|
|
|
|
|
|
class RagAgent:
|
|
def __init__(
|
|
self,
|
|
understander: QueryUnderstander,
|
|
retrieval: RetrievalService,
|
|
answers: GroundedAnswerService,
|
|
autocomplete: AutocompleteSource | None = None,
|
|
history_turns: int = HISTORY_TURNS,
|
|
) -> None:
|
|
self._understander = understander
|
|
self._retrieval = retrieval
|
|
self._answers = answers
|
|
self._autocomplete = autocomplete
|
|
self._history_turns = history_turns
|
|
self._history: dict[str, list[str]] = {}
|
|
|
|
def complete(self, prefix: str, k: int = 8) -> list[str]:
|
|
"""Display names matching a typed prefix, for input autocomplete."""
|
|
if self._autocomplete is None:
|
|
return []
|
|
return [_display_name(drug_id) for drug_id in self._autocomplete.complete(prefix, k)]
|
|
|
|
def handle(self, turn: str, conversation_id: str | None = None) -> AgentReply:
|
|
history = self._history.get(conversation_id, []) if conversation_id else []
|
|
frame = self._understander.understand(turn, tuple(history))
|
|
reply = self._route(turn, frame)
|
|
if conversation_id is not None:
|
|
self._remember(conversation_id, turn, reply)
|
|
return reply
|
|
|
|
def _route(self, turn: str, frame: QueryFrame) -> AgentReply:
|
|
tt = frame.turn_type
|
|
|
|
if frame.needs_clarify and frame.clarify_reason:
|
|
return AgentReply("clarify", "needs_more_info",
|
|
clarification=frame.clarify_reason,
|
|
drugs=frame.drugs, turn_type=tt)
|
|
|
|
if tt == "smalltalk":
|
|
return AgentReply(
|
|
"answerable", "smalltalk",
|
|
answer="Chào anh/chị! Em là trợ lý tra cứu Dược thư Quốc gia Việt Nam "
|
|
"2018, sẵn sàng hỗ trợ tra liều dùng, chống chỉ định, tương tác "
|
|
"thuốc... Anh/chị đang cần tra thuốc nào ạ?",
|
|
turn_type=tt)
|
|
|
|
if tt in ("out_of_scope",) or looks_non_human(turn):
|
|
return AgentReply(
|
|
"abstain", "out_of_scope",
|
|
answer="Nội dung này nằm ngoài phần chuyên luận thuốc của Dược thư "
|
|
"(có thể thuộc phần hướng dẫn chung/phụ lục chưa được đưa vào). "
|
|
"Tôi chưa có dữ liệu để trả lời chính xác.",
|
|
turn_type=tt)
|
|
|
|
if not frame.drugs:
|
|
if frame.unknown_drugs:
|
|
names = ", ".join(frame.unknown_drugs)
|
|
return AgentReply(
|
|
"abstain", "drug_not_in_formulary",
|
|
answer=f"Không tìm thấy \"{names}\" trong Dược thư Quốc gia Việt Nam.",
|
|
turn_type=tt)
|
|
if tt == "symptom_to_drug":
|
|
# Reverse lookup (indication/adverse-effect -> drugs) is a distinct
|
|
# retrieval mode, not yet wired. Be honest rather than abstain blank.
|
|
return AgentReply(
|
|
"clarify", "reverse_lookup_not_ready",
|
|
clarification="Tra ngược theo triệu chứng/chỉ định đang được bổ "
|
|
"sung. Anh/chị cho biết tên thuốc cụ thể để tôi tra giúp?",
|
|
turn_type=tt)
|
|
return AgentReply(
|
|
"clarify", "no_drug",
|
|
clarification="Anh/chị muốn tra thuốc nào?", turn_type=tt)
|
|
|
|
if tt == "interaction" and len(frame.drugs) >= 2:
|
|
return self._interaction(turn, frame)
|
|
|
|
# drug_attribute / drug_overview / dosing_calc / fallback: one drug + section
|
|
return self._single_drug(turn, frame)
|
|
|
|
def _single_drug(self, turn: str, frame: QueryFrame) -> AgentReply:
|
|
result = self._retrieval.retrieve_framed(
|
|
frame.drugs[0], frame.attribute, turn,
|
|
is_overview=frame.turn_type == "drug_overview",
|
|
)
|
|
return self._grounded(turn, result, frame)
|
|
|
|
def _interaction(self, turn: str, frame: QueryFrame) -> AgentReply:
|
|
"""Gather the interaction section of each named drug and synthesise.
|
|
|
|
Absence of a match is stated as "not found in each drug's interaction
|
|
section", never as "safe" — the answer layer's grounding still applies.
|
|
"""
|
|
evidences = []
|
|
for drug_id in frame.drugs:
|
|
part = self._retrieval.retrieve_framed(drug_id, TUONG_TAC, turn)
|
|
if part.decision == EvidenceDecision.ANSWERABLE:
|
|
evidences.extend(part.evidence)
|
|
if not evidences:
|
|
listed = " và ".join(frame.drugs)
|
|
return AgentReply(
|
|
"abstain", "no_interaction_evidence",
|
|
answer=f"Không tìm thấy mục tương tác thuốc cho {listed} trong Dược "
|
|
"thư. Điều này KHÔNG có nghĩa là an toàn khi phối hợp.",
|
|
drugs=frame.drugs, turn_type=frame.turn_type)
|
|
combined = RetrievalResult(
|
|
EvidenceDecision.ANSWERABLE, "interaction_evidence",
|
|
tuple(evidences),
|
|
)
|
|
return self._grounded(turn, combined, frame)
|
|
|
|
def _grounded(
|
|
self, turn: str, result: RetrievalResult, frame: QueryFrame
|
|
) -> AgentReply:
|
|
ga = self._answers.answer_from_result(turn, result)
|
|
decision = ga.result.decision.value
|
|
if ga.clarification is not None:
|
|
decision = "clarify"
|
|
return AgentReply(
|
|
decision=decision,
|
|
reason=ga.result.reason,
|
|
answer=ga.answer,
|
|
clarification=ga.clarification,
|
|
citations=ga.citations,
|
|
drugs=frame.drugs,
|
|
turn_type=frame.turn_type,
|
|
generated=ga.generated,
|
|
)
|
|
|
|
def _remember(self, conversation_id: str, turn: str, reply: AgentReply) -> None:
|
|
history = self._history.setdefault(conversation_id, [])
|
|
history.append(f"Người dùng: {turn}")
|
|
spoken = reply.answer or reply.clarification
|
|
if spoken:
|
|
history.append(f"Trợ lý: {spoken[:300]}")
|
|
# Keep only the recent window; the LLM re-reads it every turn.
|
|
excess = len(history) - self._history_turns * 2
|
|
if excess > 0:
|
|
del history[:excess]
|
|
|
|
|
|
def _display_name(drug_id: str) -> str:
|
|
"""A readable display name from a drug id ('paracetamol_acetaminophen')."""
|
|
return drug_id.replace("_", " ").title()
|