Remove corpus counts from chat chrome
This commit is contained in:
@@ -13,6 +13,7 @@ and the Messages-API path on Bedrock is the Mantle client — not the legacy
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from rag.ports import AnswerGenerationUnavailable
|
||||
@@ -123,8 +124,17 @@ class StubAnswerGenerator:
|
||||
"""
|
||||
|
||||
def __init__(self, answer: str, evidence_sufficient: bool = True) -> None:
|
||||
# `answer` keeps its old free-text-with-[n]-markers shape for this
|
||||
# constructor's own callers (bootstrap.py's demo string) — wrapped
|
||||
# into a single structured claim here since 2026-08-10's schema
|
||||
# change (see rag/prompt.py's ANSWER_SCHEMA).
|
||||
citations = [int(n) for n in re.findall(r"\[(\d+)\]", answer)]
|
||||
text = re.sub(r"\s*\[\d+\]", "", answer).strip()
|
||||
self._payload = json.dumps(
|
||||
{"answer": answer, "evidence_sufficient": evidence_sufficient},
|
||||
{
|
||||
"claims": [{"text": text, "citations": citations}] if text else [],
|
||||
"evidence_sufficient": evidence_sufficient,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
|
||||
@@ -9,8 +9,8 @@ One structural difference from the Anthropic path drives the shape of this file:
|
||||
Converse has **no** server-side response schema (no `output_config.format`), so
|
||||
the JSON envelope `answer.py` parses cannot be enforced by the API. It is asked
|
||||
for in the prompt and then isolated here (`_extract_json`) before returning. If
|
||||
the model still emits something unparseable, `answer.py` falls back to the
|
||||
verbatim source text — losing the rewrite, never the answer.
|
||||
the model still emits something unparseable, `answer.py` fails closed with a
|
||||
diagnosable abstention instead of presenting a raw dump as a generated answer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -92,17 +92,15 @@ class BedrockConverseAnswerGenerator:
|
||||
BEDROCK_RUNTIME_SERVICE,
|
||||
region_name=self._region,
|
||||
config=Config(
|
||||
connect_timeout=10,
|
||||
read_timeout=60,
|
||||
# "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"},
|
||||
connect_timeout=5,
|
||||
# The request budget is checked between calls and cannot
|
||||
# interrupt boto3 while a call is in flight. A 60s read
|
||||
# timeout with four SDK attempts allowed one turn to run
|
||||
# for minutes. Allow one bounded retry: production smoke
|
||||
# tests showed an isolated Qwen read timeout immediately
|
||||
# followed by a healthy 3s response for the same request.
|
||||
read_timeout=20,
|
||||
retries={"total_max_attempts": 2, "mode": "standard"},
|
||||
),
|
||||
)
|
||||
return self._client
|
||||
|
||||
@@ -52,11 +52,12 @@ class Settings(BaseSettings):
|
||||
metrics_enabled: bool = True
|
||||
entities_path: Path = _default_entities_path()
|
||||
# F-08: a per-turn budget across RagAgent's sequential Bedrock calls
|
||||
# (understand, sufficiency, generate, up to 2 entailment retries).
|
||||
# (understand, generate, one entailment check on the live agent path).
|
||||
# Defaults sized with headroom above what a normal turn measures live
|
||||
# (~8-9s, 4-5 calls) — see rag/agent.py's MAX_WALL_CLOCK_MS/
|
||||
# (~8-9s before the bounded-provider change) — see rag/agent.py's
|
||||
# MAX_WALL_CLOCK_MS/
|
||||
# MAX_LLM_CALLS_PER_TURN for the full rationale.
|
||||
max_wall_clock_ms: int = 20_000
|
||||
max_wall_clock_ms: int = 40_000
|
||||
max_llm_calls_per_turn: int = 8
|
||||
|
||||
|
||||
|
||||
+160
-21
@@ -19,10 +19,10 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Protocol
|
||||
|
||||
from .answer import Citation, GroundedAnswerService
|
||||
from .answer import AnswerBlock, AnswerPlan, Citation, GroundedAnswerService
|
||||
from .budget import RequestBudget
|
||||
from .models import EvidenceDecision, RetrievalResult
|
||||
from .policy import looks_non_human
|
||||
@@ -33,14 +33,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
TUONG_TAC = "tuong_tac_thuoc"
|
||||
HISTORY_TURNS = 6
|
||||
# F-08: measured live 2026-08-07, a normal answerable turn makes 4 sequential
|
||||
# Bedrock calls (understand, sufficiency, generate, entailment) and costs
|
||||
# ~8-9s; a noisy entailment retry adds a 5th. Defaults sized with headroom
|
||||
# above that measured normal case, not at its exact edge, so ordinary
|
||||
# F-08: a normal answerable agent turn makes 3 sequential Bedrock calls
|
||||
# (understand, generate, entailment). Input-field sufficiency is enforced by
|
||||
# the structured state machine; generation still fails closed when retrieved
|
||||
# evidence is insufficient. Defaults are sized with headroom, so ordinary
|
||||
# traffic never trips the budget — it exists for the pathological case
|
||||
# (a stuck/slow call, or an unexpectedly long retry chain), not to shave
|
||||
# time off the common path.
|
||||
MAX_WALL_CLOCK_MS = 20_000
|
||||
MAX_WALL_CLOCK_MS = 40_000
|
||||
MAX_LLM_CALLS_PER_TURN = 8
|
||||
# Found live 2026-08-07 (50-question hand-typed browser audit): the
|
||||
# understanding LLM can get stuck re-asking the same (or a near-identical)
|
||||
@@ -81,6 +81,9 @@ class AgentReply:
|
||||
turn_type: str = ""
|
||||
generated: bool = False
|
||||
quick_replies: tuple[str, ...] = ()
|
||||
blocks: tuple[AnswerBlock, ...] = ()
|
||||
answer_mode: str = "concise"
|
||||
plan: AnswerPlan | None = None
|
||||
|
||||
|
||||
class RagAgent:
|
||||
@@ -132,7 +135,20 @@ class RagAgent:
|
||||
t3 = time.monotonic()
|
||||
if conversation_id is not None:
|
||||
self._remember(conversation_id, turn, reply)
|
||||
self._last_frame[conversation_id] = frame
|
||||
# A clarify can originate downstream of understanding (the dose
|
||||
# route invariant or evidence sufficiency). Persist that as an
|
||||
# open frame too; otherwise the next short reply sees a prior
|
||||
# frame marked complete and the structured merge cannot inherit
|
||||
# the drug/population the user already supplied.
|
||||
remembered_frame = frame
|
||||
if reply.decision == "clarify" and reply.clarification:
|
||||
remembered_frame = replace(
|
||||
frame,
|
||||
needs_clarify=True,
|
||||
clarify_reason=reply.clarification,
|
||||
quick_replies=reply.quick_replies,
|
||||
)
|
||||
self._last_frame[conversation_id] = remembered_frame
|
||||
t4 = time.monotonic()
|
||||
# Temporary instrumentation (2026-08-07): added specifically to
|
||||
# pinpoint a live, reproduced-in-browser case of the FIRST LLM call
|
||||
@@ -204,8 +220,71 @@ class RagAgent:
|
||||
|
||||
def _route(self, turn: str, frame: QueryFrame, budget: RequestBudget) -> AgentReply:
|
||||
tt = frame.turn_type
|
||||
section_overview = _is_section_overview(turn, frame)
|
||||
if section_overview and not frame.section_overview:
|
||||
frame = replace(frame, section_overview=True)
|
||||
|
||||
if frame.needs_clarify and frame.clarify_reason:
|
||||
# Deterministic scope guard precedes every conversational clarify. A
|
||||
# non-human dose must abstain, never ask which attribute/route and make
|
||||
# an out-of-scope request look recoverable.
|
||||
if 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)
|
||||
|
||||
# Dosing is a small state machine, not an unconstrained model opinion.
|
||||
# The LLM extracts the fields and can phrase/populate a useful initial
|
||||
# clarify; code decides which core fields are actually required. Live
|
||||
# testing caught the model asking an adult's weight repeatedly even
|
||||
# after the user supplied a route, while previously skipping route and
|
||||
# dumping oral + rectal regimens together.
|
||||
if tt == "dosing_calc" and frame.drugs and not section_overview:
|
||||
if frame.population is None:
|
||||
return AgentReply(
|
||||
"clarify", "missing_population",
|
||||
clarification=(
|
||||
frame.clarify_reason
|
||||
if frame.needs_clarify and frame.clarify_reason
|
||||
else "Anh/chị muốn tra liều cho người lớn hay trẻ em?"
|
||||
),
|
||||
drugs=frame.drugs, turn_type=tt,
|
||||
quick_replies=(
|
||||
frame.quick_replies
|
||||
if frame.needs_clarify else ()
|
||||
),
|
||||
)
|
||||
if frame.population in {"tre_em", "tre_so_sinh"} and (
|
||||
frame.age_text is None or frame.weight_kg is None
|
||||
):
|
||||
return AgentReply(
|
||||
"clarify", "missing_pediatric_age_or_weight",
|
||||
clarification=(
|
||||
frame.clarify_reason
|
||||
if frame.needs_clarify and frame.clarify_reason
|
||||
else "Bé bao nhiêu tuổi và cân nặng bao nhiêu kg?"
|
||||
),
|
||||
drugs=frame.drugs, turn_type=tt,
|
||||
quick_replies=(
|
||||
frame.quick_replies
|
||||
if frame.needs_clarify else ()
|
||||
),
|
||||
)
|
||||
# Route is intentionally not a universal required slot. Retrieval
|
||||
# and the answer contract decide from the actual evidence whether
|
||||
# omitting it is harmless (one applicable route -> answer now) or
|
||||
# materially ambiguous (several routes -> model clarification and
|
||||
# model-proposed quick replies). This prevents chip funnels for a
|
||||
# question that was already precise enough to answer.
|
||||
|
||||
if (
|
||||
frame.needs_clarify
|
||||
and frame.clarify_reason
|
||||
and tt != "dosing_calc"
|
||||
and not section_overview
|
||||
):
|
||||
# `system_error` set means this isn't a real clarify at all — the
|
||||
# understanding call itself failed (provider outage, malformed
|
||||
# output) and failed closed to this same shape. Surface the real
|
||||
@@ -214,10 +293,28 @@ class RagAgent:
|
||||
# in the API response and in `/metrics`/traces (found live
|
||||
# 2026-08-07: these were indistinguishable, which is why a real
|
||||
# outage looked identical to normal clarify traffic).
|
||||
return AgentReply("clarify", frame.system_error or "needs_more_info",
|
||||
clarification=frame.clarify_reason,
|
||||
drugs=frame.drugs, turn_type=tt,
|
||||
quick_replies=frame.quick_replies)
|
||||
if frame.system_error:
|
||||
return AgentReply(
|
||||
"abstain", frame.system_error,
|
||||
answer=frame.clarify_reason,
|
||||
drugs=frame.drugs, turn_type=tt,
|
||||
)
|
||||
return AgentReply(
|
||||
"clarify", "needs_more_info",
|
||||
clarification=frame.clarify_reason,
|
||||
drugs=frame.drugs, turn_type=tt,
|
||||
quick_replies=frame.quick_replies,
|
||||
)
|
||||
|
||||
if tt == "drug_attribute" and frame.drugs and frame.attribute is None:
|
||||
return AgentReply(
|
||||
"clarify", "missing_attribute",
|
||||
clarification=(
|
||||
"Anh/chị muốn tra nội dung nào của thuốc này "
|
||||
"(chỉ định, chống chỉ định, thận trọng, tác dụng phụ…)?"
|
||||
),
|
||||
drugs=frame.drugs, turn_type=tt,
|
||||
)
|
||||
|
||||
if tt == "smalltalk":
|
||||
return AgentReply(
|
||||
@@ -227,7 +324,7 @@ class RagAgent:
|
||||
"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):
|
||||
if tt == "out_of_scope":
|
||||
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ư "
|
||||
@@ -261,9 +358,18 @@ class RagAgent:
|
||||
return self._single_drug(turn, frame, budget)
|
||||
|
||||
def _single_drug(self, turn: str, frame: QueryFrame, budget: RequestBudget) -> AgentReply:
|
||||
query = _synthesize_query(turn, frame)
|
||||
query = _synthesize_query(frame.standalone_query or turn, frame)
|
||||
# `dosing_calc` semantically names the dosage section even when the
|
||||
# understanding model leaves the separate `attribute` field null.
|
||||
# Passing null here falls into an overview retrieval and was observed
|
||||
# pulling interactions/precautions into a plain adult-dose answer.
|
||||
section_key = (
|
||||
"lieu_luong_va_cach_dung"
|
||||
if frame.turn_type == "dosing_calc"
|
||||
else frame.attribute
|
||||
)
|
||||
result = self._retrieval.retrieve_framed(
|
||||
frame.drugs[0], frame.attribute, query,
|
||||
frame.drugs[0], section_key, query,
|
||||
is_overview=frame.turn_type == "drug_overview",
|
||||
)
|
||||
return self._grounded(query, result, frame, budget=budget)
|
||||
@@ -288,8 +394,9 @@ class RagAgent:
|
||||
generation the same way.
|
||||
"""
|
||||
evidences = []
|
||||
query = _synthesize_query(frame.standalone_query or turn, frame)
|
||||
for drug_id in frame.drugs:
|
||||
part = self._retrieval.retrieve_framed(drug_id, TUONG_TAC, turn)
|
||||
part = self._retrieval.retrieve_framed(drug_id, TUONG_TAC, query)
|
||||
if part.decision in (EvidenceDecision.ANSWERABLE, EvidenceDecision.VERIFY_PDF):
|
||||
evidences.extend(part.evidence)
|
||||
if not evidences:
|
||||
@@ -300,7 +407,7 @@ class RagAgent:
|
||||
"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 = self._retrieval.decide(tuple(evidences))
|
||||
return self._grounded(turn, combined, frame, budget=budget)
|
||||
return self._grounded(query, combined, frame, budget=budget)
|
||||
|
||||
def _symptom_to_drug(
|
||||
self, turn: str, frame: QueryFrame, budget: RequestBudget
|
||||
@@ -338,14 +445,15 @@ class RagAgent:
|
||||
budget: RequestBudget | None = None,
|
||||
) -> AgentReply:
|
||||
ga = self._answers.answer_from_result(
|
||||
turn, result, list_mode=list_mode, budget=budget
|
||||
turn, result, list_mode=list_mode, budget=budget, prechecked=True
|
||||
)
|
||||
decision = ga.result.decision.value
|
||||
if ga.clarification is not None:
|
||||
decision = "clarify"
|
||||
reason = "needs_more_info" if ga.clarification is not None else ga.result.reason
|
||||
return AgentReply(
|
||||
decision=decision,
|
||||
reason=ga.result.reason,
|
||||
reason=reason,
|
||||
answer=ga.answer,
|
||||
clarification=ga.clarification,
|
||||
citations=ga.citations,
|
||||
@@ -353,6 +461,9 @@ class RagAgent:
|
||||
turn_type=frame.turn_type,
|
||||
generated=ga.generated,
|
||||
quick_replies=ga.quick_replies,
|
||||
blocks=ga.blocks,
|
||||
answer_mode=ga.answer_mode,
|
||||
plan=ga.plan,
|
||||
)
|
||||
|
||||
def _remember(self, conversation_id: str, turn: str, reply: AgentReply) -> None:
|
||||
@@ -387,6 +498,28 @@ def _display_name(drug_id: str) -> str:
|
||||
return drug_id.replace("_", " ").title()
|
||||
|
||||
|
||||
def _is_section_overview(turn: str, frame: QueryFrame) -> bool:
|
||||
"""Separate a handbook survey from a patient-specific decision.
|
||||
|
||||
The model supplies the first-class flag, while the narrow lexical
|
||||
backstop makes a clear section lookup deterministic. A personal target
|
||||
always wins: broad words inside a patient-specific dose question must not
|
||||
suppress a necessary clarification.
|
||||
"""
|
||||
text = turn.casefold()
|
||||
personal_cues = (
|
||||
"cho tôi", "tôi đang", "bệnh nhân này", "ca này", "bé ", "trẻ ",
|
||||
"tuổi", "cân nặng", " kg", "suy thận", "suy gan", "nên dùng liều nào",
|
||||
)
|
||||
if any(cue in text for cue in personal_cues):
|
||||
return False
|
||||
overview_cues = (
|
||||
"dược thư hướng dẫn", "những ", "các ", "toàn bộ", "tất cả",
|
||||
"trình bày", "theo nhóm", "theo tần suất", "nếu có", "tổng quan",
|
||||
)
|
||||
return frame.section_overview or any(cue in text for cue in overview_cues)
|
||||
|
||||
|
||||
_POPULATION_LABELS = {
|
||||
"tre_em": "trẻ em",
|
||||
"tre_so_sinh": "trẻ sơ sinh",
|
||||
@@ -417,7 +550,8 @@ def _synthesize_query(turn: str, frame: QueryFrame) -> str:
|
||||
|
||||
`GroundedAnswerService.answer_from_result` has no conversation history of
|
||||
its own; the `query` string it receives IS the entire context its
|
||||
sufficiency-check and generation LLM calls see. Passing the bare current
|
||||
generation LLM call sees (and the legacy sufficiency call, if enabled).
|
||||
Passing the bare current
|
||||
turn loses everything resolved earlier: a reply like "Uống" answering a
|
||||
route question three turns into a dose conversation would reach
|
||||
generation as just "Uống", indistinguishable from a user who typed
|
||||
@@ -429,6 +563,11 @@ def _synthesize_query(turn: str, frame: QueryFrame) -> str:
|
||||
repetition.
|
||||
"""
|
||||
parts = [turn]
|
||||
if frame.section_overview:
|
||||
parts.append(
|
||||
"Phạm vi yêu cầu: tra cứu tổng quan toàn mục; trình bày các nhánh "
|
||||
"trong Dược thư với nhãn điều kiện, không chọn một phác đồ cho một người bệnh"
|
||||
)
|
||||
if frame.population:
|
||||
parts.append(f"Đối tượng: {_POPULATION_LABELS.get(frame.population, frame.population)}")
|
||||
if frame.age_text:
|
||||
|
||||
+525
-66
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from . import grounding, metrics as metric_names
|
||||
@@ -9,12 +11,42 @@ from .budget import RequestBudget, RequestBudgetExhausted
|
||||
from .metrics import Metrics, NullMetrics
|
||||
from .models import EvidenceDecision, QueryIntent, RetrievalResult, SubjectScope
|
||||
from .ports import AnswerGenerationUnavailable, AnswerGenerator
|
||||
from .prompt import build_entailment_request, build_request, build_sufficiency_request
|
||||
from .prompt import (
|
||||
GenerationRequest,
|
||||
build_entailment_request,
|
||||
build_request,
|
||||
build_sufficiency_request,
|
||||
)
|
||||
from .routing import QueryRoutingService
|
||||
|
||||
# See `_verify_entailment`'s docstring for the measured trade-off behind
|
||||
# widening this from 2 to 3.
|
||||
_ENTAILMENT_MAX_ATTEMPTS = 3
|
||||
# Repeating the same temperature-0 prompt against the same model is a
|
||||
# correlated retry, not an independent vote. One fail-closed semantic pass is
|
||||
# kept after structured-claim parsing and deterministic grounding.
|
||||
_ENTAILMENT_MAX_ATTEMPTS = 1
|
||||
|
||||
_QUICK_REPLY_MAX_ITEMS = 4
|
||||
_QUICK_REPLY_MAX_CHARS = 40
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _sanitize_quick_replies(value) -> tuple[str, ...]:
|
||||
"""Validate LLM-proposed chips without hard-coding their content."""
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
replies: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
continue
|
||||
reply = " ".join(item.split())
|
||||
key = reply.casefold()
|
||||
if not reply or len(reply) > _QUICK_REPLY_MAX_CHARS or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
replies.append(reply)
|
||||
if len(replies) >= _QUICK_REPLY_MAX_ITEMS:
|
||||
break
|
||||
return tuple(replies)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -33,6 +65,34 @@ class Citation:
|
||||
evidence_text: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AnswerClaim:
|
||||
"""One user-visible fact and the exact retrieved chunks supporting it."""
|
||||
|
||||
text: str
|
||||
source_ids: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AnswerBlock:
|
||||
"""Semantic presentation unit produced by the answer service, not the UI."""
|
||||
|
||||
title: str
|
||||
kind: str
|
||||
claims: tuple[AnswerClaim, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AnswerPlan:
|
||||
"""Compact, non-reasoning presentation plan decided before generation."""
|
||||
|
||||
verbosity: str
|
||||
layout: str
|
||||
reasoning_mode: str
|
||||
show_heading: bool = False
|
||||
needs_warning: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GroundedAnswer:
|
||||
result: RetrievalResult
|
||||
@@ -47,18 +107,23 @@ class GroundedAnswer:
|
||||
# em") — only populated when the sufficiency check judged the question
|
||||
# to have a few natural discrete answers, never invented client-side.
|
||||
quick_replies: tuple[str, ...] = ()
|
||||
blocks: tuple[AnswerBlock, ...] = ()
|
||||
answer_mode: str = "concise"
|
||||
plan: AnswerPlan | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _GenOutcome:
|
||||
answer: str | None = None
|
||||
clarification: str | None = None
|
||||
quick_replies: tuple[str, ...] = ()
|
||||
# The specific reason a rejection happened — the exact string already
|
||||
# used for the GENERATION_REJECTED metric, propagated here so
|
||||
# `answer_from_result` can put it in the API response's `reason` field
|
||||
# instead of a generic catch-all. `None` when `answer`/`clarification`
|
||||
# is set (nothing was rejected).
|
||||
reject_reason: str | None = None
|
||||
claims: tuple[tuple[str, tuple[int, ...]], ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -67,13 +132,251 @@ class _RawAttempt:
|
||||
lets `_generate` retry the noisy `insufficient` case without
|
||||
double-counting a rejection metric across both attempts."""
|
||||
answer: str | None = None
|
||||
# The model's own claim/citation boundaries (2026-08-10 structured-claims
|
||||
# change) — `_verify_entailment` checks these directly instead of
|
||||
# re-deriving claim boundaries from `answer` via regex. `answer` is still
|
||||
# populated (assembled from these by `_assemble_answer`) because it's
|
||||
# the exact string `grounding.verify` and the API response need — one
|
||||
# format, not two representations that could drift apart.
|
||||
claims: tuple[tuple[str, tuple[int, ...]], ...] = ()
|
||||
clarification: str | None = None
|
||||
quick_replies: tuple[str, ...] = ()
|
||||
insufficient: bool = False
|
||||
outage: bool = False
|
||||
budget_exhausted: bool = False
|
||||
malformed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _VerificationOutcome:
|
||||
supported: bool
|
||||
complete: bool
|
||||
missing: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def _parse_claims(raw_claims: list) -> tuple[tuple[str, tuple[int, ...]], ...] | None:
|
||||
"""Validate the model's `claims` array into `(text, citation_indices)`
|
||||
pairs, or `None` on any malformed entry — same fail-closed contract as
|
||||
every other shape check in `_attempt_generation`."""
|
||||
claims: list[tuple[str, tuple[int, ...]]] = []
|
||||
for item in raw_claims:
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
text = item.get("text")
|
||||
citations = item.get("citations")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
return None
|
||||
if not isinstance(citations, list) or not all(
|
||||
isinstance(c, int) and not isinstance(c, bool) for c in citations
|
||||
):
|
||||
return None
|
||||
claims.append((text.strip(), tuple(citations)))
|
||||
return tuple(claims)
|
||||
|
||||
|
||||
def _prompt_evidence_texts(evidence: tuple) -> tuple[str, ...]:
|
||||
"""Evidence text as shown to the generator/entailment judge — labeled with
|
||||
its source drug ONLY when the evidence set spans more than one drug.
|
||||
|
||||
Found live 2026-08-10: a drug interaction section routinely refers to the
|
||||
drug it belongs to by pharmacological class rather than by name (e.g.
|
||||
warfarin's own "tương tác thuốc" section says "thuốc kháng vitamin K",
|
||||
never "warfarin" — normal in a monograph, since the reader already knows
|
||||
which drug's chapter they're in). Read as an isolated chunk with no
|
||||
section header, that self-reference is lost, and the entailment judge
|
||||
was measured flip-flopping ~50/50 on a claim naming the drug directly
|
||||
against evidence that never does (10 identical calls: 5 entailed, 5 not
|
||||
— see the commit this function was added in for the full trace). Labeling
|
||||
each block with its own `chunk_id` drug prefix (`ingestion/.../chunker.py`
|
||||
always writes `{drug_id}__{section}__{n}`, so this is not a per-drug
|
||||
special case) restores that anchor without asking the judge to reason
|
||||
about pharmacology — it only has to match a name already handed to it.
|
||||
Single-drug evidence sets are left unlabeled: nothing there was
|
||||
ambiguous, and every token here is spent on every call this product
|
||||
makes, so it is not added where the measured bug does not apply.
|
||||
"""
|
||||
drug_ids = [item.matched_doc_id.split("__", 1)[0] for item in evidence]
|
||||
if len(set(drug_ids)) < 2:
|
||||
return tuple(item.text for item in evidence)
|
||||
return tuple(
|
||||
f"(Nguồn: chuyên luận {drug_id.replace('_', ' ').upper()}) {item.text}"
|
||||
for drug_id, item in zip(drug_ids, evidence, strict=True)
|
||||
)
|
||||
|
||||
|
||||
def _assemble_answer(claims: tuple[tuple[str, tuple[int, ...]], ...]) -> str:
|
||||
"""The exact display string `grounding.verify` and the API response use
|
||||
— built from the model's own claim/citation structure, not written by
|
||||
the model as free text. Format matches what the product already shows
|
||||
(`text [n]` / `text [n][m]`) so the frontend needs no changes and
|
||||
`grounding.verify`'s existing `[n]`-marker parsing applies unmodified."""
|
||||
parts = []
|
||||
for text, citations in claims:
|
||||
marker = "".join(f"[{c}]" for c in citations)
|
||||
parts.append(f"{text} {marker}".rstrip() if marker else text)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
_SECTION_PRESENTATION = {
|
||||
"adr": ("Tác dụng không mong muốn", "fact_list"),
|
||||
"tac_dung_khong_mong_muon": ("Tác dụng không mong muốn", "fact_list"),
|
||||
"tuong_tac_thuoc": ("Tương tác thuốc", "warning"),
|
||||
"tuong_tac": ("Tương tác thuốc", "warning"),
|
||||
"chong_chi_dinh": ("Chống chỉ định", "warning"),
|
||||
"than_trong": ("Thận trọng", "warning"),
|
||||
"canh_bao": ("Cảnh báo", "warning"),
|
||||
"lieu_luong_va_cach_dung": ("Liều lượng và cách dùng", "dosage"),
|
||||
"lieu_dung": ("Liều lượng và cách dùng", "dosage"),
|
||||
"cach_dung": ("Cách dùng", "dosage"),
|
||||
"bao_quan": ("Bảo quản", "fact_list"),
|
||||
"do_on_dinh_va_bao_quan": ("Bảo quản", "fact_list"),
|
||||
"chi_dinh": ("Chỉ định", "fact_list"),
|
||||
"duoc_luc_hoc": ("Dược lực học", "fact_list"),
|
||||
"duoc_dong_hoc": ("Dược động học", "fact_list"),
|
||||
"qua_lieu_va_xu_tri": ("Quá liều và xử trí", "warning"),
|
||||
}
|
||||
|
||||
|
||||
def _section_key(chunk_id: str) -> str:
|
||||
parts = chunk_id.split("__")
|
||||
return parts[1].casefold() if len(parts) > 1 else ""
|
||||
|
||||
|
||||
def _presentation_for_sources(source_ids: tuple[str, ...]) -> tuple[str, str]:
|
||||
section = _section_key(source_ids[0]) if source_ids else ""
|
||||
if section in _SECTION_PRESENTATION:
|
||||
return _SECTION_PRESENTATION[section]
|
||||
if section:
|
||||
return section.replace("_", " ").strip().capitalize(), "fact_list"
|
||||
return "Trả lời", "fact_list"
|
||||
|
||||
|
||||
def _build_blocks(
|
||||
claims: tuple[tuple[str, tuple[int, ...]], ...],
|
||||
indexed: list[tuple[str, Citation]],
|
||||
) -> tuple[AnswerBlock, ...]:
|
||||
"""Map verified claims to semantic blocks and stable source ids."""
|
||||
groups: list[tuple[str, str, list[AnswerClaim]]] = []
|
||||
for text, citation_indices in claims:
|
||||
source_ids = tuple(
|
||||
indexed[index - 1][1].chunk_id
|
||||
for index in citation_indices
|
||||
if 1 <= index <= len(indexed)
|
||||
)
|
||||
claim = AnswerClaim(text=text, source_ids=tuple(dict.fromkeys(source_ids)))
|
||||
title, kind = _presentation_for_sources(claim.source_ids)
|
||||
if groups and groups[-1][0] == title and groups[-1][1] == kind:
|
||||
groups[-1][2].append(claim)
|
||||
else:
|
||||
groups.append((title, kind, [claim]))
|
||||
return tuple(
|
||||
AnswerBlock(title=title, kind=kind, claims=tuple(items))
|
||||
for title, kind, items in groups
|
||||
)
|
||||
|
||||
|
||||
def _answer_mode(claim_count: int) -> str:
|
||||
if claim_count <= 2:
|
||||
return "concise"
|
||||
if claim_count <= 6:
|
||||
return "normal"
|
||||
return "detailed"
|
||||
|
||||
|
||||
_BROAD_QUERY_CUES = (
|
||||
"đầy đủ", "tất cả", "toàn bộ", "tổng hợp", "so sánh", "đối chiếu",
|
||||
)
|
||||
_WARNING_SECTIONS = frozenset({
|
||||
"chong_chi_dinh", "than_trong", "qua_lieu_va_xu_tri",
|
||||
})
|
||||
_DOSAGE_SECTIONS = frozenset({"lieu_luong_va_cach_dung", "lieu_dung", "cach_dung"})
|
||||
_LIST_SECTIONS = frozenset({
|
||||
"chi_dinh", "chong_chi_dinh", "than_trong", "tac_dung_khong_mong_muon",
|
||||
"tuong_tac_thuoc", "do_on_dinh_va_bao_quan", "qua_lieu_va_xu_tri",
|
||||
})
|
||||
|
||||
|
||||
def _plan_answer(query: str, result: RetrievalResult, list_mode: bool) -> AnswerPlan:
|
||||
sections = tuple(dict.fromkeys(
|
||||
_section_key(item.matched_doc_id) for item in result.evidence
|
||||
if _section_key(item.matched_doc_id)
|
||||
))
|
||||
drugs = tuple(dict.fromkeys(
|
||||
item.matched_doc_id.split("__", 1)[0] for item in result.evidence
|
||||
))
|
||||
multi_source = len(sections) > 1 or len(drugs) > 1 or list_mode
|
||||
broad = any(cue in query.casefold() for cue in _BROAD_QUERY_CUES)
|
||||
verbosity = "detailed" if broad or multi_source else (
|
||||
"concise" if len(result.evidence) == 1 else "normal"
|
||||
)
|
||||
if any(section in _DOSAGE_SECTIONS for section in sections):
|
||||
layout = "dosage"
|
||||
elif multi_source or any(section in _LIST_SECTIONS for section in sections):
|
||||
layout = "bullet_list"
|
||||
else:
|
||||
layout = "prose"
|
||||
return AnswerPlan(
|
||||
verbosity=verbosity,
|
||||
layout=layout,
|
||||
reasoning_mode="synthesis" if multi_source else "direct_lookup",
|
||||
show_heading=multi_source or verbosity == "detailed",
|
||||
needs_warning=any(section in _WARNING_SECTIONS for section in sections),
|
||||
)
|
||||
|
||||
|
||||
def _normalise_for_coverage(text: str) -> str:
|
||||
folded = unicodedata.normalize("NFKC", text).casefold()
|
||||
return " ".join(re.sub(r"[^\w]+", " ", folded, flags=re.UNICODE).split())
|
||||
|
||||
|
||||
_MISSING_META_WORDS = {
|
||||
"không", "chưa", "thiếu", "nêu", "đề", "cập", "đến", "yêu", "cầu",
|
||||
"ghi", "nhận", "thông", "tin", "trong", "câu", "trả", "lời", "về",
|
||||
"của", "cho", "và", "hoặc", "các", "một", "những",
|
||||
}
|
||||
|
||||
|
||||
def _quote_supports_missing_description(description: str, quote: str) -> bool:
|
||||
"""The cited quote must actually contain the fact described as missing."""
|
||||
description_tokens = {
|
||||
token
|
||||
for token in _normalise_for_coverage(description).split()
|
||||
if len(token) > 1 and token not in _MISSING_META_WORDS
|
||||
}
|
||||
quote_tokens = set(_normalise_for_coverage(quote).split())
|
||||
if not description_tokens:
|
||||
return False
|
||||
description_numbers = {token for token in description_tokens if any(c.isdigit() for c in token)}
|
||||
if not description_numbers.issubset(quote_tokens):
|
||||
return False
|
||||
overlap = len(description_tokens & quote_tokens) / len(description_tokens)
|
||||
return overlap >= 0.5
|
||||
|
||||
|
||||
def _missing_is_already_explicit(
|
||||
missing: tuple[str, ...],
|
||||
claims: tuple[tuple[str, tuple[int, ...]], ...],
|
||||
) -> bool:
|
||||
"""Reject a judge contradiction when every quoted 'missing' fact is present.
|
||||
|
||||
The semantic judge occasionally reports that an exact condition is absent
|
||||
while quoting that condition verbatim from a claim that already contains it.
|
||||
This narrow check only resolves that self-contradiction; unquoted or
|
||||
paraphrased missing facts still fail closed and enter the repair path.
|
||||
"""
|
||||
answer = _normalise_for_coverage("\n".join(text for text, _ in claims))
|
||||
if not missing:
|
||||
return False
|
||||
for item in missing:
|
||||
quoted = [
|
||||
value for value in re.findall(r"['\"]([^'\"]{8,})['\"]", item)
|
||||
if _normalise_for_coverage(value)
|
||||
]
|
||||
if not quoted or not all(_normalise_for_coverage(value) in answer for value in quoted):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class GroundedAnswerService:
|
||||
"""Retrieval decides what is true; generation only decides how it reads.
|
||||
|
||||
@@ -131,6 +434,7 @@ class GroundedAnswerService:
|
||||
def answer_from_result(
|
||||
self, query: str, result: RetrievalResult, list_mode: bool = False,
|
||||
budget: RequestBudget | None = None,
|
||||
prechecked: bool = False,
|
||||
) -> GroundedAnswer:
|
||||
"""Everything after retrieval — grounding, sufficiency, generation,
|
||||
citations. Split out so the new understanding-driven orchestrator
|
||||
@@ -146,9 +450,15 @@ class GroundedAnswerService:
|
||||
already skips it.
|
||||
|
||||
`budget` (F-08): threaded through to every LLM call this method
|
||||
makes (sufficiency, generate, up to 2 entailment). `None` (the
|
||||
makes (sufficiency, generate, one entailment verification). `None` (the
|
||||
default) means unbounded, unchanged from before F-08 — only
|
||||
`RagAgent` constructs a real budget today.
|
||||
|
||||
`prechecked=True` means the structured agent has already enforced its
|
||||
required input fields. The generation contract still has its own
|
||||
`evidence_sufficient`/clarifying-question gate for ambiguity visible
|
||||
only after retrieval, so the separate same-model sufficiency call is
|
||||
redundant on that path. The legacy direct-answer path keeps it.
|
||||
"""
|
||||
if result.decision == EvidenceDecision.ABSTAIN:
|
||||
self._metrics.increment(metric_names.ABSTENTION, reason=result.reason)
|
||||
@@ -178,15 +488,13 @@ class GroundedAnswerService:
|
||||
)
|
||||
|
||||
evidence_texts = tuple(item.text for item in result.evidence)
|
||||
extractive = "\n\n".join(
|
||||
f"{text} [{index}]" for index, text in enumerate(evidence_texts, start=1)
|
||||
)
|
||||
|
||||
prompt_evidence_texts = _prompt_evidence_texts(result.evidence)
|
||||
plan = _plan_answer(query, result, list_mode)
|
||||
# Reasoning step BEFORE answering: if the turn is under-specified (a dose
|
||||
# with several bands and no age/weight/condition), ask instead of dumping.
|
||||
# A separate focused call is more reliable than folding it into generation.
|
||||
sufficiency = (
|
||||
None if list_mode
|
||||
None if list_mode or prechecked
|
||||
else self._check_sufficiency(
|
||||
query, evidence_texts, result.is_drug_overview, budget=budget
|
||||
)
|
||||
@@ -198,15 +506,20 @@ class GroundedAnswerService:
|
||||
)
|
||||
|
||||
outcome = self._generate(
|
||||
query, evidence_texts, intro=result.is_drug_overview, list_mode=list_mode,
|
||||
budget=budget,
|
||||
query, evidence_texts, prompt_evidence_texts,
|
||||
intro=result.is_drug_overview, list_mode=list_mode, budget=budget,
|
||||
plan=plan,
|
||||
)
|
||||
if outcome.clarification is not None:
|
||||
# The model judged the turn under-specified (a dose with no
|
||||
# age/weight/renal-function/indication…) and asked back instead of
|
||||
# listing every band. Return the question, not the whole section.
|
||||
return GroundedAnswer(
|
||||
result, outcome.clarification, (), clarification=outcome.clarification
|
||||
result,
|
||||
outcome.clarification,
|
||||
(),
|
||||
clarification=outcome.clarification,
|
||||
quick_replies=outcome.quick_replies,
|
||||
)
|
||||
|
||||
if outcome.answer is None:
|
||||
@@ -215,8 +528,19 @@ class GroundedAnswerService:
|
||||
# deliberate operating mode (e.g. ANSWER_PROVIDER=disabled),
|
||||
# not a failure, so the source is quoted verbatim.
|
||||
self._metrics.increment(metric_names.ANSWER_EXTRACTIVE)
|
||||
citations = self._cited_only(indexed, extractive) or all_citations
|
||||
return GroundedAnswer(result, extractive, citations)
|
||||
claims = tuple(
|
||||
(text, (index,))
|
||||
for index, text in enumerate(evidence_texts, start=1)
|
||||
)
|
||||
blocks = _build_blocks(claims, indexed)
|
||||
return GroundedAnswer(
|
||||
result,
|
||||
"\n".join(text for text, _ in claims),
|
||||
all_citations,
|
||||
blocks=blocks,
|
||||
answer_mode=plan.verbosity,
|
||||
plan=plan,
|
||||
)
|
||||
# A generator WAS configured and this specific generation did not
|
||||
# clear the safety checks (provider outage, malformed output, an
|
||||
# ungrounded/uncited/unsupported claim). This product is a real
|
||||
@@ -248,9 +572,25 @@ class GroundedAnswerService:
|
||||
# Show only the sources the answer actually cited, not every chunk that
|
||||
# was retrieved — a paragraph that cites [4] must not drag 13 citation
|
||||
# chips onto the screen. Falls back to all when the text cites nothing.
|
||||
citations = self._cited_only(indexed, outcome.answer) or all_citations
|
||||
cited_indices = tuple(dict.fromkeys(
|
||||
index
|
||||
for _, claim_indices in outcome.claims
|
||||
for index in claim_indices
|
||||
if 1 <= index <= len(indexed)
|
||||
))
|
||||
citations = tuple(indexed[index - 1][1] for index in cited_indices)
|
||||
blocks = _build_blocks(outcome.claims, indexed)
|
||||
clean_answer = "\n".join(text for text, _ in outcome.claims)
|
||||
self._metrics.increment(metric_names.GENERATION_SERVED)
|
||||
return GroundedAnswer(result, outcome.answer, citations, generated=True)
|
||||
return GroundedAnswer(
|
||||
result,
|
||||
clean_answer,
|
||||
citations,
|
||||
generated=True,
|
||||
blocks=blocks,
|
||||
answer_mode=plan.verbosity,
|
||||
plan=plan,
|
||||
)
|
||||
|
||||
def _attempt_generation(
|
||||
self, request: "GenerationRequest", budget: RequestBudget | None
|
||||
@@ -269,7 +609,7 @@ class GroundedAnswerService:
|
||||
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
answer = payload["answer"]
|
||||
raw_claims = payload["claims"]
|
||||
sufficient = payload["evidence_sufficient"]
|
||||
except (ValueError, TypeError, KeyError):
|
||||
return _RawAttempt(malformed=True)
|
||||
@@ -279,23 +619,49 @@ class GroundedAnswerService:
|
||||
# claim, so it skips the number check — it states no dose.
|
||||
clarify = payload.get("clarifying_question") if isinstance(payload, dict) else None
|
||||
if isinstance(clarify, str) and clarify.strip():
|
||||
return _RawAttempt(clarification=clarify.strip())
|
||||
return _RawAttempt(
|
||||
clarification=clarify.strip(),
|
||||
quick_replies=_sanitize_quick_replies(payload.get("quick_replies")),
|
||||
)
|
||||
|
||||
if not isinstance(answer, str) or not isinstance(sufficient, bool):
|
||||
if not isinstance(raw_claims, list) or not isinstance(sufficient, bool):
|
||||
return _RawAttempt(malformed=True)
|
||||
if not sufficient:
|
||||
return _RawAttempt(insufficient=True)
|
||||
return _RawAttempt(answer=answer)
|
||||
|
||||
claims = _parse_claims(raw_claims)
|
||||
if claims is None:
|
||||
return _RawAttempt(malformed=True)
|
||||
return _RawAttempt(answer=_assemble_answer(claims), claims=claims)
|
||||
|
||||
def _generate(
|
||||
self, query: str, evidence_texts: tuple[str, ...], intro: bool = False,
|
||||
list_mode: bool = False, budget: RequestBudget | None = None,
|
||||
self,
|
||||
query: str,
|
||||
evidence_texts: tuple[str, ...],
|
||||
prompt_evidence_texts: tuple[str, ...] | None = None,
|
||||
*,
|
||||
intro: bool = False,
|
||||
list_mode: bool = False,
|
||||
budget: RequestBudget | None = None,
|
||||
plan: AnswerPlan | None = None,
|
||||
) -> "_GenOutcome":
|
||||
"""A verified generation, a clarifying question, or empty to fall back."""
|
||||
if self._generator is None or not evidence_texts:
|
||||
return _GenOutcome()
|
||||
|
||||
request = build_request(query, evidence_texts, intro=intro, list_mode=list_mode)
|
||||
shown_evidence = prompt_evidence_texts or evidence_texts
|
||||
plan = plan or AnswerPlan("normal", "prose", "direct_lookup")
|
||||
request = build_request(
|
||||
query,
|
||||
shown_evidence,
|
||||
intro=intro,
|
||||
list_mode=list_mode,
|
||||
answer_mode=plan.verbosity,
|
||||
layout=plan.layout,
|
||||
reasoning_mode=plan.reasoning_mode,
|
||||
show_heading=plan.show_heading,
|
||||
needs_warning=plan.needs_warning,
|
||||
)
|
||||
attempt = self._attempt_generation(request, budget)
|
||||
if attempt.insufficient:
|
||||
# Empirically noisy (found live 2026-08-07, reproduced 3/3 on a
|
||||
@@ -324,7 +690,10 @@ class GroundedAnswerService:
|
||||
)
|
||||
return _GenOutcome(reject_reason="malformed_output")
|
||||
if attempt.clarification is not None:
|
||||
return _GenOutcome(clarification=attempt.clarification)
|
||||
return _GenOutcome(
|
||||
clarification=attempt.clarification,
|
||||
quick_replies=attempt.quick_replies,
|
||||
)
|
||||
if attempt.insufficient:
|
||||
# The model says the evidence does not answer the question, on
|
||||
# both attempts. Showing the retrieved section verbatim lets the
|
||||
@@ -342,17 +711,62 @@ class GroundedAnswerService:
|
||||
)
|
||||
return _GenOutcome(reject_reason=report.reason)
|
||||
|
||||
if not self._verify_entailment(answer, evidence_texts, budget=budget):
|
||||
verification = self._verify_entailment(
|
||||
query, attempt.claims, shown_evidence, budget=budget
|
||||
)
|
||||
if verification is None or not verification.supported:
|
||||
self._metrics.increment(
|
||||
metric_names.GENERATION_REJECTED, reason="unsupported_claim"
|
||||
)
|
||||
return _GenOutcome(reject_reason="unsupported_claim")
|
||||
return _GenOutcome(answer=answer)
|
||||
if not verification.complete:
|
||||
missing = "; ".join(verification.missing) or "dữ kiện liên quan trong nguồn"
|
||||
logger.warning(
|
||||
"answer completeness repair: query=%r missing=%r claims=%r",
|
||||
query,
|
||||
verification.missing,
|
||||
attempt.claims,
|
||||
)
|
||||
repair_request = GenerationRequest(
|
||||
system=request.system,
|
||||
user=(
|
||||
f"{request.user}\n\nBẢN TRƯỚC ĐÃ BỊ LOẠI VÌ THIẾU: {missing}. "
|
||||
"Hãy tạo lại từ đầu, giữ đúng phạm vi câu hỏi nhưng bổ sung các "
|
||||
"nhãn, điều kiện, con số và mục liên quan bị thiếu."
|
||||
),
|
||||
schema=request.schema,
|
||||
)
|
||||
repaired = self._attempt_generation(repair_request, budget)
|
||||
if repaired.answer is not None:
|
||||
repaired_report = grounding.verify(repaired.answer, evidence_texts)
|
||||
repaired_verification = self._verify_entailment(
|
||||
query, repaired.claims, shown_evidence, budget=budget
|
||||
)
|
||||
logger.warning(
|
||||
"answer completeness repair result: claims=%r verdict=%r",
|
||||
repaired.claims,
|
||||
repaired_verification,
|
||||
)
|
||||
if (
|
||||
repaired_report.grounded
|
||||
and repaired_verification is not None
|
||||
and repaired_verification.supported
|
||||
and repaired_verification.complete
|
||||
):
|
||||
return _GenOutcome(answer=repaired.answer, claims=repaired.claims)
|
||||
self._metrics.increment(
|
||||
metric_names.GENERATION_REJECTED, reason="incomplete_answer"
|
||||
)
|
||||
return _GenOutcome(reject_reason="incomplete_answer")
|
||||
return _GenOutcome(answer=answer, claims=attempt.claims)
|
||||
|
||||
def _verify_entailment(
|
||||
self, answer: str, evidence_texts: tuple[str, ...],
|
||||
self,
|
||||
query: str,
|
||||
structured_claims: tuple[tuple[str, tuple[int, ...]], ...],
|
||||
evidence_texts: tuple[str, ...],
|
||||
budget: RequestBudget | None = None,
|
||||
) -> bool:
|
||||
) -> _VerificationOutcome | None:
|
||||
"""A second, adversarial LLM pass over an answer that already passed
|
||||
`grounding.verify`.
|
||||
|
||||
@@ -364,45 +778,57 @@ class GroundedAnswerService:
|
||||
is checked against only the evidence block(s) it names, by a model
|
||||
told to compare wording, not to reason about medicine.
|
||||
|
||||
Fails closed on an outage or malformed output — that failure mode is
|
||||
reliable, not noisy, so it stops immediately rather than spending
|
||||
retries on it. A single rejection is NOT reliable: live probing
|
||||
(2026-08-06) found the judge call itself is noisy — the identical
|
||||
claim/evidence pair, called three times, came back entailed twice
|
||||
and rejected once, discarding a correct, well-cited interaction
|
||||
answer. Up to `_ENTAILMENT_MAX_ATTEMPTS` same-claim calls run;
|
||||
accept on the first `True`, discard only if every attempt agrees
|
||||
reject. Widened from 2 to 3 attempts 2026-08-07 after a live
|
||||
adversarial sample (50 real questions) measured this specific check
|
||||
as roughly half of all false abstentions on genuinely answerable
|
||||
questions. Trade-off, stated plainly: this raises the bar a
|
||||
genuinely fabricated claim must now clear too (it survives if ANY
|
||||
one of 3 noisy calls wrongly accepts it, not just 1 of 2) — accepted
|
||||
because the probed noise is symmetric and the entailment prompt
|
||||
itself is unchanged, not because the risk is zero.
|
||||
An answer with no claim text at all (nothing between or after its
|
||||
citation markers) is vacuously fine — nothing to verify, no call.
|
||||
Fails closed on an outage, malformed output, budget exhaustion, or a
|
||||
negative verdict. The same deterministic model and temperature are
|
||||
used for every judge call, so repeating the identical prompt is not
|
||||
independent evidence: it adds correlated latency and can amplify a
|
||||
false acceptance. One bounded adversarial pass is the safer default;
|
||||
judge quality is measured with an eval set instead of manufactured
|
||||
by retrying the same request.
|
||||
Reads the model's OWN claim/citation boundaries (2026-08-10
|
||||
structured-claims change) instead of re-deriving them from the
|
||||
assembled text via regex — `structured_claims` is exactly what the
|
||||
model returned, already validated by `_parse_claims`. A claim with
|
||||
no in-range citation, or no real content, has nothing to check
|
||||
against and is skipped, same as before.
|
||||
"""
|
||||
claims = [
|
||||
(claim.text, "\n".join(evidence_texts[i - 1] for i in claim.indices))
|
||||
for claim in grounding.split_claims(answer, len(evidence_texts))
|
||||
if claim.indices and grounding.has_content(claim.text)
|
||||
(
|
||||
text,
|
||||
"\n".join(
|
||||
evidence_texts[i - 1] for i in citations
|
||||
if 1 <= i <= len(evidence_texts)
|
||||
),
|
||||
)
|
||||
for text, citations in structured_claims
|
||||
if any(1 <= i <= len(evidence_texts) for i in citations)
|
||||
and grounding.has_content(text)
|
||||
]
|
||||
if not claims:
|
||||
return True
|
||||
return _VerificationOutcome(supported=True, complete=True)
|
||||
|
||||
request = build_entailment_request(claims)
|
||||
request = build_entailment_request(query, claims, evidence_texts)
|
||||
for _ in range(_ENTAILMENT_MAX_ATTEMPTS):
|
||||
verdict = self._run_entailment_check(request, budget=budget)
|
||||
verdict = self._run_entailment_check(
|
||||
request, evidence_texts=evidence_texts, budget=budget
|
||||
)
|
||||
if verdict is None:
|
||||
return False
|
||||
if verdict:
|
||||
return True
|
||||
return None
|
||||
if (
|
||||
verdict.supported
|
||||
and not verdict.complete
|
||||
and _missing_is_already_explicit(verdict.missing, structured_claims)
|
||||
):
|
||||
return _VerificationOutcome(supported=True, complete=True)
|
||||
return verdict
|
||||
return False
|
||||
|
||||
def _run_entailment_check(
|
||||
self, request, budget: RequestBudget | None = None
|
||||
) -> bool | None:
|
||||
self,
|
||||
request,
|
||||
evidence_texts: tuple[str, ...],
|
||||
budget: RequestBudget | None = None,
|
||||
) -> _VerificationOutcome | None:
|
||||
"""One entailment call. `None` = outage/malformed/budget-exhausted
|
||||
(fails closed by the caller without a retry); `True`/`False` = the
|
||||
judge's verdict."""
|
||||
@@ -416,11 +842,49 @@ class GroundedAnswerService:
|
||||
payload = json.loads(raw)
|
||||
entailed = payload["entailed"]
|
||||
unsupported = payload["unsupported"]
|
||||
missing_evidence = payload.get("missing_evidence", [])
|
||||
except (ValueError, TypeError, KeyError):
|
||||
return None
|
||||
if not isinstance(entailed, bool) or not isinstance(unsupported, list):
|
||||
if (
|
||||
not isinstance(entailed, bool)
|
||||
or not isinstance(unsupported, list)
|
||||
or not isinstance(missing_evidence, list)
|
||||
):
|
||||
return None
|
||||
return entailed and not unsupported
|
||||
if not entailed or unsupported:
|
||||
return _VerificationOutcome(supported=False, complete=False)
|
||||
# A completeness objection is itself a factual claim about the raw
|
||||
# evidence. Require the judge to point to an exact source quote and
|
||||
# validate it locally. This prevents false objections such as
|
||||
# "missing humidity" when the storage section never mentions humidity,
|
||||
# which previously discarded a fully grounded answer after two extra
|
||||
# model calls. Entailment still fails closed; only ungrounded
|
||||
# *completeness objections* are ignored.
|
||||
normalised_evidence = tuple(
|
||||
_normalise_for_coverage(text) for text in evidence_texts
|
||||
)
|
||||
grounded_missing: list[str] = []
|
||||
for item in missing_evidence:
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
description = item.get("description")
|
||||
evidence_quote = item.get("evidence_quote")
|
||||
if not isinstance(description, str) or not isinstance(evidence_quote, str):
|
||||
return None
|
||||
description = description.strip()
|
||||
quote = _normalise_for_coverage(evidence_quote)
|
||||
if (
|
||||
description
|
||||
and len(quote) >= 4
|
||||
and any(quote in evidence for evidence in normalised_evidence)
|
||||
and _quote_supports_missing_description(description, evidence_quote)
|
||||
):
|
||||
grounded_missing.append(description)
|
||||
return _VerificationOutcome(
|
||||
supported=True,
|
||||
complete=not grounded_missing,
|
||||
missing=tuple(grounded_missing),
|
||||
)
|
||||
|
||||
def _check_sufficiency(
|
||||
self, query: str, evidence_texts: tuple[str, ...], intro: bool = False,
|
||||
@@ -458,12 +922,7 @@ class GroundedAnswerService:
|
||||
if isinstance(payload, dict) and payload.get("sufficient") is False:
|
||||
question = payload.get("clarifying_question")
|
||||
if isinstance(question, str) and question.strip():
|
||||
raw_replies = payload.get("quick_replies")
|
||||
replies = tuple(
|
||||
reply.strip()
|
||||
for reply in raw_replies
|
||||
if isinstance(reply, str) and reply.strip()
|
||||
) if isinstance(raw_replies, list) else ()
|
||||
replies = _sanitize_quick_replies(payload.get("quick_replies"))
|
||||
return question.strip(), replies
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
"""A per-request LLM-call budget — F-08.
|
||||
|
||||
`RagAgent.handle()` makes up to ~5 sequential Bedrock calls per turn
|
||||
(understand, sufficiency, generate, up to 2 entailment retries) with no
|
||||
aggregate deadline before this: each call is bounded only by its own fixed
|
||||
provider timeout (`read_timeout=60` in `adapters/bedrock_converse.py`, times
|
||||
up to 3 retries at "standard" backoff — worst case several minutes for one
|
||||
stuck call, let alone five). Measured live 2026-08-07: a normal answerable
|
||||
(understand, sufficiency, generate, entailment) with no aggregate deadline
|
||||
before this. Each call now has at most two bounded provider attempts
|
||||
(`read_timeout=20` in `adapters/bedrock_converse.py`) because this budget is
|
||||
checked between calls and cannot cancel boto3 while it is already in flight.
|
||||
Measured live 2026-08-07: a normal answerable
|
||||
turn costs ~8-9s total; nothing bounds the pathological case.
|
||||
|
||||
Checked before each call, not wrapped around an already-running one — this
|
||||
bounds how many MORE calls get a chance to start once time/calls run out. It
|
||||
does not cancel a call already in flight past its own provider timeout; a
|
||||
hard per-call cancellation would need cooperative cancellation support from
|
||||
`adapters/bedrock_converse.py`'s boto3 client, a larger change than this
|
||||
budget object alone. Still a real improvement: five calls each capable of
|
||||
running to their own 60s+ limit, one after another, is the actual gap this
|
||||
closes.
|
||||
`adapters/bedrock_converse.py`'s boto3 client. The adapter-level timeout bounds
|
||||
that residual gap; this object prevents any later call from starting after
|
||||
the aggregate deadline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
+149
-20
@@ -28,9 +28,11 @@ Quy tắc bắt buộc:
|
||||
chứng (ví dụ "người lớn", "trẻ em", "suy thận", "đường uống"). TUYỆT ĐỐI
|
||||
không gán liều của đối tượng này cho đối tượng khác, và không gộp các liều
|
||||
khác đối tượng thành một.
|
||||
4. Gắn số nguồn [n] cho từng ý, với n là đoạn bằng chứng THỰC SỰ chứa ý đó.
|
||||
Chỉ trích [n] nếu đọc đoạn n thấy đúng ý đang nói. Không lặp lại cùng một
|
||||
[n] ở mọi câu — gắn một lần cho một cụm cùng nguồn là đủ. Không bịa số [n].
|
||||
4. KHÔNG viết câu trả lời thành 1 đoạn văn tự do. Thay vào đó, TÁCH câu trả lời
|
||||
thành từng "claims" — mỗi claim là MỘT ý độc lập, kèm "citations" là danh
|
||||
sách số đoạn bằng chứng (1-based) THỰC SỰ chứa ý đó. Một claim chỉ được
|
||||
trích citations mà đọc đúng đoạn đó thấy đúng ý đang nói — không bịa số.
|
||||
Nhiều claim có thể trích cùng citations nếu chúng thực sự cùng nguồn.
|
||||
5. Nếu BẰNG CHỨNG không đủ (thiếu đối tượng được hỏi, thiếu con số, hoặc chỉ nói
|
||||
chung chung), nói rõ là không đủ và đặt evidence_sufficient=false. Đó là câu
|
||||
trả lời hợp lệ. Không suy diễn để lấp chỗ trống. TRƯỜNG HỢP NÀY BẮT BUỘC LUÔN
|
||||
@@ -45,30 +47,78 @@ Quy tắc bắt buộc:
|
||||
không chuyên.
|
||||
7. HỎI LẠI khi thiếu dữ kiện — ĐÂY LÀ QUY TẮC QUAN TRỌNG NHẤT, ưu tiên hơn việc
|
||||
trả lời. TUYỆT ĐỐI KHÔNG liệt kê nhiều mức liều rồi để người đọc tự chọn.
|
||||
NGOẠI LỆ BẮT BUỘC: nếu CÂU HỎI ghi rõ đây là "tra cứu tổng quan toàn mục"
|
||||
hoặc yêu cầu Dược thư trình bày các đường dùng/liều/đối tượng "nếu có", đây
|
||||
KHÔNG phải yêu cầu chọn liều cho một người bệnh. Khi đó phải liệt kê các nhánh
|
||||
có trong BẰNG CHỨNG, giữ nguyên nhãn đối tượng/đường dùng/chỉ định; không hỏi
|
||||
người lớn/trẻ em chỉ để thu hẹp và không tạo quick-reply chip.
|
||||
Nếu là câu hỏi LIỀU/CÁCH DÙNG và bằng chứng phân mức theo điều kiện (tuổi,
|
||||
cân nặng, chức năng thận/gan, chỉ định/bệnh, đường dùng, mức độ nặng…) mà
|
||||
người dùng CHƯA nêu đủ (các) điều kiện để chọn ĐÚNG MỘT mức, thì BẮT BUỘC:
|
||||
để `answer`="", `evidence_sufficient`=false, và đặt `clarifying_question`
|
||||
để `claims`=[] (rỗng), `evidence_sufficient`=false, và đặt `clarifying_question`
|
||||
hỏi NGẮN GỌN tất cả dữ kiện còn thiếu.
|
||||
- "trẻ em" hay "cho trẻ" nói chung là CHƯA đủ (liều trẻ em thay đổi theo
|
||||
tuổi/cân nặng) → phải hỏi lại, KHÔNG được liệt kê các nhóm tuổi.
|
||||
- "người lớn" thường là đủ cho liều người lớn tiêu chuẩn → trả lời được.
|
||||
- "người lớn" chỉ đủ khi BẰNG CHỨNG có đúng một đường dùng phù hợp, hoặc câu
|
||||
hỏi đã nêu đường dùng. Nếu bằng chứng có nhiều đường dùng (uống/đặt/tiêm…)
|
||||
mà người dùng chưa chọn → phải hỏi đường dùng, không liệt kê tất cả.
|
||||
Ví dụ clarifying_question: "Bé mấy tuổi, cân nặng bao nhiêu kg, dùng đường
|
||||
nào (uống/đặt hậu môn/tiêm) và để hạ sốt hay giảm đau?".
|
||||
Nếu đã đủ dữ kiện thì trả lời bình thường, `clarifying_question`=null.
|
||||
8. `quick_replies` chỉ phục vụ một `clarifying_question` thực sự cần thiết:
|
||||
- Khi evidence_sufficient=true: BẮT BUỘC quick_replies=[]; trả lời thẳng, không
|
||||
tạo chip cho có.
|
||||
- Khi evidence_sufficient=false vì còn mơ hồ và câu hỏi lại có 2-4 lựa chọn
|
||||
rời rạc tự nhiên: tự sinh 2-4 quick_replies ngắn dựa trên chính các nhánh
|
||||
thấy trong BẰNG CHỨNG (ví dụ các đường dùng xuất hiện trong nguồn).
|
||||
- Nếu cần người dùng nhập một giá trị cụ thể như tuổi/cân nặng, hoặc nguồn
|
||||
hoàn toàn không đủ để hình thành lựa chọn an toàn: quick_replies=[].
|
||||
|
||||
Viết gọn. Trả lời đúng điều được hỏi, không mở rộng phạm vi."""
|
||||
9. LẬP KẾ HOẠCH ĐỘ CHI TIẾT THEO CÂU HỎI:
|
||||
- Hỏi một dữ kiện cụ thể: trả lời trực tiếp, ngắn, không kéo cả chuyên luận vào.
|
||||
- Hỏi "đầy đủ", "tất cả", hoặc hỏi cả một mục rộng như toàn bộ ADR/tương tác:
|
||||
phải bao phủ TẤT CẢ dữ kiện trực tiếp thuộc mục đó trong BẰNG CHỨNG.
|
||||
- Với danh sách có cấu trúc, KHÔNG tách mỗi thuật ngữ thành một claim rời. Mỗi
|
||||
claim phải giữ một nhóm có nghĩa và ghi rõ nhãn của sách, ví dụ
|
||||
"Thường gặp — Tiêu hóa: buồn nôn, đau bụng, biếng ăn".
|
||||
- Nhãn tần suất/mức độ, nhóm cơ quan và điều kiện bao trùm một đoạn (ví dụ
|
||||
"khi dùng liều cao để điều trị ung thư phụ thuộc hormon") là DỮ KIỆN LÂM SÀNG,
|
||||
không phải trang trí. Bắt buộc giữ chúng bên cạnh đúng các mục chúng chi phối.
|
||||
Nếu một nhãn đầu mục áp dụng cho nhiều dòng phía sau cho tới đầu mục kế tiếp,
|
||||
phải LẶP LẠI nhãn đó trong từng claim liên quan; không bắt người đọc suy ra từ
|
||||
claim đứng trước.
|
||||
|
||||
Viết gọn trong phạm vi độ chi tiết người dùng yêu cầu. Không mở rộng phạm vi."""
|
||||
|
||||
|
||||
ANSWER_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": {
|
||||
"type": "string",
|
||||
"claims": {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"Câu trả lời cho bác sĩ/dược sĩ, mỗi ý gắn [n] chỉ nguồn. "
|
||||
"Mọi con số chép nguyên văn từ bằng chứng."
|
||||
"Câu trả lời TÁCH thành từng ý độc lập cho bác sĩ/dược sĩ — "
|
||||
"KHÔNG phải một đoạn văn tự do. Rỗng nếu evidence_sufficient=false."
|
||||
),
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Một ý, không chứa số nguồn — số nguồn đi riêng ở citations.",
|
||||
},
|
||||
"citations": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"description": (
|
||||
"Số đoạn bằng chứng (1-based) THỰC SỰ chứa ý này. "
|
||||
"Không được rỗng trừ khi claim không cần trích dẫn."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["text", "citations"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"evidence_sufficient": {
|
||||
"type": "boolean",
|
||||
@@ -84,8 +134,18 @@ ANSWER_SCHEMA = {
|
||||
"null CHỈ khi evidence_sufficient=true."
|
||||
),
|
||||
},
|
||||
"quick_replies": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"maxItems": 4,
|
||||
"description": (
|
||||
"2-4 câu trả lời ngắn do model đề xuất CHỈ khi cần hỏi lại và "
|
||||
"có vài lựa chọn tự nhiên trong bằng chứng; rỗng khi trả lời "
|
||||
"được ngay hoặc cần nhập giá trị tự do."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["answer", "evidence_sufficient"],
|
||||
"required": ["claims", "evidence_sufficient", "quick_replies"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
@@ -100,7 +160,9 @@ Quy tắc:
|
||||
kiện (tuổi, cân nặng, chức năng thận/gan, chỉ định/bệnh, đường dùng, mức độ
|
||||
nặng) mà CÂU HỎI chưa nêu đủ (các) điều kiện để chọn đúng MỘT mức → CHƯA đủ.
|
||||
- "trẻ em" / "cho trẻ" / "cho bé" nói chung là CHƯA đủ (liều trẻ thay đổi theo
|
||||
tuổi và cân nặng). "người lớn" thường ĐỦ cho liều người lớn tiêu chuẩn.
|
||||
tuổi và cân nặng). "người lớn" chỉ ĐỦ khi bằng chứng có một đường dùng phù
|
||||
hợp hoặc câu hỏi đã nêu đường dùng; nếu có nhiều đường dùng mà chưa chọn thì
|
||||
phải hỏi tiếp đường dùng, không đổ tất cả các mức ra cho người đọc tự chọn.
|
||||
- Câu hỏi KHÔNG về liều (chống chỉ định, tương tác, tác dụng phụ, giới thiệu
|
||||
thuốc…) thì thường ĐỦ.
|
||||
- Câu hỏi "thận trọng"/"an toàn khi dùng cho bệnh nhân [tình trạng cụ thể]"
|
||||
@@ -181,22 +243,58 @@ nếu tên thuốc trong CÂU xuất hiện ở bất kỳ đâu trong danh sác
|
||||
quan hệ đang nói (vd "làm tăng tác dụng của X"), đó LÀ được chứng thực, dù
|
||||
tên thuốc chỉ là một mục nhỏ giữa danh sách dài.
|
||||
|
||||
Trả về DUY NHẤT JSON: {"entailed": bool, "unsupported": [danh sách số thứ tự
|
||||
1-based của các CÂU KHÔNG được chứng thực; rỗng nếu tất cả đều được chứng
|
||||
thực]}. entailed=true chỉ khi unsupported rỗng."""
|
||||
Sau kiểm tra từng claim, kiểm tra ĐỘ ĐẦY ĐỦ: đối chiếu CÂU HỎI GỐC với TOÀN BỘ
|
||||
BẰNG CHỨNG ĐÃ CHỌN. Nếu câu trả lời bỏ sót dữ kiện trực tiếp được hỏi (điều kiện,
|
||||
đối tượng, con số, mức độ/tần suất hoặc mục trong danh sách), đặt complete=false.
|
||||
Không coi nội dung ngoài phạm vi câu hỏi là thiếu.
|
||||
Chỉ được ghi thiếu một dữ kiện khi dữ kiện đó THỰC SỰ XUẤT HIỆN trong TOÀN BỘ
|
||||
BẰNG CHỨNG ĐÃ CHỌN nhưng không có trong câu trả lời. Nếu câu hỏi nói "độ ẩm hoặc
|
||||
bao bì", "điều kiện đặc biệt nếu có" mà bằng chứng không nêu độ ẩm/bao bì/điều
|
||||
kiện đặc biệt, sự im lặng đó KHÔNG phải lỗi thiếu. Không yêu cầu câu trả lời bịa
|
||||
ra một mục chỉ vì từ đó xuất hiện trong câu hỏi.
|
||||
|
||||
Đặc biệt với câu hỏi "đầy đủ/tất cả" về ADR, tương tác hoặc một mục rộng: mất nhãn
|
||||
"thường gặp/ít gặp", mất nhóm cơ quan, hoặc kéo các dữ kiện ra khỏi điều kiện bao
|
||||
trùm như "khi dùng liều cao để điều trị ung thư phụ thuộc hormon" đều là KHÔNG
|
||||
ĐẦY ĐỦ, dù tên từng dữ kiện vẫn xuất hiện. Khi đó complete=false và
|
||||
missing_evidence phải nêu đúng nhãn/điều kiện bị mất.
|
||||
|
||||
Trả về DUY NHẤT JSON: {"entailed": bool, "unsupported": [số thứ tự claim],
|
||||
"complete": bool, "missing_evidence": [{"description": mô tả ngắn dữ kiện bị
|
||||
bỏ sót, "evidence_quote": trích nguyên văn ngắn từ bằng chứng chứa dữ kiện đó}]}.
|
||||
Mỗi mục thiếu BẮT BUỘC có evidence_quote chép nguyên văn từ bằng chứng. Không tìm
|
||||
được câu trích thì không được ghi mục đó là thiếu.
|
||||
entailed=true chỉ khi unsupported rỗng; complete=true chỉ khi missing_evidence rỗng."""
|
||||
|
||||
ENTAILMENT_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entailed": {"type": "boolean"},
|
||||
"unsupported": {"type": "array", "items": {"type": "integer"}},
|
||||
"complete": {"type": "boolean"},
|
||||
"missing_evidence": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {"type": "string"},
|
||||
"evidence_quote": {"type": "string"},
|
||||
},
|
||||
"required": ["description", "evidence_quote"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["entailed", "unsupported"],
|
||||
"required": ["entailed", "unsupported", "complete", "missing_evidence"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
def build_entailment_request(claims: list[tuple[str, str]]) -> "GenerationRequest":
|
||||
def build_entailment_request(
|
||||
question: str,
|
||||
claims: list[tuple[str, str]],
|
||||
all_evidence: tuple[str, ...],
|
||||
) -> "GenerationRequest":
|
||||
"""`claims` is a list of (claim_text, cited_evidence_text) pairs — already
|
||||
filtered by the caller to the claims worth checking (substantive content,
|
||||
a validly-cited evidence block to check it against)."""
|
||||
@@ -206,13 +304,33 @@ def build_entailment_request(claims: list[tuple[str, str]]) -> "GenerationReques
|
||||
f"CÂU {index}: {claim}\nBẰNG CHỨNG ĐƯỢC TRÍCH: {evidence}"
|
||||
for index, (claim, evidence) in enumerate(claims, start=1)
|
||||
)
|
||||
user = f"{blocks}\n\nKiểm tra từng CÂU theo đúng BẰNG CHỨNG ĐƯỢC TRÍCH của nó."
|
||||
evidence = "\n\n".join(
|
||||
f"NGUỒN {index}: {text}"
|
||||
for index, text in enumerate(all_evidence, start=1)
|
||||
)
|
||||
user = (
|
||||
f"CÂU HỎI GỐC: {question}\n\n{blocks}\n\n"
|
||||
f"TOÀN BỘ BẰNG CHỨNG ĐÃ CHỌN:\n{evidence}\n\n"
|
||||
"Kiểm tra hai chiều. (1) Từng CÂU phải được đúng bằng chứng trích dẫn "
|
||||
"chứng thực. (2) So với CÂU HỎI GỐC và TOÀN BỘ BẰNG CHỨNG, câu trả lời "
|
||||
"phải đủ các dữ kiện trực tiếp liên quan: không làm rơi điều kiện áp dụng, "
|
||||
"đối tượng, con số, mức độ/tần suất hoặc các mục trong danh sách mà người "
|
||||
"dùng yêu cầu. complete=false và liệt kê ngắn trong missing_evidence nếu "
|
||||
"còn thiếu; mỗi mục phải kèm evidence_quote NGUYÊN VĂN từ nguồn chứa dữ "
|
||||
"kiện đó. Không có quote trong nguồn thì không được báo thiếu. Không bắt "
|
||||
"câu trả lời mở rộng sang nội dung ngoài phạm vi hỏi."
|
||||
)
|
||||
return GenerationRequest(system=ENTAILMENT_SYSTEM, user=user, schema=ENTAILMENT_SCHEMA)
|
||||
|
||||
|
||||
def build_request(
|
||||
question: str, evidence_texts: tuple[str, ...], intro: bool = False,
|
||||
list_mode: bool = False,
|
||||
answer_mode: str = "normal",
|
||||
layout: str = "prose",
|
||||
reasoning_mode: str = "direct_lookup",
|
||||
show_heading: bool = False,
|
||||
needs_warning: bool = False,
|
||||
) -> GenerationRequest:
|
||||
"""The prompt for one question over one ordered evidence list.
|
||||
|
||||
@@ -251,12 +369,23 @@ def build_request(
|
||||
f"CÂU HỎI: {question}\n\nMỖI đoạn BẰNG CHỨNG trên là chỉ định của MỘT "
|
||||
"thuốc KHÁC NHAU. Hãy LIỆT KÊ TẤT CẢ các thuốc mà bằng chứng cho thấy "
|
||||
"có chỉ định phù hợp với câu hỏi — không chỉ chọn một thuốc. Mỗi thuốc "
|
||||
"một câu ngắn, gắn đúng số nguồn [n] của thuốc đó. Đây là liệt kê tra "
|
||||
"một claim ngắn riêng, citations đúng số đoạn của thuốc đó. Đây là liệt kê tra "
|
||||
"cứu, KHÔNG phải khuyến cáo thuốc nào tốt hơn — không xếp hạng, không "
|
||||
"chọn thuốc \"phù hợp nhất\". Nếu KHÔNG thuốc nào trong bằng chứng thực "
|
||||
"sự phù hợp với câu hỏi, nói rõ điều đó thay vì liệt kê thuốc không liên quan."
|
||||
)
|
||||
else:
|
||||
task = f"CÂU HỎI: {question}"
|
||||
user = f"BẰNG CHỨNG:\n\n{blocks}\n\n{task}"
|
||||
plan = (
|
||||
"KẾ HOẠCH TRÌNH BÀY (không phải dữ kiện y khoa; không được nhắc lại trong "
|
||||
"câu trả lời):\n"
|
||||
f"- độ dài: {answer_mode}\n"
|
||||
f"- bố cục: {layout}\n"
|
||||
f"- chế độ: {reasoning_mode}\n"
|
||||
f"- cần heading: {'có' if show_heading else 'không'}\n"
|
||||
f"- cần nhấn mạnh cảnh báo: {'có' if needs_warning else 'không'}\n"
|
||||
"Dù bố cục nào, claims vẫn là các ý có evidence riêng. Không tạo heading "
|
||||
"hoặc boilerplate thành claim."
|
||||
)
|
||||
user = f"BẰNG CHỨNG:\n\n{blocks}\n\n{plan}\n\n{task}"
|
||||
return GenerationRequest(system=SYSTEM_PROMPT, user=user, schema=ANSWER_SCHEMA)
|
||||
|
||||
@@ -53,6 +53,12 @@ class CatalogDrugResolver:
|
||||
for alias in aliases
|
||||
if (normalized := normalize_name(alias))
|
||||
]
|
||||
self._alias_to_drug_ids: dict[str, set[str]] = {}
|
||||
for drug_id, alias in self._aliases:
|
||||
self._alias_to_drug_ids.setdefault(alias, set()).add(drug_id)
|
||||
self._max_alias_tokens = max(
|
||||
(len(alias.split()) for alias in self._alias_to_drug_ids), default=0
|
||||
)
|
||||
self._fuzzy_threshold = fuzzy_threshold
|
||||
self._ambiguity_margin = ambiguity_margin
|
||||
|
||||
@@ -69,18 +75,24 @@ class CatalogDrugResolver:
|
||||
# "Dịch vụ đang gặp sự cố" — not a provider outage at all. Caching by
|
||||
# exact input turns all but the newest turn's own text into a dict
|
||||
# lookup on every subsequent call.
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
@functools.lru_cache(maxsize=4096) # noqa: B019 - bounded process singleton
|
||||
def resolve(self, query: str) -> DrugResolution:
|
||||
normalized_query = normalize_name(query)
|
||||
query_tokens = normalized_query.split()
|
||||
exact = [
|
||||
(drug_id, alias, match.start(1), match.end(1))
|
||||
for drug_id, alias in self._aliases
|
||||
for match in [
|
||||
re.search(rf"(?:^| )({re.escape(alias)})(?:$| )", normalized_query)
|
||||
]
|
||||
if match
|
||||
]
|
||||
# Exact matching used to compile and run one regex for every alias
|
||||
# (~10k) on every new line. Enumerating the query's contiguous token
|
||||
# spans and looking them up in an immutable alias index is equivalent
|
||||
# at word boundaries and turns the common exact-name path into O(q²)
|
||||
# in the short query rather than O(catalog).
|
||||
exact: list[tuple[str, str, int, int]] = []
|
||||
for start in range(len(query_tokens)):
|
||||
last = min(len(query_tokens), start + self._max_alias_tokens)
|
||||
for end in range(start + 1, last + 1):
|
||||
alias = " ".join(query_tokens[start:end])
|
||||
exact.extend(
|
||||
(drug_id, alias, start, end)
|
||||
for drug_id in self._alias_to_drug_ids.get(alias, ())
|
||||
)
|
||||
if exact:
|
||||
maximal = [
|
||||
row for row in exact
|
||||
@@ -137,12 +149,22 @@ class CatalogDrugResolver:
|
||||
needle = normalize_name(prefix)
|
||||
if not needle:
|
||||
return []
|
||||
matches: list[tuple[tuple[int, int], str]] = []
|
||||
matches: list[tuple[tuple[int, int, int], str]] = []
|
||||
for drug_id, alias in self._aliases:
|
||||
position = alias.find(needle)
|
||||
if position < 0:
|
||||
continue
|
||||
matches.append(((0 if position == 0 else 1, len(alias)), drug_id))
|
||||
canonical = normalize_name(drug_id.replace("_", " "))
|
||||
canonical_words = canonical.split()
|
||||
if canonical.startswith(needle):
|
||||
source_rank = 0
|
||||
elif any(word.startswith(needle) for word in canonical_words):
|
||||
source_rank = 1
|
||||
elif position == 0:
|
||||
source_rank = 2
|
||||
else:
|
||||
source_rank = 3
|
||||
matches.append(((source_rank, len(canonical), len(alias)), drug_id))
|
||||
matches.sort()
|
||||
ordered: list[str] = []
|
||||
seen: set[str] = set()
|
||||
@@ -157,7 +179,7 @@ class CatalogDrugResolver:
|
||||
|
||||
# See the comment on `resolve` above — same cost, same fix, same
|
||||
# single-caller read-only usage (safe to hand back a cached list).
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
@functools.lru_cache(maxsize=4096) # noqa: B019 - bounded process singleton
|
||||
def suggest(
|
||||
self, query: str, k: int = 3, min_score: float = 0.5
|
||||
) -> list[tuple[str, float]]:
|
||||
|
||||
@@ -270,6 +270,13 @@ class RetrievalService:
|
||||
# Excluded from pooling outright rather than trusting a score margin
|
||||
# that isn't reliably wide enough on its own.
|
||||
_LEXICAL_POOL_EXCLUDED_SECTIONS = frozenset({"duoc_ly_va_co_che_tac_dung"})
|
||||
# Cross-section pooling solves one measured semantic mismatch: a
|
||||
# "thận trọng" question whose decisive condition is filed under
|
||||
# contraindications. Applying it to every explicit section leaked a
|
||||
# lexically-overlapping interaction section into a dosage answer. Keep
|
||||
# this recall expansion opt-in for the one section with evidence for it;
|
||||
# exact dosage/interaction/contraindication routes remain exact.
|
||||
_LEXICAL_POOL_ENABLED_SECTIONS = frozenset({"than_trong"})
|
||||
|
||||
def _section_hits(self, query: str, drug_id: str) -> list[SearchHit] | None:
|
||||
"""Hits for an explicitly named section, or None to fall back.
|
||||
@@ -302,6 +309,8 @@ class RetrievalService:
|
||||
as into a query-driven check across every section, still bounded and
|
||||
still whole-section (never a partial, out-of-context fragment).
|
||||
"""
|
||||
if resolved_section not in self._LEXICAL_POOL_ENABLED_SECTIONS:
|
||||
return []
|
||||
search_lexical = getattr(self._retriever, "search_lexical", None)
|
||||
if search_lexical is None:
|
||||
return []
|
||||
|
||||
@@ -133,6 +133,17 @@ class QueryFrame:
|
||||
age_text: str | None = None
|
||||
indication: str | None = None # symptom/disease, for symptom_to_drug
|
||||
route: str | None = None # e.g. "uong", "tiem_tinh_mach", "dat_truc_trang"
|
||||
# True when the user is asking to survey/summarise a whole named section
|
||||
# (for example all ADRs, precautions, or dosage regimens), rather than
|
||||
# asking for one regimen to apply to a particular patient. This is a
|
||||
# materially different sufficiency contract: a section survey must label
|
||||
# every branch, while a patient-specific dose may need clarification.
|
||||
section_overview: bool = False
|
||||
# Self-contained meaning used downstream. It rewrites references/omitted
|
||||
# subjects only; retrieval still trusts the separately validated drug ids,
|
||||
# facet and constraints below rather than parsing authority back from text.
|
||||
standalone_query: str | None = None
|
||||
depends_on_previous_turn: bool = False
|
||||
needs_clarify: bool = False
|
||||
clarify_reason: str | None = None
|
||||
# Short suggested replies for `clarify_reason` (e.g. ("Người lớn", "Trẻ
|
||||
@@ -174,6 +185,19 @@ FRAME_SCHEMA = {
|
||||
"'uống' or 'tiêm' to your own prior clarify question about route IS "
|
||||
"this field — read it here, do not leave it null and re-ask."
|
||||
),
|
||||
"section_overview": (
|
||||
"true when the user asks for a general survey of the requested section "
|
||||
"(all ADRs/interactions/precautions/dose regimens, 'theo Dược thư', "
|
||||
"'nếu có'), not one dose/decision for a specific patient"
|
||||
),
|
||||
"standalone_query": (
|
||||
"self-contained Vietnamese question after resolving pronouns/omitted subject "
|
||||
"from history; do not answer it. For a self-contained current turn, copy its "
|
||||
"meaning without adding facts"
|
||||
),
|
||||
"depends_on_previous_turn": (
|
||||
"true only when standalone_query needed entity/facet/constraints from history"
|
||||
),
|
||||
"needs_clarify": "true only if the turn cannot be acted on without more info",
|
||||
"clarify_reason": "short Vietnamese question to ask, or null",
|
||||
"quick_replies": (
|
||||
@@ -187,6 +211,17 @@ FRAME_SCHEMA = {
|
||||
),
|
||||
}
|
||||
|
||||
_ALLOWED_POPULATIONS = {
|
||||
"tre_em", "tre_so_sinh", "nguoi_lon", "nguoi_cao_tuoi",
|
||||
"phu_nu_co_thai", "phu_nu_cho_con_bu", "suy_than", "suy_gan",
|
||||
}
|
||||
_ALLOWED_ROUTES = {
|
||||
"uong", "tiem_tinh_mach", "tiem_bap", "tiem_duoi_da",
|
||||
"dat_truc_trang", "boi_ngoai_da", "nho_mat", "nho_mui", "khac",
|
||||
}
|
||||
_QUICK_REPLY_MAX_ITEMS = 4
|
||||
_QUICK_REPLY_MAX_CHARS = 40
|
||||
|
||||
_SYSTEM = """Bạn là bộ HIỂU CÂU HỎI cho một chatbot tra cứu Dược thư Quốc gia Việt Nam.
|
||||
Nhiệm vụ: đọc câu hỏi (tiếng Việt, có thể sai chính tả, viết tắt, nhiều lượt) và
|
||||
XUẤT RA một JSON mô tả ý định. TUYỆT ĐỐI KHÔNG trả lời câu hỏi y khoa, không nêu liều.
|
||||
@@ -203,8 +238,24 @@ Quy tắc bắt buộc:
|
||||
- Nếu hỏi liều cần cân nặng/tuổi -> "dosing_calc", điền weight_kg/age_text nếu có.
|
||||
Nói cân nặng kiểu thường ngày ("bé 30 cân", "nặng 30 ký", chỉ 1 số + "cân"/"ký"
|
||||
không kèm đơn vị khác) NGHĨA LÀ 30 kg -> điền weight_kg=30, không bỏ trống.
|
||||
- Với "dosing_calc", bộ hiểu câu hỏi chỉ bắt buộc làm rõ population và tuổi/cân
|
||||
nặng nếu là trẻ em/trẻ sơ sinh. KHÔNG tự kết luận rằng route luôn bắt buộc: nếu
|
||||
người dùng chưa nêu đường dùng thì giữ route=null; tầng trả lời sẽ nhìn chính
|
||||
BẰNG CHỨNG truy xuất được để quyết định câu hỏi đã đủ rõ hay chưa. Nhờ vậy nếu
|
||||
nguồn chỉ có một đường dùng phù hợp thì trả lời thẳng, còn nếu có nhiều nhánh
|
||||
khác nhau mới hỏi lại và sinh quick_replies.
|
||||
- Phân biệt tra cứu TOÀN MỤC với áp dụng cho MỘT NGƯỜI BỆNH. Nếu người dùng hỏi
|
||||
tổng quan kiểu "Dược thư hướng dẫn dùng thế nào", "các ADR/tương tác/thận trọng
|
||||
nào", "theo nhóm/tần suất", "nếu có" thì đặt section_overview=true và KHÔNG
|
||||
hỏi người lớn/trẻ em/đường dùng chỉ để thu hẹp. Hãy trả về các nhánh trong sách
|
||||
với nhãn rõ ràng. Chỉ đặt section_overview=false và hỏi thêm khi người dùng muốn
|
||||
chọn một liều/quyết định áp dụng cho ca bệnh cụ thể.
|
||||
- Lượt nối tiếp ("còn liều thì sao", "nó dùng cho trẻ em?") -> dùng LỊCH SỬ để biết
|
||||
thuốc đang nói tới và điền vào "drugs".
|
||||
thuốc đang nói tới và điền vào "drugs". Đồng thời viết `standalone_query` thành
|
||||
câu có nghĩa độc lập (ví dụ "Còn chống chỉ định của levetiracetam?" ->
|
||||
"Chống chỉ định của levetiracetam") và đặt `depends_on_previous_turn=true`.
|
||||
- Nếu lượt hiện tại đã tự đủ nghĩa, `standalone_query` chỉ chuẩn hóa chính câu đó,
|
||||
không chèn thuốc/đối tượng cũ và đặt `depends_on_previous_turn=false`.
|
||||
- QUAN TRỌNG — lượt hiện tại trả lời câu hỏi bạn VỪA hỏi: nếu dòng "Trợ lý:" cuối
|
||||
cùng trong LỊCH SỬ là một câu hỏi làm rõ (vd "Người lớn hay trẻ em?", "Uống hay
|
||||
tiêm?", "Cân nặng bao nhiêu kg?"), và CÂU HỎI HIỆN TẠI là một câu trả lời ngắn
|
||||
@@ -308,8 +359,16 @@ class LlmQueryUnderstander:
|
||||
ids.add(resolution.drug_id)
|
||||
elif resolution.status == "ambiguous":
|
||||
ids.update(resolution.candidate_drug_ids)
|
||||
for drug_id, _score in self._resolver.suggest(line, k=5, min_score=0.55):
|
||||
ids.add(drug_id)
|
||||
else:
|
||||
# Exact/ambiguous resolution has already exhaustively scanned
|
||||
# the aliases present in this line. Running the O(catalog)
|
||||
# fuzzy pass as well only adds unrelated candidates and cost
|
||||
# (~1s on the real catalog); fuzzy suggestions are useful only
|
||||
# when no exact candidate was found (the typo path).
|
||||
for drug_id, _score in self._resolver.suggest(
|
||||
line, k=5, min_score=0.55
|
||||
):
|
||||
ids.add(drug_id)
|
||||
return ids
|
||||
|
||||
def understand(
|
||||
@@ -424,19 +483,29 @@ class LlmQueryUnderstander:
|
||||
turn_type = data.get("turn_type")
|
||||
if turn_type not in TURN_TYPES:
|
||||
turn_type = "drug_attribute" if drugs else "out_of_scope"
|
||||
needs_clarify = data.get("needs_clarify") is True
|
||||
clarify_reason = _clean_str(data.get("clarify_reason"))
|
||||
quick_replies = (
|
||||
_clean_quick_replies(data.get("quick_replies"))
|
||||
if needs_clarify and clarify_reason
|
||||
else ()
|
||||
)
|
||||
return QueryFrame(
|
||||
turn_type=turn_type,
|
||||
drugs=drugs,
|
||||
unknown_drugs=tuple(dict.fromkeys(unknown)),
|
||||
attribute=attribute,
|
||||
population=_clean_str(data.get("population")),
|
||||
population=_clean_enum(data.get("population"), _ALLOWED_POPULATIONS),
|
||||
weight_kg=_clean_float(data.get("weight_kg")),
|
||||
age_text=_clean_str(data.get("age_text")),
|
||||
indication=_clean_str(data.get("indication")),
|
||||
route=_clean_str(data.get("route")),
|
||||
needs_clarify=bool(data.get("needs_clarify")),
|
||||
clarify_reason=_clean_str(data.get("clarify_reason")),
|
||||
quick_replies=tuple(_as_list(data.get("quick_replies"))),
|
||||
route=_clean_enum(data.get("route"), _ALLOWED_ROUTES),
|
||||
section_overview=data.get("section_overview") is True,
|
||||
standalone_query=_clean_str(data.get("standalone_query")),
|
||||
depends_on_previous_turn=data.get("depends_on_previous_turn") is True,
|
||||
needs_clarify=needs_clarify,
|
||||
clarify_reason=clarify_reason,
|
||||
quick_replies=quick_replies,
|
||||
raw=data if isinstance(data, dict) else {},
|
||||
)
|
||||
|
||||
@@ -527,12 +596,39 @@ def _clean_str(value) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _clean_enum(value, allowed: set[str]) -> str | None:
|
||||
cleaned = _clean_str(value)
|
||||
return cleaned if cleaned in allowed else None
|
||||
|
||||
|
||||
def _clean_quick_replies(value) -> tuple[str, ...]:
|
||||
"""Keep the LLM's dynamic suggestions, but enforce the UI contract."""
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
replies: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
continue
|
||||
reply = " ".join(item.split())
|
||||
key = reply.casefold()
|
||||
if not reply or len(reply) > _QUICK_REPLY_MAX_CHARS or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
replies.append(reply)
|
||||
if len(replies) >= _QUICK_REPLY_MAX_ITEMS:
|
||||
break
|
||||
return tuple(replies)
|
||||
|
||||
|
||||
def _clean_float(value) -> float | None:
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
parsed = float(value)
|
||||
return parsed if 0 < parsed <= 500 else None
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return float(value.replace(",", ".").split()[0])
|
||||
parsed = float(value.replace(",", ".").split()[0])
|
||||
return parsed if 0 < parsed <= 500 else None
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
return None
|
||||
|
||||
@@ -40,6 +40,25 @@ class CitationResponse(BaseModel):
|
||||
evidence_text: str = ""
|
||||
|
||||
|
||||
class AnswerClaimResponse(BaseModel):
|
||||
text: str
|
||||
source_ids: list[str]
|
||||
|
||||
|
||||
class AnswerBlockResponse(BaseModel):
|
||||
title: str
|
||||
kind: str
|
||||
claims: list[AnswerClaimResponse]
|
||||
|
||||
|
||||
class AnswerPlanResponse(BaseModel):
|
||||
verbosity: str
|
||||
layout: str
|
||||
reasoning_mode: str
|
||||
show_heading: bool
|
||||
needs_warning: bool
|
||||
|
||||
|
||||
class RagQueryResponse(BaseModel):
|
||||
trace_id: str
|
||||
decision: str
|
||||
@@ -57,6 +76,9 @@ class RagQueryResponse(BaseModel):
|
||||
# clarify path today; other clarify sources (no_drug, dosing_calc's
|
||||
# needs_clarify) leave this empty rather than fabricate options.
|
||||
quick_replies: list[str] = []
|
||||
blocks: list[AnswerBlockResponse] = []
|
||||
answer_mode: str = "concise"
|
||||
answer_plan: AnswerPlanResponse | None = None
|
||||
|
||||
|
||||
def _answer_service(request: Request) -> GroundedAnswerService:
|
||||
@@ -110,6 +132,32 @@ def _map_citations(items) -> list[CitationResponse]:
|
||||
]
|
||||
|
||||
|
||||
def _map_blocks(items) -> list[AnswerBlockResponse]:
|
||||
return [
|
||||
AnswerBlockResponse(
|
||||
title=item.title,
|
||||
kind=item.kind,
|
||||
claims=[
|
||||
AnswerClaimResponse(text=claim.text, source_ids=list(claim.source_ids))
|
||||
for claim in item.claims
|
||||
],
|
||||
)
|
||||
for item in items
|
||||
]
|
||||
|
||||
|
||||
def _map_plan(item) -> AnswerPlanResponse | None:
|
||||
if item is None:
|
||||
return None
|
||||
return AnswerPlanResponse(
|
||||
verbosity=item.verbosity,
|
||||
layout=item.layout,
|
||||
reasoning_mode=item.reasoning_mode,
|
||||
show_heading=item.show_heading,
|
||||
needs_warning=item.needs_warning,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/query", response_model=RagQueryResponse)
|
||||
def query_rag(
|
||||
payload: RagQueryRequest,
|
||||
@@ -144,6 +192,9 @@ def query_rag(
|
||||
citations = _map_citations(reply.citations)
|
||||
generated = reply.generated
|
||||
quick_replies = list(reply.quick_replies)
|
||||
blocks = _map_blocks(reply.blocks)
|
||||
answer_mode = reply.answer_mode
|
||||
answer_plan = _map_plan(reply.plan)
|
||||
else:
|
||||
# No generator configured (ANSWER_PROVIDER=disabled): there is no LLM
|
||||
# to understand a turn with, so this is retrieval-only, single-turn,
|
||||
@@ -156,6 +207,9 @@ def query_rag(
|
||||
citations = []
|
||||
generated = False
|
||||
quick_replies = list(grounded.quick_replies)
|
||||
blocks = []
|
||||
answer_mode = "concise"
|
||||
answer_plan = None
|
||||
else:
|
||||
decision = grounded.result.decision.value
|
||||
reason = grounded.result.reason
|
||||
@@ -164,6 +218,9 @@ def query_rag(
|
||||
citations = _map_citations(grounded.citations)
|
||||
generated = grounded.generated
|
||||
quick_replies = []
|
||||
blocks = _map_blocks(grounded.blocks)
|
||||
answer_mode = grounded.answer_mode
|
||||
answer_plan = _map_plan(grounded.plan)
|
||||
|
||||
# Trace persistence is fail-open (F-09): an already-computed, safe answer
|
||||
# must reach the caller even if Postgres is unreachable. `save()` opens a
|
||||
@@ -197,4 +254,7 @@ def query_rag(
|
||||
citations=citations,
|
||||
generated=generated,
|
||||
quick_replies=quick_replies,
|
||||
blocks=blocks,
|
||||
answer_mode=answer_mode,
|
||||
answer_plan=answer_plan,
|
||||
)
|
||||
|
||||
@@ -124,6 +124,24 @@ def test_no_drug_named_asks_which_one():
|
||||
assert reply.reason == "no_drug"
|
||||
|
||||
|
||||
def test_drug_attribute_without_an_attribute_does_not_fall_into_overview_retrieval():
|
||||
retrieval = _FixedRetrieval({})
|
||||
answers = GroundedAnswerService(routing=None)
|
||||
agent = RagAgent(
|
||||
_FixedUnderstander(QueryFrame(
|
||||
turn_type="drug_attribute", drugs=("paracetamol_acetaminophen",)
|
||||
)),
|
||||
retrieval,
|
||||
answers,
|
||||
)
|
||||
|
||||
reply = agent.handle("paracetamol thì sao?")
|
||||
|
||||
assert reply.decision == "clarify"
|
||||
assert reply.reason == "missing_attribute"
|
||||
assert retrieval.calls == []
|
||||
|
||||
|
||||
def test_needs_clarify_frame_is_surfaced_directly():
|
||||
agent = _agent(QueryFrame(
|
||||
turn_type="dosing_calc", drugs=("paracetamol",),
|
||||
@@ -137,6 +155,22 @@ def test_needs_clarify_frame_is_surfaced_directly():
|
||||
assert reply.quick_replies == ()
|
||||
|
||||
|
||||
def test_understanding_provider_failure_is_an_abstain_not_a_fake_clarification():
|
||||
agent = _agent(QueryFrame(
|
||||
turn_type="out_of_scope",
|
||||
needs_clarify=True,
|
||||
clarify_reason="Dịch vụ đang gặp sự cố tạm thời.",
|
||||
system_error="understanding_provider_unavailable",
|
||||
))
|
||||
|
||||
reply = agent.handle("liều paracetamol")
|
||||
|
||||
assert reply.decision == "abstain"
|
||||
assert reply.reason == "understanding_provider_unavailable"
|
||||
assert reply.answer == "Dịch vụ đang gặp sự cố tạm thời."
|
||||
assert reply.clarification is None
|
||||
|
||||
|
||||
def test_needs_clarify_frame_carries_quick_replies_through():
|
||||
"""This is the path real traffic actually hits (checked live): the
|
||||
understanding LLM call itself sets needs_clarify/clarify_reason before
|
||||
@@ -153,6 +187,167 @@ def test_needs_clarify_frame_carries_quick_replies_through():
|
||||
assert reply.quick_replies == ("Người lớn", "Trẻ em")
|
||||
|
||||
|
||||
def test_dosing_without_route_asks_only_when_evidence_is_ambiguous():
|
||||
result = RetrievalResult(
|
||||
EvidenceDecision.ANSWERABLE,
|
||||
"grounded_evidence_available",
|
||||
(
|
||||
_evidence("Đường uống, người lớn: 500 mg mỗi lần."),
|
||||
_evidence("Đặt trực tràng, người lớn: 500 mg mỗi lần."),
|
||||
),
|
||||
resolved_drug_id="paracetamol_acetaminophen",
|
||||
)
|
||||
|
||||
class _Generator:
|
||||
def generate(self, system, user, schema):
|
||||
return (
|
||||
'{"claims": [], "evidence_sufficient": false, '
|
||||
'"clarifying_question": "Anh/chị muốn dùng đường nào?", '
|
||||
'"quick_replies": ["Uống", "Đặt trực tràng"]}'
|
||||
)
|
||||
|
||||
retrieval = _FixedRetrieval({"paracetamol_acetaminophen": result})
|
||||
answers = GroundedAnswerService(routing=None, generator=_Generator())
|
||||
agent = RagAgent(
|
||||
_FixedUnderstander(QueryFrame(
|
||||
turn_type="dosing_calc",
|
||||
drugs=("paracetamol_acetaminophen",),
|
||||
population="nguoi_lon",
|
||||
)),
|
||||
retrieval,
|
||||
answers,
|
||||
)
|
||||
|
||||
reply = agent.handle("Người lớn", conversation_id="dose-route")
|
||||
|
||||
assert reply.decision == "clarify"
|
||||
assert reply.reason == "needs_more_info"
|
||||
assert reply.quick_replies == ("Uống", "Đặt trực tràng")
|
||||
assert len(retrieval.calls) == 1
|
||||
remembered = agent._last_frame["dose-route"]
|
||||
assert remembered.needs_clarify is True
|
||||
assert remembered.population == "nguoi_lon"
|
||||
assert remembered.clarify_reason == reply.clarification
|
||||
|
||||
|
||||
def test_dosing_without_route_answers_directly_when_evidence_has_one_route():
|
||||
result = RetrievalResult(
|
||||
EvidenceDecision.ANSWERABLE,
|
||||
"grounded_evidence_available",
|
||||
(_evidence("Đường uống, người lớn: 500 mg mỗi lần."),),
|
||||
resolved_drug_id="paracetamol_acetaminophen",
|
||||
)
|
||||
|
||||
class _Generator:
|
||||
def generate(self, system, user, schema):
|
||||
if "entailed" in schema.get("properties", {}):
|
||||
return '{"entailed": true, "unsupported": []}'
|
||||
return (
|
||||
'{"claims": [{"text": "Đường uống, người lớn: 500 mg mỗi lần.", '
|
||||
'"citations": [1]}], "evidence_sufficient": true, '
|
||||
'"clarifying_question": null, "quick_replies": []}'
|
||||
)
|
||||
|
||||
retrieval = _FixedRetrieval({"paracetamol_acetaminophen": result})
|
||||
answers = GroundedAnswerService(routing=None, generator=_Generator())
|
||||
agent = RagAgent(
|
||||
_FixedUnderstander(QueryFrame(
|
||||
turn_type="dosing_calc",
|
||||
drugs=("paracetamol_acetaminophen",),
|
||||
population="nguoi_lon",
|
||||
)),
|
||||
retrieval,
|
||||
answers,
|
||||
)
|
||||
|
||||
reply = agent.handle("Liều Paracetamol cho người lớn")
|
||||
|
||||
assert reply.decision == "answerable"
|
||||
assert reply.quick_replies == ()
|
||||
assert "500 mg" in reply.answer
|
||||
assert len(retrieval.calls) == 1
|
||||
|
||||
|
||||
def test_general_dosage_section_survey_does_not_force_population_chip():
|
||||
result = RetrievalResult(
|
||||
EvidenceDecision.ANSWERABLE,
|
||||
"grounded_evidence_available",
|
||||
(_evidence("Người lớn: uống 10 mg. Trẻ em: liều theo cân nặng."),),
|
||||
resolved_drug_id="example",
|
||||
)
|
||||
retrieval = _FixedRetrieval({"example": result})
|
||||
agent = RagAgent(
|
||||
_FixedUnderstander(QueryFrame(
|
||||
turn_type="dosing_calc",
|
||||
drugs=("example",),
|
||||
needs_clarify=True,
|
||||
clarify_reason="Người lớn hay trẻ em?",
|
||||
)),
|
||||
retrieval,
|
||||
GroundedAnswerService(routing=None),
|
||||
)
|
||||
|
||||
reply = agent.handle(
|
||||
"Dược thư hướng dẫn dùng Example thế nào: đường dùng và các liều nếu có?"
|
||||
)
|
||||
|
||||
assert reply.decision == "answerable"
|
||||
assert reply.quick_replies == ()
|
||||
assert "tra cứu tổng quan toàn mục" in retrieval.calls[0][2]
|
||||
|
||||
|
||||
def test_clear_precaution_section_survey_overrides_model_overclarification():
|
||||
result = RetrievalResult(
|
||||
EvidenceDecision.ANSWERABLE,
|
||||
"grounded_evidence_available",
|
||||
(_evidence("Theo dõi chức năng thận và điện giải."),),
|
||||
resolved_drug_id="example",
|
||||
)
|
||||
agent = _agent(
|
||||
QueryFrame(
|
||||
turn_type="drug_attribute",
|
||||
drugs=("example",),
|
||||
attribute="than_trong",
|
||||
needs_clarify=True,
|
||||
clarify_reason="Muốn hỏi thận trọng hay chống chỉ định?",
|
||||
),
|
||||
{"example": result},
|
||||
)
|
||||
|
||||
reply = agent.handle("Những tình huống nào cần thận trọng khi dùng Example?")
|
||||
|
||||
assert reply.decision == "answerable"
|
||||
assert reply.quick_replies == ()
|
||||
|
||||
|
||||
def test_complete_adult_dosing_core_ignores_an_irrelevant_weight_reask():
|
||||
result = RetrievalResult(
|
||||
EvidenceDecision.ANSWERABLE, "grounded_evidence_available",
|
||||
(_evidence("Đường uống, người lớn: 500 mg mỗi lần."),),
|
||||
resolved_drug_id="paracetamol_acetaminophen",
|
||||
)
|
||||
retrieval = _FixedRetrieval({"paracetamol_acetaminophen": result})
|
||||
answers = GroundedAnswerService(routing=None)
|
||||
agent = RagAgent(
|
||||
_FixedUnderstander(QueryFrame(
|
||||
turn_type="dosing_calc",
|
||||
drugs=("paracetamol_acetaminophen",),
|
||||
population="nguoi_lon",
|
||||
route="uong",
|
||||
needs_clarify=True,
|
||||
clarify_reason="Cân nặng của người lớn là bao nhiêu kg?",
|
||||
)),
|
||||
retrieval,
|
||||
answers,
|
||||
)
|
||||
|
||||
reply = agent.handle("Uống")
|
||||
|
||||
assert reply.decision == "answerable"
|
||||
assert len(retrieval.calls) == 1
|
||||
assert retrieval.calls[0][1] == "lieu_luong_va_cach_dung"
|
||||
|
||||
|
||||
def test_single_drug_attribute_retrieves_and_answers():
|
||||
result = RetrievalResult(
|
||||
EvidenceDecision.ANSWERABLE, "grounded_evidence_available",
|
||||
@@ -490,7 +685,10 @@ def test_a_generous_budget_does_not_change_normal_behaviour():
|
||||
return '{"sufficient": true, "clarifying_question": null, "quick_replies": []}'
|
||||
if "entailed" in schema.get("properties", {}):
|
||||
return '{"entailed": true, "unsupported": []}'
|
||||
return '{"answer": "Liều 500 mg [1].", "evidence_sufficient": true, "clarifying_question": null}'
|
||||
return (
|
||||
'{"claims": [{"text": "Liều 500 mg", "citations": [1]}], '
|
||||
'"evidence_sufficient": true, "clarifying_question": null}'
|
||||
)
|
||||
|
||||
answers = GroundedAnswerService(routing=None, generator=_Generator())
|
||||
agent = RagAgent(
|
||||
@@ -500,7 +698,8 @@ def test_a_generous_budget_does_not_change_normal_behaviour():
|
||||
reply = agent.handle("liều metformin")
|
||||
|
||||
assert reply.decision == "answerable"
|
||||
assert reply.answer == "Liều 500 mg [1]."
|
||||
assert reply.answer == "Liều 500 mg"
|
||||
assert reply.blocks[0].claims[0].source_ids
|
||||
|
||||
|
||||
# --- durable conversation history (ADR 0008's named gap): an optional
|
||||
|
||||
@@ -36,7 +36,8 @@ def test_answer_uses_only_printed_page_citations():
|
||||
(evidence(source),), "abacavir", "resolved",
|
||||
)))
|
||||
answer = service.answer("q", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
|
||||
assert answer.answer == "Liều được ghi trong nguồn. [1]"
|
||||
assert answer.answer == "Liều được ghi trong nguồn."
|
||||
assert answer.blocks[0].claims[0].source_ids
|
||||
assert answer.citations[0].printed_page_start == 101
|
||||
assert answer.citations[0].printed_page_end == 103
|
||||
|
||||
|
||||
@@ -84,7 +84,8 @@ def test_only_cited_sources_are_returned():
|
||||
result = _answerable(_evidence(0, 100), _evidence(1, 200), _evidence(2, 300))
|
||||
service = GroundedAnswerService(
|
||||
_Routing(result),
|
||||
_Generator({"answer": "Chỉ dùng đoạn hai [2].", "evidence_sufficient": True}),
|
||||
_Generator({"claims": [{"text": "Chỉ dùng đoạn hai", "citations": [2]}],
|
||||
"evidence_sufficient": True}),
|
||||
)
|
||||
|
||||
grounded = service.answer("q", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
|
||||
@@ -105,7 +106,8 @@ def test_answer_citing_nothing_is_rejected_not_dressed_up_with_borrowed_citation
|
||||
# and it must not silently degrade to a raw extractive quote either
|
||||
# (owner correction, 2026-08-06: no fallback to the retired
|
||||
# offline-extractive shape when a real generator is configured).
|
||||
_Generator({"answer": "Không có trích dẫn.", "evidence_sufficient": True}),
|
||||
_Generator({"claims": [{"text": "Không có trích dẫn.", "citations": []}],
|
||||
"evidence_sufficient": True}),
|
||||
)
|
||||
|
||||
grounded = service.answer("q", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
|
||||
@@ -157,8 +159,8 @@ def test_sufficiency_check_outage_fails_open_to_generation_not_abstain():
|
||||
the real safety net on whatever gets generated next."""
|
||||
result = _answerable(_evidence(0, 100), _evidence(1, 200))
|
||||
gen = _Generator(
|
||||
{"answer": "Đoạn bằng chứng 0 [1].", "evidence_sufficient": True,
|
||||
"clarifying_question": None},
|
||||
{"claims": [{"text": "Đoạn bằng chứng 0", "citations": [1]}],
|
||||
"evidence_sufficient": True, "clarifying_question": None},
|
||||
sufficiency_payload=AnswerGenerationUnavailable("Bedrock unreachable"),
|
||||
)
|
||||
service = GroundedAnswerService(_Routing(result), gen)
|
||||
@@ -177,8 +179,8 @@ def test_sufficient_query_is_not_turned_into_a_clarification():
|
||||
|
||||
g = service.answer("liều người lớn", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
|
||||
|
||||
# sufficiency passes; generation then runs (its payload lacks answer keys, so
|
||||
# it falls back to the source text) — the point is no clarification fired.
|
||||
# Sufficiency passes; generation then runs (its payload lacks answer keys,
|
||||
# so it fails closed) — the point is no clarification fired.
|
||||
assert g.clarification is None
|
||||
|
||||
|
||||
@@ -208,8 +210,10 @@ def test_list_mode_skips_the_sufficiency_clarify():
|
||||
must never call it at all, so only the real answer payload is ever read."""
|
||||
result = _answerable(_evidence(0, 100), _evidence(1, 200))
|
||||
gen = _Generator(
|
||||
{"answer": "Đoạn bằng chứng 0 [1]. Đoạn bằng chứng 1 [2].",
|
||||
"evidence_sufficient": True, "clarifying_question": None},
|
||||
{"claims": [
|
||||
{"text": "Đoạn bằng chứng 0", "citations": [1]},
|
||||
{"text": "Đoạn bằng chứng 1", "citations": [2]},
|
||||
], "evidence_sufficient": True, "clarifying_question": None},
|
||||
sufficiency_payload={
|
||||
"sufficient": False, "clarifying_question": "Loại nào?", "quick_replies": [],
|
||||
},
|
||||
|
||||
@@ -67,18 +67,14 @@ class _FixedRouting:
|
||||
class _Generator:
|
||||
"""Returns whatever payload the test wants the model to have produced.
|
||||
|
||||
`_generate` now makes up to five calls through this port: the main
|
||||
answer (a lone `evidence_sufficient: false` retries once — the same
|
||||
noisy-judge finding as entailment, live-confirmed 2026-08-07), a
|
||||
sufficiency check (skipped here — one evidence block), and up to three
|
||||
entailment calls (widened from two 2026-08-07: live probing found the
|
||||
judge noisy on an identical claim/evidence pair, and a real adversarial
|
||||
sample showed a single retry still discarding correct answers on the
|
||||
unlucky reject-reject draw). They're told apart by schema, so a test
|
||||
`_generate` can make a main answer call (a lone
|
||||
`evidence_sufficient: false` retries once) and one fail-closed entailment
|
||||
call. The legacy direct-answer path may also make a sufficiency call when
|
||||
several evidence blocks need disambiguation; the structured agent path
|
||||
skips that duplicate judgment. They're told apart by schema, so a test
|
||||
that only cares about one call doesn't have to fake the others; `payload`
|
||||
and `entailment_payload` each take either a fixed value or a list for a
|
||||
different answer on each successive call to that schema (e.g.
|
||||
`[reject, reject, accept]` for the third-attempt-recovers case).
|
||||
different answer on each successive call to that schema.
|
||||
"""
|
||||
|
||||
def __init__(self, payload, entailment_payload=None) -> None:
|
||||
@@ -124,7 +120,7 @@ def _answer(payload, result: RetrievalResult | None = None, entailment_payload=N
|
||||
|
||||
def test_invented_dose_is_refused_and_never_reaches_the_answer():
|
||||
grounded, metrics = _answer(
|
||||
{"answer": "Người lớn uống 850 mg, 2 lần mỗi ngày [1].",
|
||||
{"claims": [{"text": "Người lớn uống 850 mg, 2 lần mỗi ngày", "citations": [1]}],
|
||||
"evidence_sufficient": True}
|
||||
)
|
||||
|
||||
@@ -147,7 +143,8 @@ def test_a_rounded_figure_counts_as_invented():
|
||||
"""`2 g` is in the source; `2000 mg` is a conversion, and conversions are
|
||||
where unit errors live. The prompt forbids it and the check enforces it."""
|
||||
grounded, metrics = _answer(
|
||||
{"answer": "Liều tối đa 2000 mg mỗi ngày [1].", "evidence_sufficient": True}
|
||||
{"claims": [{"text": "Liều tối đa 2000 mg mỗi ngày", "citations": [1]}],
|
||||
"evidence_sufficient": True}
|
||||
)
|
||||
|
||||
assert grounded.generated is False
|
||||
@@ -156,7 +153,8 @@ def test_a_rounded_figure_counts_as_invented():
|
||||
|
||||
def test_citation_pointing_at_nothing_is_refused():
|
||||
grounded, metrics = _answer(
|
||||
{"answer": "Người lớn uống 500 mg [3].", "evidence_sufficient": True}
|
||||
{"claims": [{"text": "Người lớn uống 500 mg", "citations": [3]}],
|
||||
"evidence_sufficient": True}
|
||||
)
|
||||
|
||||
assert grounded.generated is False
|
||||
@@ -170,12 +168,13 @@ def test_citation_pointing_at_nothing_is_refused():
|
||||
|
||||
def test_faithful_rewrite_is_served():
|
||||
grounded, metrics = _answer(
|
||||
{"answer": "Người lớn: 500 mg, 2 lần/ngày; tối đa 2 g/ngày [1].",
|
||||
{"claims": [{"text": "Người lớn: 500 mg, 2 lần/ngày; tối đa 2 g/ngày", "citations": [1]}],
|
||||
"evidence_sufficient": True}
|
||||
)
|
||||
|
||||
assert grounded.generated is True
|
||||
assert grounded.answer == "Người lớn: 500 mg, 2 lần/ngày; tối đa 2 g/ngày [1]."
|
||||
assert grounded.answer == "Người lớn: 500 mg, 2 lần/ngày; tối đa 2 g/ngày"
|
||||
assert grounded.blocks[0].claims[0].source_ids == ("metformin::lieu::0",)
|
||||
assert metrics.total(GENERATION_SERVED) == 1
|
||||
assert metrics.total(GENERATION_REJECTED) == 0
|
||||
|
||||
@@ -190,7 +189,7 @@ def test_fabricated_nonnumeric_claim_with_a_valid_citation_is_rejected():
|
||||
the entailment pass, told the model judged evidence 1 does not support
|
||||
it, is what rejects the generation."""
|
||||
grounded, metrics = _answer(
|
||||
{"answer": "Metformin chữa ung thư [1].", "evidence_sufficient": True},
|
||||
{"claims": [{"text": "Metformin chữa ung thư", "citations": [1]}], "evidence_sufficient": True},
|
||||
entailment_payload={"entailed": False, "unsupported": [1]},
|
||||
)
|
||||
|
||||
@@ -202,7 +201,7 @@ def test_fabricated_nonnumeric_claim_with_a_valid_citation_is_rejected():
|
||||
|
||||
def test_entailment_check_running_and_passing_still_serves_the_answer():
|
||||
grounded, metrics = _answer(
|
||||
{"answer": "Metformin dùng điều trị đái tháo đường [1].",
|
||||
{"claims": [{"text": "Metformin dùng điều trị đái tháo đường", "citations": [1]}],
|
||||
"evidence_sufficient": True},
|
||||
entailment_payload={"entailed": True, "unsupported": []},
|
||||
)
|
||||
@@ -211,13 +210,15 @@ def test_entailment_check_running_and_passing_still_serves_the_answer():
|
||||
assert metrics.total(GENERATION_SERVED) == 1
|
||||
|
||||
|
||||
def test_entailment_retries_once_after_a_reject_and_a_later_accept_serves():
|
||||
"""Reproduces the 2026-08-06 live finding: the same claim/evidence pair,
|
||||
called three times through the real judge, came back entailed twice and
|
||||
rejected once — a single noisy reject must not discard a correct,
|
||||
well-cited answer."""
|
||||
def test_entailment_rejects_after_one_fail_closed_semantic_pass():
|
||||
"""The verifier is one semantic pass after deterministic grounding.
|
||||
|
||||
Repeating an identical temperature-0 prompt against the same model is a
|
||||
correlated retry, not an independent vote, and doubled the hot-path model
|
||||
latency for every valid answer.
|
||||
"""
|
||||
grounded, metrics = _answer(
|
||||
{"answer": "Metformin dùng điều trị đái tháo đường [1].",
|
||||
{"claims": [{"text": "Metformin chữa ung thư", "citations": [1]}],
|
||||
"evidence_sufficient": True},
|
||||
entailment_payload=[
|
||||
{"entailed": False, "unsupported": [1]},
|
||||
@@ -225,52 +226,102 @@ def test_entailment_retries_once_after_a_reject_and_a_later_accept_serves():
|
||||
],
|
||||
)
|
||||
|
||||
assert grounded.generated is True
|
||||
assert metrics.total(GENERATION_SERVED) == 1
|
||||
|
||||
|
||||
def test_entailment_recovers_on_third_attempt_after_two_rejects():
|
||||
"""The improvement 2026-08-07 widened the retry from 2 to 3 attempts
|
||||
after a live 50-question adversarial sample found the 2-attempt policy's
|
||||
own math (~11% false-discard rate on a genuinely valid claim, from the
|
||||
noise probed in the docstring above) matched the observed real
|
||||
abstention rate almost exactly. Two rejects followed by a real accept
|
||||
must now be served, not discarded."""
|
||||
grounded, metrics = _answer(
|
||||
{"answer": "Metformin dùng điều trị đái tháo đường [1].",
|
||||
"evidence_sufficient": True},
|
||||
entailment_payload=[
|
||||
{"entailed": False, "unsupported": [1]},
|
||||
{"entailed": False, "unsupported": [1]},
|
||||
{"entailed": True, "unsupported": []},
|
||||
],
|
||||
)
|
||||
|
||||
assert grounded.generated is True
|
||||
assert metrics.total(GENERATION_SERVED) == 1
|
||||
assert metrics.total(GENERATION_REJECTED) == 0
|
||||
|
||||
|
||||
def test_entailment_three_agreeing_rejects_still_discard():
|
||||
grounded, metrics = _answer(
|
||||
{"answer": "Metformin chữa ung thư [1].", "evidence_sufficient": True},
|
||||
entailment_payload=[
|
||||
{"entailed": False, "unsupported": [1]},
|
||||
{"entailed": False, "unsupported": [1]},
|
||||
{"entailed": False, "unsupported": [1]},
|
||||
],
|
||||
)
|
||||
|
||||
assert grounded.generated is False
|
||||
assert grounded.answer is None
|
||||
# All 3 attempts are noisy-judge calls against the SAME claim/evidence —
|
||||
# a real, reliable rejection must still discard exactly once, not 3x.
|
||||
assert metrics.total(GENERATION_REJECTED, reason="unsupported_claim") == 1
|
||||
|
||||
|
||||
def test_supported_but_incomplete_answer_is_rejected_against_full_raw_evidence():
|
||||
grounded, metrics = _answer(
|
||||
{
|
||||
"claims": [{"text": "Người lớn uống 500 mg", "citations": [1]}],
|
||||
"evidence_sufficient": True,
|
||||
},
|
||||
entailment_payload={
|
||||
"entailed": True,
|
||||
"unsupported": [],
|
||||
"complete": False,
|
||||
"missing_evidence": [{
|
||||
"description": "2 lần mỗi ngày và liều tối đa 2 g mỗi ngày",
|
||||
"evidence_quote": EVIDENCE_TEXT,
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
assert grounded.answer is None
|
||||
assert grounded.result.reason == "incomplete_answer"
|
||||
assert metrics.total(GENERATION_REJECTED, reason="incomplete_answer") == 1
|
||||
|
||||
|
||||
def test_completeness_judge_cannot_claim_its_own_quoted_fact_is_missing():
|
||||
grounded, _ = _answer(
|
||||
{
|
||||
"claims": [{
|
||||
"text": "Chảy máu giữa vòng kinh (rất hay gặp trong 3 tháng đầu dùng thuốc theo đường tiêm).",
|
||||
"citations": [1],
|
||||
}],
|
||||
"evidence_sufficient": True,
|
||||
},
|
||||
result=_result(
|
||||
"Chảy máu giữa vòng kinh (rất hay gặp trong 3 tháng đầu dùng thuốc theo đường tiêm)."
|
||||
),
|
||||
entailment_payload={
|
||||
"entailed": True,
|
||||
"unsupported": [],
|
||||
"complete": False,
|
||||
"missing_evidence": [{
|
||||
"description": "Không ghi nhận 'rất hay gặp trong 3 tháng đầu dùng thuốc theo đường tiêm'",
|
||||
"evidence_quote": "rất hay gặp trong 3 tháng đầu dùng thuốc theo đường tiêm",
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
assert grounded.generated is True
|
||||
assert grounded.answer is not None
|
||||
|
||||
|
||||
def test_completeness_objection_without_a_real_source_quote_is_ignored():
|
||||
grounded, _ = _answer(
|
||||
{
|
||||
"claims": [{
|
||||
"text": "Người lớn: uống 500 mg metformin hydroclorid, 2 lần mỗi ngày. Liều tối đa 2 g mỗi ngày, chia làm nhiều lần.",
|
||||
"citations": [1],
|
||||
}],
|
||||
"evidence_sufficient": True,
|
||||
},
|
||||
entailment_payload={
|
||||
"entailed": True,
|
||||
"unsupported": [],
|
||||
"complete": False,
|
||||
"missing_evidence": [{
|
||||
"description": "Không nêu điều kiện độ ẩm",
|
||||
"evidence_quote": "độ ẩm",
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
assert grounded.generated is True
|
||||
assert grounded.answer is not None
|
||||
|
||||
|
||||
def test_entailment_accepts_after_one_semantic_pass():
|
||||
grounded, metrics = _answer(
|
||||
{"claims": [{"text": "Metformin dùng điều trị đái tháo đường", "citations": [1]}],
|
||||
"evidence_sufficient": True},
|
||||
entailment_payload=[
|
||||
{"entailed": True, "unsupported": []},
|
||||
{"entailed": False, "unsupported": [1]}, # never consulted
|
||||
],
|
||||
)
|
||||
|
||||
assert grounded.generated is True
|
||||
assert metrics.total(GENERATION_SERVED) == 1
|
||||
|
||||
|
||||
def test_entailment_provider_outage_fails_closed_to_abstain():
|
||||
grounded, metrics = _answer(
|
||||
{"answer": "Người lớn: 500 mg, 2 lần/ngày [1].", "evidence_sufficient": True},
|
||||
{"claims": [{"text": "Người lớn: 500 mg, 2 lần/ngày", "citations": [1]}],
|
||||
"evidence_sufficient": True},
|
||||
entailment_payload=AnswerGenerationUnavailable(),
|
||||
)
|
||||
|
||||
@@ -280,13 +331,16 @@ def test_entailment_provider_outage_fails_closed_to_abstain():
|
||||
assert metrics.total(GENERATION_REJECTED, reason="unsupported_claim") == 1
|
||||
|
||||
|
||||
def test_entailment_check_is_skipped_when_the_answer_has_no_claim_text():
|
||||
"""An answer that is nothing but a citation marker has no claim text for
|
||||
an entailment pass to check against — `_verify_entailment` must not call
|
||||
def test_entailment_check_is_skipped_when_there_are_no_claims():
|
||||
"""No claims at all (2026-08-10: the structured-claims schema makes a
|
||||
claim's `text` a required, non-empty field, so the old "answer is
|
||||
nothing but a bare citation marker" scenario can no longer occur — the
|
||||
analogous edge case is an empty `claims` list) has nothing for an
|
||||
entailment pass to check against — `_verify_entailment` must not call
|
||||
the provider at all. Proven by making that call raise: if the skip
|
||||
didn't fire, this would reject rather than serve the answer."""
|
||||
grounded, metrics = _answer(
|
||||
{"answer": "[1]", "evidence_sufficient": True},
|
||||
{"claims": [], "evidence_sufficient": True},
|
||||
entailment_payload=AnswerGenerationUnavailable(),
|
||||
)
|
||||
|
||||
@@ -297,7 +351,8 @@ def test_entailment_check_is_skipped_when_the_answer_has_no_claim_text():
|
||||
def test_citations_survive_generation():
|
||||
"""Provenance is the point; a prettier answer must not cost the folio."""
|
||||
grounded, _ = _answer(
|
||||
{"answer": "Người lớn: 500 mg [1].", "evidence_sufficient": True}
|
||||
{"claims": [{"text": "Người lớn: 500 mg", "citations": [1]}],
|
||||
"evidence_sufficient": True}
|
||||
)
|
||||
|
||||
assert grounded.generated is True
|
||||
@@ -313,9 +368,9 @@ def test_citations_survive_generation():
|
||||
[
|
||||
(AnswerGenerationUnavailable("revoked"), "provider_unavailable"),
|
||||
("not json at all", "malformed_output"),
|
||||
({"answer": "500 mg [1]"}, "malformed_output"),
|
||||
({"answer": 500, "evidence_sufficient": True}, "malformed_output"),
|
||||
({"answer": "...", "evidence_sufficient": False}, "evidence_insufficient"),
|
||||
({"claims": [{"text": "500 mg", "citations": [1]}]}, "malformed_output"),
|
||||
({"claims": 500, "evidence_sufficient": True}, "malformed_output"),
|
||||
({"claims": [], "evidence_sufficient": False}, "evidence_insufficient"),
|
||||
],
|
||||
)
|
||||
def test_every_generation_failure_abstains_instead_of_a_raw_source_dump(payload, reason):
|
||||
@@ -340,21 +395,21 @@ def test_evidence_insufficient_retries_once_and_recovers():
|
||||
pattern already known for entailment, just on a different field of the
|
||||
same call. A lone insufficient verdict must not be final."""
|
||||
grounded, metrics = _answer([
|
||||
{"answer": "...", "evidence_sufficient": False},
|
||||
{"answer": "Metformin dùng điều trị đái tháo đường [1].",
|
||||
{"claims": [], "evidence_sufficient": False},
|
||||
{"claims": [{"text": "Metformin dùng điều trị đái tháo đường", "citations": [1]}],
|
||||
"evidence_sufficient": True},
|
||||
])
|
||||
|
||||
assert grounded.generated is True
|
||||
assert grounded.answer == "Metformin dùng điều trị đái tháo đường [1]."
|
||||
assert grounded.answer == "Metformin dùng điều trị đái tháo đường"
|
||||
assert metrics.total(GENERATION_SERVED) == 1
|
||||
assert metrics.total(GENERATION_REJECTED) == 0
|
||||
|
||||
|
||||
def test_evidence_insufficient_twice_still_abstains():
|
||||
grounded, metrics = _answer([
|
||||
{"answer": "...", "evidence_sufficient": False},
|
||||
{"answer": "...", "evidence_sufficient": False},
|
||||
{"claims": [], "evidence_sufficient": False},
|
||||
{"claims": [], "evidence_sufficient": False},
|
||||
])
|
||||
|
||||
assert grounded.generated is False
|
||||
|
||||
@@ -259,7 +259,8 @@ def test_real_rag_agent_end_to_end_through_the_http_api():
|
||||
"needs_clarify": False, "clarify_reason": None,
|
||||
},
|
||||
answer_payload={
|
||||
"answer": f"{record['text']} [1].", "evidence_sufficient": True,
|
||||
"claims": [{"text": record["text"], "citations": [1]}],
|
||||
"evidence_sufficient": True,
|
||||
},
|
||||
)
|
||||
understander = LlmQueryUnderstander(
|
||||
|
||||
@@ -197,6 +197,34 @@ def test_retrieve_framed_pools_lexically_strong_neighbour_section():
|
||||
]
|
||||
|
||||
|
||||
def test_explicit_dosage_section_does_not_pool_a_lexical_interaction_match():
|
||||
documents = [
|
||||
RetrievalDocument(
|
||||
doc_id=f"paracetamol::{section}::0", drug_id="paracetamol",
|
||||
kind="prose", section_key=section,
|
||||
text=f"Nội dung mục {section}.", source_refs=(SOURCE,),
|
||||
)
|
||||
for section in ("lieu_luong_va_cach_dung", "tuong_tac_thuoc")
|
||||
]
|
||||
interaction = next(d for d in documents if d.section_key == "tuong_tac_thuoc")
|
||||
retriever = _OverviewRetriever(
|
||||
documents, lexical_hits=[SearchHit(interaction, score=10.0)]
|
||||
)
|
||||
service = RetrievalService(retriever, InMemoryParentStore([]), EvidencePolicy())
|
||||
|
||||
result = service.retrieve_framed(
|
||||
"paracetamol", "lieu_luong_va_cach_dung",
|
||||
"Liều uống paracetamol cho người lớn",
|
||||
)
|
||||
|
||||
assert result.decision == EvidenceDecision.ANSWERABLE
|
||||
returned_sections = {
|
||||
evidence.matched_doc_id.split("::")[1] for evidence in result.evidence
|
||||
}
|
||||
assert returned_sections == {"lieu_luong_va_cach_dung"}
|
||||
assert retriever.lexical_calls == []
|
||||
|
||||
|
||||
def test_row_hit_hydrates_complete_parent_and_keeps_citation():
|
||||
result = table_service().retrieve("acetylcystein 45 kg bao nhiêu ml", "acetylcystein")
|
||||
assert result.decision == EvidenceDecision.ANSWERABLE
|
||||
@@ -318,6 +346,18 @@ def test_verified_aliases_reach_common_parenthesized_drug_names():
|
||||
)
|
||||
|
||||
|
||||
def test_autocomplete_prioritizes_canonical_name_over_an_unrelated_trade_alias():
|
||||
resolver = CatalogDrugResolver({
|
||||
"paracetamol_acetaminophen": {"paracetamol", "acetaminophen"},
|
||||
"galantamin": {"paragal"},
|
||||
"metformin": {"metformin"},
|
||||
"alpha_tocopherol_vitamin_e": {"met-alpha"},
|
||||
})
|
||||
|
||||
assert resolver.complete("para", k=2)[0] == "paracetamol_acetaminophen"
|
||||
assert resolver.complete("met", k=2)[0] == "metformin"
|
||||
|
||||
|
||||
def test_verified_catalog_protects_canonical_substring_traps():
|
||||
resolver = CatalogDrugResolver(load_aliases(VERIFIED_ENTITIES))
|
||||
traps = {
|
||||
|
||||
@@ -51,6 +51,7 @@ class _FakeResolver:
|
||||
def __init__(self, known: dict[str, str], suggestions: dict[str, str] | None = None) -> None:
|
||||
self._known = known
|
||||
self._suggestions = suggestions or {}
|
||||
self.suggest_calls = 0
|
||||
|
||||
def resolve(self, query: str) -> _Resolution:
|
||||
low = query.lower()
|
||||
@@ -60,6 +61,7 @@ class _FakeResolver:
|
||||
return _Resolution()
|
||||
|
||||
def suggest(self, query: str, k: int = 3, min_score: float = 0.5):
|
||||
self.suggest_calls += 1
|
||||
low = query.lower()
|
||||
return [
|
||||
(drug_id, 0.9) for needle, drug_id in self._suggestions.items() if needle in low
|
||||
@@ -84,6 +86,21 @@ def test_drug_id_in_exact_underscore_form_resolves():
|
||||
assert frame.unknown_drugs == ()
|
||||
|
||||
|
||||
def test_exact_candidate_does_not_repeat_the_catalog_wide_fuzzy_scan():
|
||||
resolver = _FakeResolver({"metformin": "metformin"})
|
||||
understander = LlmQueryUnderstander(_FixedLlm({
|
||||
"turn_type": "drug_attribute", "drugs": ["metformin"],
|
||||
"unknown_drugs": [], "attribute": "chong_chi_dinh",
|
||||
"population": None, "weight_kg": None, "age_text": None,
|
||||
"indication": None, "needs_clarify": False, "clarify_reason": None,
|
||||
}), CATALOG, resolver)
|
||||
|
||||
frame = understander.understand("chống chỉ định metformin")
|
||||
|
||||
assert frame.drugs == ("metformin",)
|
||||
assert resolver.suggest_calls == 0
|
||||
|
||||
|
||||
def test_drug_id_echoed_with_spaces_instead_of_underscores_still_resolves():
|
||||
"""Reproduces the live 2026-08-06 bug on a genuine multi-turn shape: the
|
||||
drug is named in an earlier turn (in history), the current turn is just
|
||||
@@ -202,6 +219,40 @@ def test_quick_replies_are_parsed_when_the_model_offers_them():
|
||||
assert frame.quick_replies == ("Người lớn", "Trẻ em")
|
||||
|
||||
|
||||
def test_quick_replies_are_dynamic_but_bounded_before_becoming_ui_chips():
|
||||
understander = LlmQueryUnderstander(_FixedLlm({
|
||||
"turn_type": "dosing_calc", "drugs": ["paracetamol_acetaminophen"],
|
||||
"unknown_drugs": [], "attribute": None, "population": None,
|
||||
"weight_kg": None, "age_text": None, "indication": None,
|
||||
"needs_clarify": True, "clarify_reason": "Chọn nhóm phù hợp?",
|
||||
"quick_replies": [
|
||||
" Người lớn ", "người lớn", "Trẻ em", 12,
|
||||
"Phụ nữ có thai", "Người cao tuổi", "Lựa chọn thứ năm",
|
||||
],
|
||||
}), CATALOG, RESOLVER)
|
||||
|
||||
frame = understander.understand("liều paracetamol")
|
||||
|
||||
assert frame.quick_replies == (
|
||||
"Người lớn", "Trẻ em", "Phụ nữ có thai", "Người cao tuổi"
|
||||
)
|
||||
|
||||
|
||||
def test_string_false_does_not_turn_into_a_clarification():
|
||||
understander = LlmQueryUnderstander(_FixedLlm({
|
||||
"turn_type": "drug_attribute", "drugs": ["paracetamol_acetaminophen"],
|
||||
"unknown_drugs": [], "attribute": "chong_chi_dinh", "population": None,
|
||||
"weight_kg": None, "age_text": None, "indication": None,
|
||||
"needs_clarify": "false", "clarify_reason": "Không được hiển thị",
|
||||
"quick_replies": ["Có", "Không"],
|
||||
}), CATALOG, RESOLVER)
|
||||
|
||||
frame = understander.understand("chống chỉ định paracetamol")
|
||||
|
||||
assert frame.needs_clarify is False
|
||||
assert frame.quick_replies == ()
|
||||
|
||||
|
||||
def test_missing_quick_replies_key_defaults_to_empty_not_a_crash():
|
||||
"""The model is asked for `quick_replies` but structured-output providers
|
||||
aren't guaranteed to include every optional key — a clarify without it
|
||||
@@ -240,6 +291,26 @@ def test_route_is_parsed_when_the_model_resolves_it():
|
||||
assert frame.needs_clarify is False
|
||||
|
||||
|
||||
def test_follow_up_exposes_a_first_class_standalone_query():
|
||||
understander = LlmQueryUnderstander(_FixedLlm({
|
||||
"turn_type": "drug_attribute", "drugs": ["metformin"],
|
||||
"unknown_drugs": [], "attribute": "chong_chi_dinh",
|
||||
"population": None, "weight_kg": None, "age_text": None,
|
||||
"indication": None, "route": None,
|
||||
"standalone_query": "Chống chỉ định của metformin",
|
||||
"depends_on_previous_turn": True,
|
||||
"needs_clarify": False, "clarify_reason": None,
|
||||
}), CATALOG, RESOLVER)
|
||||
|
||||
frame = understander.understand(
|
||||
"thế còn chống chỉ định?",
|
||||
history=("Người dùng: Metformin dùng để làm gì?",),
|
||||
)
|
||||
|
||||
assert frame.standalone_query == "Chống chỉ định của metformin"
|
||||
assert frame.depends_on_previous_turn is True
|
||||
|
||||
|
||||
def test_missing_route_key_defaults_to_none_not_a_crash():
|
||||
understander = LlmQueryUnderstander(_FixedLlm({
|
||||
"turn_type": "dosing_calc", "drugs": [],
|
||||
|
||||
Reference in New Issue
Block a user