400 lines
15 KiB
Python
400 lines
15 KiB
Python
"""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, normalize_name
|
|
from .sections import SectionResolver
|
|
|
|
# Turns that only confirm a prior suggestion. They resolve no drug and must not
|
|
# be fuzzy-matched against the catalog (which returns garbage like terbinafin).
|
|
# Stored normalised (normalize_name strips diacritics: "đúng" -> "dung"), or the
|
|
# lookup below never matches.
|
|
_CONFIRMATION_WORDS = (
|
|
"đúng", "đúng rồi", "đúng vậy", "phải", "phải rồi", "chuẩn", "chuẩn rồi",
|
|
"chính xác", "ừ", "uh", "ok", "oke", "yes", "vâng",
|
|
)
|
|
_CONFIRMATIONS = frozenset(normalize_name(word) for word in _CONFIRMATION_WORDS)
|
|
|
|
|
|
def _is_confirmation(text: str) -> bool:
|
|
return normalize_name(text) in _CONFIRMATIONS
|
|
|
|
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:
|
|
# A bare confirmation ("đúng") with no drug in context is not a drug
|
|
# lookup — never fuzzy-match it (that returned terbinafin/tretinoin).
|
|
if _is_confirmation(query):
|
|
reason = "confirm_without_context"
|
|
clarification = Clarification(
|
|
reason=reason,
|
|
question="Bạn muốn xác nhận thuốc nào? Vui lòng gõ tên thuốc để mình tra cứu.",
|
|
options=(),
|
|
)
|
|
self._metrics.increment(metric_names.CLARIFY_ASKED, reason=reason)
|
|
self._persist(state, resolved, None)
|
|
return ConversationTurnResult(None, clarification, None, False, None, reason)
|
|
# Only offer "did you mean" for a SHORT, drug-name-shaped miss (a
|
|
# typo). Fuzzy-matching a whole sentence ("EPO điều trị thiếu máu…")
|
|
# or a confirmation ("đúng") against 684 aliases returns confident
|
|
# garbage — that is the did-you-mean loop the reviewer hit. A long or
|
|
# confirming turn that resolves no drug is answered honestly, not
|
|
# with a list of unrelated drugs.
|
|
looks_like_name = len(normalize_name(query).split()) <= 4
|
|
suggestions = (
|
|
self._resolver.suggest(query, k=3, min_score=0.72)
|
|
if looks_like_name and not _is_confirmation(query)
|
|
else []
|
|
)
|
|
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. The drug is passed already-resolved (incl.
|
|
# an inherited follow-up drug), so the engine does NOT re-resolve it from
|
|
# the turn text — that double-resolution is what abstained follow-ups as
|
|
# "ambiguous". The turn's own text drives section routing; when it names
|
|
# no attribute the drug-overview + rerank path finds the relevant part.
|
|
grounded: GroundedAnswer | None = self._answers.answer(
|
|
query, subject_scope, intent, drug_id=resolved.drug_id
|
|
)
|
|
|
|
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()
|
|
|
|
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)
|