Wire the guarded conversational RAG answer layer end-to-end
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
"""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
|
||||
from .sections import SECTION_PHRASES, SectionResolver
|
||||
|
||||
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:
|
||||
# Only genuinely-close names are offered. A far match (Arginin for
|
||||
# "metfomin") is noise, not a suggestion — so the bar is high, and
|
||||
# when nothing clears it the honest answer is "not in the formulary",
|
||||
# never a padded list of unrelated drugs.
|
||||
suggestions = self._resolver.suggest(query, k=3, min_score=0.72)
|
||||
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 with the self-contained (rewritten) query.
|
||||
# A multi-round retrieval-refine loop was tried and removed: refining an
|
||||
# already-answerable whole-section result cannot fetch more (the section
|
||||
# is complete) and, worse, the refined query drops the inherited drug and
|
||||
# abstains — discarding a good answer. Refinement belongs to the
|
||||
# similarity path, not here. Clarify + inheritance are the loop's value,
|
||||
# and both happen above this line.
|
||||
effective = self._rewrite(query, resolved)
|
||||
grounded: GroundedAnswer | None = self._answers.answer(
|
||||
effective, subject_scope, intent
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
@staticmethod
|
||||
def _rewrite(query: str, resolved) -> str:
|
||||
parts: list[str] = []
|
||||
if resolved.inherited_drug and resolved.drug_id:
|
||||
parts.append(resolved.drug_id)
|
||||
if resolved.inherited_section and resolved.section_key:
|
||||
phrases = SECTION_PHRASES.get(resolved.section_key)
|
||||
if phrases:
|
||||
parts.append(phrases[0])
|
||||
parts.append(query)
|
||||
return " ".join(parts)
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user