Checkpoint frontend UI/UX overhaul and ingestion embed benchmark work

This commit is contained in:
2026-08-06 17:21:21 +07:00
parent 1e8cbdb586
commit a4b8e1c4db
78 changed files with 6761 additions and 654 deletions
-141
View File
@@ -1,141 +0,0 @@
# Instructions for Claude working in this repo
## Never fabricate, never bluff
Do not state a number, a test result, a "verified" claim, or a capability
estimate unless it is backed by something you actually ran or actually
read. If you haven't checked something, say so explicitly ("not verified
yet", "estimate, not measured") instead of presenting a guess as fact.
**Why:** this project involves parsing a medical reference book into a
chatbot's knowledge base — false confidence here is not a cosmetic bug, it
propagates into medical answers. During the ingestion-strategy
investigation, a size-based heading threshold silently dropped ~15% of real
monographs before whole-document validation caught it; a monograph-boundary
scan was initially run on 1405 of 1668 pages before being caught and
corrected. Confident-sounding claims that turn out wrong cost real rework
and could cost real answer quality once this is live.
**How to apply:**
- Prefer "I ran X and got Y" over "X should work" — run the check.
- When asked something you don't know for certain (throughput estimates,
whether a tool/library works on this environment, whether a heuristic
holds at scale), say what's measured vs. estimated, explicitly.
- Whole-document / whole-scope validation over small-sample claims — if the
user states a total (e.g. "1668 pages"), any check must cover that literal
total before being reported as done, not a convenient subset.
- When a claim turns out wrong after fuller checking, say so plainly and
show the corrected result — don't quietly smooth over the miss.
See `docs/pdf-parsing-outlier-catalog.md` and
`docs/adr/0003-pdf-parsing-strategy.md` for the concrete track record this
rule comes from.
**What "verified" means:** when reporting something as verified, state (1)
the command/test/script/manual check that was run, (2) the exact input
scope, (3) the expected invariant or acceptance condition, (4) the observed
result, and (5) any part of the requested scope that was *not* covered.
Distinct scopes (unit test, regression fixture, selected-page sample,
selected monographs, all detected monographs, full 1668-page document) are
not interchangeable — don't describe one as another. Avoid words like
"fully verified", "complete", "all", "no data lost", or "production-ready"
unless the checks actually performed support that literal claim.
## Real code follows Clean Code / Clean Architecture / SoC / DRY / SOLID
Applies to anything meant to be committed as part of the actual system
(`apps/*`, `ingestion/*`, `packages/*`) — not throwaway investigation
scripts (e.g. a one-off scan to check a hypothesis), which may stay quick
and disposable as long as they're never confused for production code and
get deleted once their finding is written down.
**Why this is a written rule, not just an intention:** intentions from one
conversation don't carry into the next session, and under time pressure or
mid-refactor it's easy to let a principle slip without noticing — a written
checklist is what actually catches that, the same reasoning behind the
"never fabricate" rule above.
**How to apply, concretely, in this repo:**
- **SoC**: keep the `ingestion/` pipeline stages (`extract/`, `segment/`,
`chunk/`, `embed/`, `load/`) genuinely independent — extraction code must
not know about chunking, chunking must not call OpenAI, etc.
- **DRY**: shared logic (e.g. the bold-span heading/boundary detector) lives
in exactly one module that both the real pipeline and any validation
script import — never re-implemented per script, which is what happened
during exploratory investigation and is fine there, but must not carry
into real code.
- **SOLID**: single-responsibility modules/classes (a detector detects, it
doesn't also chunk); open/closed section taxonomy (adding a new section
label — e.g. a field like "Tên thương mại" not in the book's own
documented list — must not require editing existing matching code, only
adding an entry); dependency inversion at infrastructure boundaries
(`ai-service`'s domain/retrieval logic depends on an interface, not a
hard import of the Qdrant SDK or OpenAI client directly, so it stays
testable without live services).
- **Clean Architecture**: domain/business logic (parsing rules, chunking
rules, retrieval/grounding logic) stays independent of infrastructure
(OpenAI SDK, Qdrant client, filesystem, NestJS framework details) so it's
testable in isolation.
- **Clean Code**: meaningful names, small functions, minimal comments (only
where the *why* isn't obvious from the code itself) — matches the
no-comments-unless-non-obvious style already used throughout this
project's docs and ADRs.
## Preserve provenance
Every extracted or transformed unit must retain enough provenance to trace
it back to the source document — depending on the data type, this may
include document id, page number, source block/span id, bounding box,
reading-order position, table id and row/column coordinates, formula
source span, monograph id, section path, and extraction method/parser
version.
**Why:** this is a medical reference book being turned into a chatbot's
knowledge base — if an answer is wrong, being able to trace a chunk back to
the exact page/span it came from is how it gets debugged and corrected.
Normalized text that "looks right" is not the same guarantee as text that
is traceable.
**How to apply:**
- Don't discard provenance fields just because the normalized text appears
correct — a text value that can't be traced back to its source is not a
fully validated extraction result.
- When adding a new pipeline stage or record type, carry existing
provenance fields through rather than dropping them at the boundary.
## Investigation scripts are evidence tools, not production code
One-off investigation scripts (e.g. scanning the corpus to check a
hypothesis) may optimize for speed, but they must:
- be clearly named or located as temporary investigation code;
- state or record the scope they scanned;
- output enough information to reproduce or inspect the finding;
- not be imported by production code, and not become the only
implementation of a parsing rule;
- not be cited as whole-document evidence unless they actually covered the
whole document;
- be deleted after their finding is captured in a regression test, fixture,
ADR, or the outlier catalog.
When an investigation uncovers a real parsing rule, move that rule into the
production implementation and validate both the production code and the
regression fixture — per [[DRY]] above, the rule should end up living in
exactly one place.
## Definition of done
A task is not complete merely because code was written. Before reporting
completion:
- run the most relevant available tests and validation commands, and
report the exact commands/checks run and whether each passed or failed;
- state the validation scope (see "What 'verified' means" above);
- add or update a regression fixture for each parser bug fixed;
- confirm intended provenance fields remain present;
- check for silent loss of expected monographs, sections, tables, formulas,
or source references when the task could affect them;
- avoid whole-document claims when only sample validation was performed;
- list anything not tested, not measured, blocked, or still uncertain.
If only part of the task is complete, report the completed and incomplete
parts separately — don't hide failing tests, unexpected counts, incomplete
coverage, or contradictory evidence to present a cleaner status.
-58
View File
@@ -1,7 +1,5 @@
from __future__ import annotations
import hashlib
import math
from typing import Any
from rag.ports import QueryEmbeddingUnavailable
@@ -120,59 +118,3 @@ class BedrockCohereQueryEmbedder:
f"expected {self._dimensions}"
)
return values
class SectionOnlyQueryEmbedder:
"""Refuses to embed, confining retrieval to the route that is verified.
The section route resolves the question's attribute to a `section_key` and
filters on it; no vector is involved, and it measured 16/16 on the
human-written golden questions on 2026-08-04. Similarity measured 0.544 and
its provider is currently revoked.
Declining locally and immediately is better than the two alternatives it
replaces: a `cohere-v4` round-trip spends the boto3 retry budget before
failing, and `LocalHashQueryEmbedder` searches a SHA-256 vector against a
Cohere collection, which returns confident and meaningless hits.
"""
def __init__(self, dimensions: int) -> None:
if dimensions <= 0:
raise ValueError("dimensions must be positive")
self._dimensions = dimensions
@property
def dimensions(self) -> int:
return self._dimensions
def embed_query(self, text: str) -> list[float]: # noqa: ARG002
# The text is irrelevant: this embedder exists to refuse, not to embed.
raise QueryEmbeddingUnavailable(
"no query embedding provider is enabled; set EMBEDDING_PROVIDER to "
"use the similarity fallback"
)
class LocalHashQueryEmbedder:
"""Deterministic local plumbing probe; not a semantic retrieval model."""
def __init__(self, dimensions: int) -> None:
if dimensions <= 0:
raise ValueError("dimensions must be positive")
self._dimensions = dimensions
@property
def dimensions(self) -> int:
return self._dimensions
def embed_query(self, text: str) -> list[float]:
vector = [0.0] * self._dimensions
for token in text.casefold().split():
digest = hashlib.sha256(token.encode("utf-8")).digest()
index = int.from_bytes(digest[:4], "big") % self._dimensions
sign = 1.0 if digest[4] & 1 else -1.0
vector[index] += sign
norm = math.sqrt(sum(value * value for value in vector))
if norm == 0:
return vector
return [value / norm for value in vector]
+16 -3
View File
@@ -22,6 +22,19 @@ class RetrievalTrace:
class PostgresTraceRepository:
"""Opens a new connection per call — no pooling (F-09: a real pool, with
startup-time lifecycle, is a further improvement not made here).
`connect_timeout` matters more than it looks: found live 2026-08-06 that
an unreachable Postgres (packets dropped, not actively refused) makes a
bare `psycopg.connect()` hang on the OS-level TCP timeout — tens of
seconds, not immediate — which defeats a caller's try/except fail-open
around `save()` just as effectively as no try/except at all, since the
exception it's waiting for never arrives in time. `routers/rag.py`
wraps `save()` to keep a trace outage from failing an already-computed
answer; this bounds how long that protection can take to kick in.
"""
def __init__(self, dsn: str) -> None:
self._dsn = dsn
@@ -29,7 +42,7 @@ class PostgresTraceRepository:
import psycopg
statement = migration_path.read_text(encoding="utf-8")
with psycopg.connect(self._dsn) as connection:
with psycopg.connect(self._dsn, connect_timeout=5) as connection:
connection.execute(statement)
def save(
@@ -46,7 +59,7 @@ class PostgresTraceRepository:
import psycopg
trace_id = str(uuid.uuid4())
with psycopg.connect(self._dsn) as connection:
with psycopg.connect(self._dsn, connect_timeout=5) as connection:
connection.execute(
"""
INSERT INTO rag_retrieval_trace (
@@ -64,7 +77,7 @@ class PostgresTraceRepository:
def get(self, trace_id: str) -> RetrievalTrace | None:
import psycopg
with psycopg.connect(self._dsn) as connection:
with psycopg.connect(self._dsn, connect_timeout=5) as connection:
row = connection.execute(
"""
SELECT trace_id::text, query_text, subject_scope, query_intent,
+4 -1
View File
@@ -31,7 +31,10 @@ _HELP = {
ABSTENTION: "Answers refused, by the reason retrieval gave.",
GENERATION_REJECTED: (
"Generations discarded before reaching the caller. `reason=\"ungrounded_number\"` "
"counts answers that stated a figure absent from the cited source."
"counts answers that stated a figure absent from the cited source; "
"`reason=\"uncited_claim\"` counts claims with no valid citation at all; "
"`reason=\"unsupported_claim\"` counts claims the entailment pass judged "
"not actually stated by the block they cite."
),
GENERATION_SERVED: "Generations that passed grounding verification and were served.",
ANSWER_EXTRACTIVE: "Answers served as verbatim source text.",
+87 -31
View File
@@ -2,22 +2,51 @@ from __future__ import annotations
from qdrant_client import QdrantClient
from adapters.embedding import (
BedrockCohereQueryEmbedder,
LocalHashQueryEmbedder,
SectionOnlyQueryEmbedder,
)
from adapters.embedding import BedrockCohereQueryEmbedder
from adapters.postgres import PostgresTraceRepository
from adapters.qdrant import QdrantParentStore, QdrantRetriever
from config import Settings
from rag.agent import RagAgent
from rag.answer import GroundedAnswerService
from rag.artifacts import load_aliases
from rag.conversation import DeterministicSummariser, InMemoryConversationStore
from rag.conversational import ConversationalLoopService
from rag.manifest import MANIFEST_POINT_ID, check_manifest, manifest_collection
from rag.metrics import NullMetrics
from rag.routing import CatalogDrugResolver, QueryRoutingService
from rag.sections import SectionResolver
from rag.service import EvidencePolicy, RetrievalService
from rag.understanding import LlmQueryUnderstander
# How many aliases to show per candidate drug (F-04 bounds *which* drugs are
# shown at all, per turn; this bounds how many names each shown one gets).
_CATALOG_ALIASES_PER_DRUG = 3
def _catalog_names(aliases: dict[str, set[str]]) -> dict[str, str]:
"""The name(s) shown to the LLM for each drug_id.
Found live 2026-08-06: picking the first 3 aliases *alphabetically* could
drop the drug's own canonical/INN name entirely — paracetamol has 191
aliases (mostly trade names), and the alphabetically-first 3 were
"0Frezefev, ABAB, Ace kid 80", none recognizable. Mid-conversation, once
the current turn no longer restates the drug name in raw text, the model
has to re-derive it from history + this catalog line alone — with no
recognizable name shown, it read the earlier "paracetamol" mention as an
unknown drug and answered "not found in the formulary" for a drug that
plainly is. Fixed by always showing the drug_id's own name form first
(guaranteed present, always recognizable — it's the exact string a user
who names a drug is most likely to type), then filling remaining slots
with short, ALL-CAPS-preferring aliases (the book's own heading
convention, so usually the generic name, not a dosage-suffixed brand
like "Ace kid 80").
"""
result: dict[str, str] = {}
for drug_id, names in aliases.items():
canonical = drug_id.replace("_", " ")
ranked = sorted(names, key=lambda n: (not n.isupper(), len(n), n))
extra = [n for n in ranked if n.strip().casefold() != canonical]
shown = [canonical, *extra][:_CATALOG_ALIASES_PER_DRUG]
result[drug_id] = ", ".join(dict.fromkeys(shown))
return result
def _build_metrics(settings: Settings):
@@ -73,15 +102,37 @@ def _build_reranker(settings: Settings):
return BedrockCohereReranker(region=settings.aws_region)
def _verify_corpus_manifest(client, collection: str, embedder, settings: Settings) -> None:
"""Read the sidecar manifest point the ingestion loader writes
(`ingestion/ingestion/load/manifest.py`) and refuse to start on a
mismatch. Raises `rag.manifest.ManifestMismatch` (a `RuntimeError`),
which crashes startup — a deliberate refusal, not an oversight: this
runs at process start (`main.py` calls `build_runtime` at import time),
so a mismatch here means the process never comes up and never serves a
query from a corpus it wasn't verified against.
"""
sidecar = manifest_collection(collection)
payload = None
# This qdrant-client version has no `collection_exists`, and
# `get_collection` (singular) is a known parse-bug risk in this
# environment — list collections and check membership instead.
existing = {col.name for col in client.get_collections().collections}
if sidecar in existing:
points = client.retrieve(sidecar, [MANIFEST_POINT_ID], with_payload=True)
if points:
payload = points[0].payload
check_manifest(payload, collection, embedder.model_id, settings.embedding_dimensions)
def build_runtime(settings: Settings):
metrics = _build_metrics(settings)
if settings.embedding_provider == "disabled":
return None, None, PostgresTraceRepository(settings.postgres_dsn), metrics
if settings.embedding_provider not in ("section-only", "local-smoke", "cohere-v4"):
if settings.embedding_provider != "cohere-v4":
raise ValueError(
"No production query embedder is configured. Supported values: "
"EMBEDDING_PROVIDER=section-only (default; section route only), "
"local-smoke (plumbing only) or cohere-v4"
"EMBEDDING_PROVIDER=cohere-v4 (semantic query embedding) or "
"disabled. The old local/section-only stubs were removed."
)
client = QdrantClient(
@@ -89,19 +140,21 @@ def build_runtime(settings: Settings):
api_key=settings.qdrant_api_key,
timeout=30,
)
# A collection built with one model and queried with another returns hits
# and raises nothing; the results are just meaningless. Keep this in step
# with `model_id` in the collection's manifest.
if settings.embedding_provider == "cohere-v4":
embedder = BedrockCohereQueryEmbedder(
settings.embedding_dimensions, region=settings.aws_region
)
elif settings.embedding_provider == "local-smoke":
embedder = LocalHashQueryEmbedder(settings.embedding_dimensions)
else:
embedder = SectionOnlyQueryEmbedder(settings.embedding_dimensions)
# F-05: a collection built with one model and queried with another
# returns hits and raises nothing — the results are just meaningless,
# with no error to notice. Refuse to start rather than search with
# vectors this collection was not built from.
_verify_corpus_manifest(client, settings.qdrant_collection, embedder, settings)
section_resolver = SectionResolver()
resolver = CatalogDrugResolver(load_aliases(settings.entities_path))
aliases = load_aliases(settings.entities_path)
# Kept only for deterministic input-time autocomplete (`RagAgent.complete`)
# and to satisfy `GroundedAnswerService`'s constructor — its fuzzy
# `.resolve()` is no longer on the live query path; `RagAgent` resolves
# drug identity through `LlmQueryUnderstander` against the same catalog.
resolver = CatalogDrugResolver(aliases)
retrieval = RetrievalService(
QdrantRetriever(client, settings.qdrant_collection, embedder),
QdrantParentStore(client, settings.qdrant_collection),
@@ -110,19 +163,22 @@ def build_runtime(settings: Settings):
reranker=_build_reranker(settings),
)
routing = QueryRoutingService(retrieval, resolver)
generator = _build_generator(settings)
answers = GroundedAnswerService(
routing, generator=_build_generator(settings), metrics=metrics or NullMetrics()
routing, generator=generator, metrics=metrics or NullMetrics()
)
# The conversational layer reuses the same resolvers and the safe answer
# engine, adding only turn understanding, follow-up inheritance and the
# clarify/refine loop around it. InMemory store for now; a Postgres-backed
# store is the persistence follow-up.
conversational = ConversationalLoopService(
trace_writer = PostgresTraceRepository(settings.postgres_dsn)
if generator is None:
# The new front end understands a turn with the same LLM call that
# answers it — with no generator configured there is no query
# understanding either, so there is no conversational/agent
# capability to offer. Answer-only (retrieval-verified, no
# generation) still works through `answers` directly.
return answers, None, trace_writer, metrics
agent = RagAgent(
understander=LlmQueryUnderstander(generator, _catalog_names(aliases), resolver),
retrieval=retrieval,
answers=answers,
resolver=resolver,
section_resolver=section_resolver,
store=InMemoryConversationStore(),
summariser=DeterministicSummariser(),
metrics=metrics or NullMetrics(),
autocomplete=resolver,
)
return answers, conversational, PostgresTraceRepository(settings.postgres_dsn), metrics
return answers, agent, trace_writer, metrics
+4 -3
View File
@@ -18,9 +18,10 @@ class Settings(BaseSettings):
default="postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu",
repr=False,
)
# Defaults to the route that is measured and needs no provider. Raising it
# to `cohere-v4` enables the similarity fallback and requires live Bedrock.
embedding_provider: str = "section-only"
# `cohere-v4` = semantic query embedding in the corpus's own space (requires
# live Bedrock). `disabled` skips retrieval entirely. The old local-hash /
# section-only stub embedders were removed in the 2026-08-06 rebuild.
embedding_provider: str = "cohere-v4"
embedding_dimensions: int = 1024
evidence_minimum_score: float = 0.12
aws_region: str = "us-east-1"
+194
View File
@@ -0,0 +1,194 @@
"""The new RAG orchestrator — LLM understanding in, grounded answer out.
Replaces the old front-end wholesale:
- `CatalogDrugResolver` (fuzzy) + `SectionResolver` (keyword) -> `understanding.py`
- `ConversationalLoopService` + `conversation.py` (Focus / Summariser / manual
follow-up inheritance) -> the LLM reads a plain turn history and resolves
"thuốc đó" / "còn liều thì sao" itself.
What is deliberately KEPT because it is the safety spine, not the brittle part:
- `RetrievalService.retrieve_framed` (Qdrant section/overview retrieval, whole
section, provenance, quarantine `VERIFY_PDF`),
- `GroundedAnswerService.answer_from_result` (`grounding.verify` + entailment
on every generated claim; a configured generator that fails abstains rather
than degrading to a raw source dump).
This module owns routing only; it states no medical fact of its own.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol
from .answer import Citation, GroundedAnswerService
from .models import EvidenceDecision, RetrievalResult
from .policy import looks_non_human
from .service import RetrievalService
from .understanding import QueryFrame, QueryUnderstander
TUONG_TAC = "tuong_tac_thuoc"
HISTORY_TURNS = 6
class AutocompleteSource(Protocol):
"""As-you-type suggestion, kept deterministic and independent of the LLM
understander — a prefix match needs no model call."""
def complete(self, prefix: str, k: int) -> list[str]: ...
@dataclass(frozen=True)
class AgentReply:
decision: str # answerable | abstain | clarify | verify_pdf
reason: str
answer: str | None = None
clarification: str | None = None
citations: tuple[Citation, ...] = ()
drugs: tuple[str, ...] = ()
turn_type: str = ""
generated: bool = False
class RagAgent:
def __init__(
self,
understander: QueryUnderstander,
retrieval: RetrievalService,
answers: GroundedAnswerService,
autocomplete: AutocompleteSource | None = None,
history_turns: int = HISTORY_TURNS,
) -> None:
self._understander = understander
self._retrieval = retrieval
self._answers = answers
self._autocomplete = autocomplete
self._history_turns = history_turns
self._history: dict[str, list[str]] = {}
def complete(self, prefix: str, k: int = 8) -> list[str]:
"""Display names matching a typed prefix, for input autocomplete."""
if self._autocomplete is None:
return []
return [_display_name(drug_id) for drug_id in self._autocomplete.complete(prefix, k)]
def handle(self, turn: str, conversation_id: str | None = None) -> AgentReply:
history = self._history.get(conversation_id, []) if conversation_id else []
frame = self._understander.understand(turn, tuple(history))
reply = self._route(turn, frame)
if conversation_id is not None:
self._remember(conversation_id, turn, reply)
return reply
def _route(self, turn: str, frame: QueryFrame) -> AgentReply:
tt = frame.turn_type
if frame.needs_clarify and frame.clarify_reason:
return AgentReply("clarify", "needs_more_info",
clarification=frame.clarify_reason,
drugs=frame.drugs, turn_type=tt)
if tt == "smalltalk":
return AgentReply(
"answerable", "smalltalk",
answer="Chào anh/chị! Em là trợ lý tra cứu Dược thư Quốc gia Việt Nam "
"2018, sẵn sàng hỗ trợ tra liều dùng, chống chỉ định, tương tác "
"thuốc... Anh/chị đang cần tra thuốc nào ạ?",
turn_type=tt)
if tt in ("out_of_scope",) or looks_non_human(turn):
return AgentReply(
"abstain", "out_of_scope",
answer="Nội dung này nằm ngoài phần chuyên luận thuốc của Dược thư "
"(có thể thuộc phần hướng dẫn chung/phụ lục chưa được đưa vào). "
"Tôi chưa có dữ liệu để trả lời chính xác.",
turn_type=tt)
if not frame.drugs:
if frame.unknown_drugs:
names = ", ".join(frame.unknown_drugs)
return AgentReply(
"abstain", "drug_not_in_formulary",
answer=f"Không tìm thấy \"{names}\" trong Dược thư Quốc gia Việt Nam.",
turn_type=tt)
if tt == "symptom_to_drug":
# Reverse lookup (indication/adverse-effect -> drugs) is a distinct
# retrieval mode, not yet wired. Be honest rather than abstain blank.
return AgentReply(
"clarify", "reverse_lookup_not_ready",
clarification="Tra ngược theo triệu chứng/chỉ định đang được bổ "
"sung. Anh/chị cho biết tên thuốc cụ thể để tôi tra giúp?",
turn_type=tt)
return AgentReply(
"clarify", "no_drug",
clarification="Anh/chị muốn tra thuốc nào?", turn_type=tt)
if tt == "interaction" and len(frame.drugs) >= 2:
return self._interaction(turn, frame)
# drug_attribute / drug_overview / dosing_calc / fallback: one drug + section
return self._single_drug(turn, frame)
def _single_drug(self, turn: str, frame: QueryFrame) -> AgentReply:
result = self._retrieval.retrieve_framed(
frame.drugs[0], frame.attribute, turn,
is_overview=frame.turn_type == "drug_overview",
)
return self._grounded(turn, result, frame)
def _interaction(self, turn: str, frame: QueryFrame) -> AgentReply:
"""Gather the interaction section of each named drug and synthesise.
Absence of a match is stated as "not found in each drug's interaction
section", never as "safe" — the answer layer's grounding still applies.
"""
evidences = []
for drug_id in frame.drugs:
part = self._retrieval.retrieve_framed(drug_id, TUONG_TAC, turn)
if part.decision == EvidenceDecision.ANSWERABLE:
evidences.extend(part.evidence)
if not evidences:
listed = "".join(frame.drugs)
return AgentReply(
"abstain", "no_interaction_evidence",
answer=f"Không tìm thấy mục tương tác thuốc cho {listed} trong Dược "
"thư. Điều này KHÔNG có nghĩa là an toàn khi phối hợp.",
drugs=frame.drugs, turn_type=frame.turn_type)
combined = RetrievalResult(
EvidenceDecision.ANSWERABLE, "interaction_evidence",
tuple(evidences),
)
return self._grounded(turn, combined, frame)
def _grounded(
self, turn: str, result: RetrievalResult, frame: QueryFrame
) -> AgentReply:
ga = self._answers.answer_from_result(turn, result)
decision = ga.result.decision.value
if ga.clarification is not None:
decision = "clarify"
return AgentReply(
decision=decision,
reason=ga.result.reason,
answer=ga.answer,
clarification=ga.clarification,
citations=ga.citations,
drugs=frame.drugs,
turn_type=frame.turn_type,
generated=ga.generated,
)
def _remember(self, conversation_id: str, turn: str, reply: AgentReply) -> None:
history = self._history.setdefault(conversation_id, [])
history.append(f"Người dùng: {turn}")
spoken = reply.answer or reply.clarification
if spoken:
history.append(f"Trợ lý: {spoken[:300]}")
# Keep only the recent window; the LLM re-reads it every turn.
excess = len(history) - self._history_turns * 2
if excess > 0:
del history[:excess]
def _display_name(drug_id: str) -> str:
"""A readable display name from a drug id ('paracetamol_acetaminophen')."""
return drug_id.replace("_", " ").title()
+185 -23
View File
@@ -8,7 +8,7 @@ from . import grounding, metrics as metric_names
from .metrics import Metrics, NullMetrics
from .models import EvidenceDecision, QueryIntent, RetrievalResult, SubjectScope
from .ports import AnswerGenerationUnavailable, AnswerGenerator
from .prompt import build_request
from .prompt import build_entailment_request, build_request, build_sufficiency_request
from .routing import QueryRoutingService
@@ -30,17 +30,38 @@ class GroundedAnswer:
answer: str | None
citations: tuple[Citation, ...] = ()
generated: bool = False
# Set when the model decided the turn is under-specified and asked back
# (e.g. a dose question with no age/weight). The answer field carries the
# question; the caller renders it as a clarification, not a final answer.
clarification: str | None = None
@dataclass(frozen=True)
class _GenOutcome:
answer: str | None = None
clarification: str | None = None
class GroundedAnswerService:
"""Retrieval decides what is true; generation only decides how it reads.
When a generator is configured, its output replaces the extractive text
**only** if `grounding.verify` confirms every figure and citation in it
traces back to the retrieved evidence. Anything else — an unsupported
number, a citation to nothing, a provider outage, malformed output — falls
back to quoting the source verbatim, which is always available because it
was computed first.
Two operating modes, not to be confused with each other:
- **No generator configured** (`generator=None`, e.g. `ANSWER_PROVIDER=
disabled`) is retrieval-only mode, a deliberate and fully supported
way to run this service. It quotes the retrieved source verbatim.
- **A generator IS configured.** Its output replaces the extractive text
only if it clears two independent checks: `grounding.verify` (every
figure and citation traces to the specific evidence block it cites,
and every claim carries one) and `_verify_entailment` (a second LLM
pass confirming each cited claim's *content* — not just its numbers —
is actually stated by that block). If a configured generation fails
any check, or the provider itself is unreachable, or its output is
malformed, the turn **abstains** (`reason="generation_unavailable"`
or the specific `grounding.verify` reason) rather than silently
degrading to a raw source dump — this product is a real LLM chatbot,
and a citation-stapled paragraph of book text is not an acceptable
stand-in for an answer the model was supposed to produce.
"""
def __init__(
@@ -70,6 +91,15 @@ class GroundedAnswerService:
)
else:
result = self._routing.retrieve(query, subject_scope, intent)
return self.answer_from_result(query, result)
def answer_from_result(
self, query: str, result: RetrievalResult
) -> GroundedAnswer:
"""Everything after retrieval — grounding, sufficiency, generation,
citations. Split out so the new understanding-driven orchestrator
(`rag/agent.py`) reuses the safe answer path without going through the
old `QueryRoutingService` text resolution."""
if result.decision == EvidenceDecision.ABSTAIN:
self._metrics.increment(metric_names.ABSTENTION, reason=result.reason)
return GroundedAnswer(result, None)
@@ -102,25 +132,62 @@ class GroundedAnswerService:
f"{text} [{index}]" for index, text in enumerate(evidence_texts, start=1)
)
generated = self._generate(query, evidence_texts, intro=result.is_drug_overview)
answer_text = extractive if generated is None else generated
# 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.
clarify_q = self._check_sufficiency(query, evidence_texts, result.is_drug_overview)
if clarify_q is not None:
return GroundedAnswer(result, clarify_q, (), clarification=clarify_q)
outcome = self._generate(query, evidence_texts, intro=result.is_drug_overview)
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
)
if outcome.answer is None:
if self._generator is None:
# No generator configured at all — retrieval-only mode. A
# 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)
# 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
# LLM chatbot, not the retired offline-extractive build — a raw
# source dump is not an acceptable stand-in for a failed
# generation, so this abstains instead of silently degrading to
# one.
self._metrics.increment(
metric_names.ABSTENTION, reason="generation_unavailable"
)
return GroundedAnswer(
replace(
result,
decision=EvidenceDecision.ABSTAIN,
reason="generation_unavailable",
),
None,
)
# 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, answer_text) or all_citations
if generated is None:
self._metrics.increment(metric_names.ANSWER_EXTRACTIVE)
return GroundedAnswer(result, extractive, citations)
citations = self._cited_only(indexed, outcome.answer) or all_citations
self._metrics.increment(metric_names.GENERATION_SERVED)
return GroundedAnswer(result, generated, citations, generated=True)
return GroundedAnswer(result, outcome.answer, citations, generated=True)
def _generate(
self, query: str, evidence_texts: tuple[str, ...], intro: bool = False
) -> str | None:
"""A verified generation, or None to fall back to the source text."""
) -> "_GenOutcome":
"""A verified generation, a clarifying question, or empty to fall back."""
if self._generator is None or not evidence_texts:
return None
return _GenOutcome()
request = build_request(query, evidence_texts, intro=intro)
try:
@@ -129,7 +196,7 @@ class GroundedAnswerService:
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="provider_unavailable"
)
return None
return _GenOutcome()
try:
payload = json.loads(raw)
@@ -139,28 +206,123 @@ class GroundedAnswerService:
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="malformed_output"
)
return None
return _GenOutcome()
# The model asked for a missing detail (age/weight/renal function/
# indication…) instead of listing every band. A clarify is not a grounded
# 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 _GenOutcome(clarification=clarify.strip())
if not isinstance(answer, str) or not isinstance(sufficient, bool):
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="malformed_output"
)
return None
return _GenOutcome()
if not sufficient:
# The model says the evidence does not answer the question. Showing
# the retrieved section verbatim lets the clinician judge that.
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="evidence_insufficient"
)
return None
return _GenOutcome()
report = grounding.verify(answer, evidence_texts)
if not report.grounded:
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason=report.reason
)
return _GenOutcome()
if not self._verify_entailment(answer, evidence_texts):
self._metrics.increment(
metric_names.GENERATION_REJECTED, reason="unsupported_claim"
)
return _GenOutcome()
return _GenOutcome(answer=answer)
def _verify_entailment(self, answer: str, evidence_texts: tuple[str, ...]) -> bool:
"""A second, adversarial LLM pass over an answer that already passed
`grounding.verify`.
The regex check above only binds numbers and citation indices — it
has no notion of meaning, so "Metformin chữa ung thư [1]" citing an
evidence block about "điều trị đái tháo đường" sails through it
untouched: right drug, right citation shape, fabricated indication.
This call is what catches that: each substantive, validly-cited claim
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. A single rejection is
NOT: 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. So a reject triggers one same-claim retry, and
only a second, agreeing reject discards the generation; a single
provider outage/malformed response still fails closed immediately
(that failure mode is reliable, not noisy — no retry needed there).
An answer with no claim text at all (nothing between or after its
citation markers) is vacuously fine — nothing to verify, no call.
"""
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)
]
if not claims:
return True
request = build_entailment_request(claims)
first = self._run_entailment_check(request)
if first is None:
return False
if first:
return True
second = self._run_entailment_check(request)
return bool(second)
def _run_entailment_check(self, request) -> bool | None:
"""One entailment call. `None` = outage/malformed (fails closed by the
caller without a retry); `True`/`False` = the judge's verdict."""
try:
raw = self._generator.generate(request.system, request.user, request.schema)
except AnswerGenerationUnavailable:
return None
try:
payload = json.loads(raw)
entailed = payload["entailed"]
unsupported = payload["unsupported"]
except (ValueError, TypeError, KeyError):
return None
if not isinstance(entailed, bool) or not isinstance(unsupported, list):
return None
return entailed and not unsupported
def _check_sufficiency(
self, query: str, evidence_texts: tuple[str, ...], intro: bool = False
) -> str | None:
"""A focused reasoning call: is the turn specific enough to answer, or
must we ask? Returns a clarifying question, or None to proceed.
Skipped without a model, for a bare-name intro (not a dose), or for a
single evidence block (nothing to disambiguate)."""
if self._generator is None or intro or len(evidence_texts) < 2:
return None
request = build_sufficiency_request(query, evidence_texts)
try:
raw = self._generator.generate(request.system, request.user, request.schema)
except AnswerGenerationUnavailable:
return None
try:
payload = json.loads(raw)
except (ValueError, TypeError):
return None
if isinstance(payload, dict) and payload.get("sufficient") is False:
question = payload.get("clarifying_question")
if isinstance(question, str) and question.strip():
return question.strip()
return None
return answer
@staticmethod
def _cited_only(
+30 -6
View File
@@ -87,23 +87,47 @@ class ConversationState:
summary: str = ""
focus: Focus = field(default_factory=Focus)
turn_count: int = 0
# Turns evicted from `recent` since the last time `overflow()` was
# consumed and cleared (by the caller passing `pending_overflow=()` to
# `replace()` after folding them into the summary). NOT derivable from
# `recent` alone — `recent` is already capped at `window`, so comparing
# its length against `window` can never find anything (see the bug note
# on `overflow` below). Plumbing, not conversation content.
pending_overflow: tuple[Turn, ...] = ()
def append(self, turn: Turn, window: int = RECENT_TURNS) -> "ConversationState":
"""Adds a turn and evicts the oldest beyond the window.
Eviction returns the dropped turns to the caller's summariser via
`overflow`, rather than discarding them here — this type does not
decide what a summary says.
Eviction accumulates the dropped turns into `pending_overflow` for
the caller's summariser to fold via `overflow()`, rather than
discarding them here — this type does not decide what a summary
says. Accumulates rather than overwrites because one turn commonly
triggers two `append()` calls in a row (user, then assistant); each
can evict at most one turn, and the second call must not lose the
first's.
"""
recent = (*self.recent, turn)[-window:]
combined = (*self.recent, turn)
recent = combined[-window:]
dropped = combined[:-window] if len(combined) > window else ()
return replace(
self,
recent=recent,
turn_count=self.turn_count + 1,
pending_overflow=(*self.pending_overflow, *dropped),
)
def overflow(self, window: int = RECENT_TURNS) -> tuple[Turn, ...]:
return self.recent[:-window] if len(self.recent) > window else ()
def overflow(self) -> tuple[Turn, ...]:
"""Turns evicted from `recent` and not yet folded into the summary.
Bug fixed 2026-08-06 (Codex review, F-06): this used to check
`len(self.recent) > window`, but `recent` is already truncated to
`window` by every `append()` call, so that comparison could never be
true — dropped turns were silently discarded and the summariser
never received them, no matter how long a conversation ran. The
caller must clear `pending_overflow` (pass `pending_overflow=()` to
`replace()`) after folding, or the same turns fold again next time.
"""
return self.pending_overflow
def inherited(self, name: str):
"""A focus value only if it is still fresh; otherwise None."""
+27 -2
View File
@@ -343,10 +343,31 @@ class ConversationalLoopService:
query, subject_scope, intent, drug_id=resolved.drug_id
)
# The engine asked for a missing detail (age/weight/renal function/
# indication…) rather than dumping every dose band — surface it as a
# clarification, not a final answer.
if grounded is not None and grounded.clarification is not None:
self._metrics.increment(
metric_names.CLARIFY_ASKED, reason="needs_more_info"
)
self._persist(state, resolved, None)
return ConversationTurnResult(
None,
Clarification(
reason="needs_more_info",
question=grounded.clarification,
options=(),
),
None,
False,
None,
"needs_more_info",
)
answer = grounded.answer if grounded else None
inherited = resolved.drug_id if resolved.needs_carry_over_notice else None
if answer is not None and inherited is not None:
answer = f"Về {inherited}: {answer}"
answer = f"Về {self._drug_name(inherited)}: {answer}"
if grounded is not None:
grounded = replace(grounded, answer=answer)
@@ -395,5 +416,9 @@ class ConversationalLoopService:
and state.overflow()
):
summary = self._summariser.fold(state.summary, state.overflow())
state = replace(state, summary=summary)
# Clear what was just folded — `replace()` keeps every field not
# named here, and `pending_overflow` accumulates across calls
# (see `ConversationState.append`), so leaving it would fold the
# same already-summarised turns again next time.
state = replace(state, summary=summary, pending_overflow=())
self._store.save(state)
+117 -23
View File
@@ -2,10 +2,25 @@
The answer layer may only rephrase retrieved text. This module is what makes
that a checkable property rather than a promise in a prompt: it recomputes,
from the evidence alone, whether every number and every citation in a
generated answer can be traced back to the source. A generation that fails is
from the evidence alone, whether every claim in a generated answer traces
back to the specific source block it cites. A generation that fails is
discarded, never shown.
Binding is per-citation, not global. The answer is split at each citation
marker group (one or more consecutive `[n]`); the text immediately before a
group is that group's claim, and only the evidence block(s) named in that
group may support it. A number that is true of evidence block 2 does not
make a claim citing `[1]` grounded — the old implementation pooled every
number from every evidence block into one set, which let a number attributed
to the wrong source pass silently. `evidence_texts` is positional: `[n]`
refers to `evidence_texts[n - 1]`.
A claim with no valid citation group is rejected outright — a citation
nobody can follow is not a citation, and an uncited clinical statement is not
verifiable, numeric or not. This catches a missing-citation defect that the
old check never looked for at all (it only ever checked numbers already
carrying a marker).
Numbers are compared **character for character**, deliberately. "7,5" and
"7.5" are not treated as equal, and no attempt is made to parse either into a
quantity. Parsing invites the one error that matters most here: `1.500` is
@@ -13,6 +28,13 @@ quantity. Parsing invites the one error that matters most here: `1.500` is
separators maps "7,5" and "75" to the same key — a tenfold dose error scored
as a match. The model is told to copy figures verbatim, so an exact match is
achievable, and every deviation from it is refused rather than interpreted.
What this module still cannot do: confirm that a citation-bearing nonnumeric
claim is actually *entailed* by the block it cites (e.g. "chữa ung thư [1]"
where evidence 1 is only about "điều trị đái tháo đường" — same drug name,
unrelated indication). Regex-level number/citation checking has no notion of
semantic content. That gap is closed separately by an LLM entailment pass
(`rag/answer.py`'s post-generation verifier call), not by this module.
"""
from __future__ import annotations
@@ -27,12 +49,23 @@ _NUMBER = re.compile(r"\d+(?:[.,]\d+)*")
# never mistaken for the quantity 2.
_CITATION = re.compile(r"\[(\d+)\]")
# One or more consecutive markers ("[1]", "[1][2]") count as a single group:
# the prompt allows citing more than one source for one claim, and each is
# checked against the union of just those sources, not all evidence.
_CITATION_GROUP = re.compile(r"(?:\[\d+\])+")
# Any word character — letter (Vietnamese diacritics included) or digit —
# used to tell "claim with actual content" apart from bare punctuation or
# whitespace trailing a citation, which needs no citation of its own.
_LETTER = re.compile(r"\w", re.UNICODE)
@dataclass(frozen=True)
class GroundingReport:
grounded: bool
unsupported_numbers: tuple[str, ...]
invalid_citations: tuple[int, ...]
uncited_claim: bool
cited_indices: tuple[int, ...]
@property
@@ -41,6 +74,8 @@ class GroundingReport:
return "ungrounded_number"
if self.invalid_citations:
return "invalid_citation"
if self.uncited_claim:
return "uncited_claim"
return "grounded"
@@ -53,31 +88,90 @@ def citations_in(text: str) -> tuple[int, ...]:
return tuple(int(marker) for marker in _CITATION.findall(text))
def verify(answer: str, evidence_texts: tuple[str, ...]) -> GroundingReport:
"""Whether `answer` states only figures and sources present in evidence.
def has_content(text: str) -> bool:
"""True once `text` carries any letter or digit — i.e. more than
punctuation or whitespace left over between/after citation markers."""
return _LETTER.search(text) is not None
`evidence_texts` is positional: citation `[n]` refers to
`evidence_texts[n - 1]`, so an out-of-range marker is a defect even when
the prose around it is faithful — a citation nobody can follow is not a
citation.
@dataclass(frozen=True)
class Claim:
"""One citation-bounded segment of an answer: the text before a citation
group, and the (in-range) evidence indices that group names.
`indices` is empty for the trailing segment after the last citation
group, or for a claim whose only marker(s) were out of range — in both
cases there is no evidence block left to check the claim against.
"""
source_numbers = set()
for text in evidence_texts:
source_numbers.update(numbers_in(text))
unsupported = tuple(
token for token in numbers_in(answer) if token not in source_numbers
text: str
indices: tuple[int, ...]
def split_claims(answer: str, evidence_count: int) -> tuple[Claim, ...]:
"""The claim segmentation `verify` checks numbers against, exposed so a
semantic entailment pass can run the same per-claim binding — each claim
checked only against the evidence block(s) it actually cites, never the
whole evidence set.
"""
claims: list[Claim] = []
cursor = 0
for group in _CITATION_GROUP.finditer(answer):
text = answer[cursor:group.start()]
cursor = group.end()
indices = tuple(
i for i in citations_in(group.group(0)) if 1 <= i <= evidence_count
)
invalid = tuple(
index
for index in citations_in(answer)
if not 1 <= index <= len(evidence_texts)
)
cited = tuple(sorted({index for index in citations_in(answer)} - set(invalid)))
claims.append(Claim(text, indices))
claims.append(Claim(answer[cursor:], ()))
return tuple(claims)
def verify(answer: str, evidence_texts: tuple[str, ...]) -> GroundingReport:
"""Whether `answer` states only figures and sources traceable to the
specific evidence block(s) cited immediately after each claim.
See module docstring for the binding rule and its known limit (no
semantic entailment check).
"""
unsupported: list[str] = []
invalid: list[int] = []
uncited = False
cited_all: set[int] = set()
cursor = 0
for group in _CITATION_GROUP.finditer(answer):
claim = answer[cursor:group.start()]
cursor = group.end()
indices = citations_in(group.group(0))
bad = [i for i in indices if not 1 <= i <= len(evidence_texts)]
good = [i for i in indices if i not in bad]
invalid.extend(bad)
cited_all.update(good)
claim_numbers = numbers_in(claim)
if good:
source_numbers: set[str] = set()
for index in good:
source_numbers.update(numbers_in(evidence_texts[index - 1]))
unsupported.extend(n for n in claim_numbers if n not in source_numbers)
else:
# Every marker in this group was out of range: nothing to bind
# the claim to, numeric or not.
unsupported.extend(claim_numbers)
if has_content(claim):
uncited = True
tail = answer[cursor:]
unsupported.extend(numbers_in(tail))
if has_content(tail):
uncited = True
return GroundingReport(
grounded=not unsupported and not invalid,
unsupported_numbers=unsupported,
invalid_citations=invalid,
cited_indices=cited,
grounded=not unsupported and not invalid and not uncited,
unsupported_numbers=tuple(unsupported),
invalid_citations=tuple(invalid),
uncited_claim=uncited,
cited_indices=tuple(sorted(cited_all)),
)
+62
View File
@@ -0,0 +1,62 @@
"""F-05: refuse to become ready on a corpus/model manifest mismatch.
Two different embedding models can produce vectors of the same
dimensionality; Qdrant returns plausible-looking but meaningless nearest
neighbours with no error at query time — a stale or wrong collection is
otherwise invisible until a clinician notices the answers are subtly off.
The ingestion loader already writes a sidecar manifest recording what a
collection was built from (`ingestion/ingestion/load/manifest.py`); this is
the query-time half — compare it against the configured query embedder
*before* serving anything, not after a bad answer is reported.
"""
from __future__ import annotations
MANIFEST_POINT_ID = "00000000-0000-5000-8000-000000000001"
class ManifestMismatch(RuntimeError):
"""The configured query embedder does not match what the collection was
built from. Raised at startup so the service refuses to become ready
rather than search with mismatched vectors."""
def manifest_collection(name: str) -> str:
return f"{name}__manifest"
def check_manifest(
payload: dict | None,
collection: str,
expected_model_id: str,
expected_dimensions: int,
) -> None:
"""Raises `ManifestMismatch` unless `payload` (the manifest sidecar
point's payload, or `None` if the sidecar/point is missing entirely)
matches the configured query embedder.
A collection with no manifest at all is refused for the same reason a
mismatched one is: nothing can be said about what it was built from, and
"probably fine" is not a load-bearing claim for a medical formulary.
"""
if payload is None:
raise ManifestMismatch(
f"{collection!r} has no corpus manifest "
f"({manifest_collection(collection)!r}) — refusing to query an "
"unattested corpus."
)
mismatches = []
if payload.get("model_id") != expected_model_id:
mismatches.append(
f"model_id: corpus={payload.get('model_id')!r} "
f"query_embedder={expected_model_id!r}"
)
if payload.get("dimensions") != expected_dimensions:
mismatches.append(
f"dimensions: corpus={payload.get('dimensions')!r} "
f"query_embedder={expected_dimensions!r}"
)
if mismatches:
raise ManifestMismatch(
f"{collection!r}'s corpus manifest does not match the configured "
"query embedder — " + "; ".join(mismatches)
)
+6
View File
@@ -54,3 +54,9 @@ LOOP_ROUNDS = "duocthu_loop_retrieval_rounds_total"
LOOP_REFINED = "duocthu_loop_refined_total"
LOOP_REPAIRED = "duocthu_loop_repaired_total"
FOLLOWUP_INHERITED = "duocthu_followup_inherited_total"
# Trace persistence is fail-open (F-09): a Postgres outage must not turn an
# already-computed, safe answer into a 500. This counts how often that
# degradation actually happens, since a silent fail-open with no counter is
# indistinguishable from tracing quietly working.
TRACE_WRITE_FAILED = "duocthu_trace_write_failed_total"
+71
View File
@@ -0,0 +1,71 @@
"""Server-derived subject scope.
`routing.py`'s `_scope_gate` abstains outright on `NON_HUMAN` scope. Before
this module, that value came straight from the request body — a caller could
send `{"subject_scope":"human",...}` regardless of the query text, and the
shipped web BFF did exactly that, hard-coded on every request without reading
the message at all (Codex's 2026-08-06 review, F-02). A scope decision must
not be something the caller gets to assert.
`resolve_subject_scope` derives it from the query text and combines that with
whatever the caller claimed by taking the more conservative of the two: a
caller can *narrow* scope (claim `non_human` and have it stick) but can never
*widen* it — a claim of `human` cannot override a server-detected veterinary
turn. This is a corpus-coverage check, not a restriction on what a doctor or
pharmacist is allowed to ask: the book behind this product covers human
drug monographs only, so a query about dosing a dog is out of scope
regardless of who is asking. It has nothing to do with, and must never be
extended into, gatekeeping what kind of *clinical* question a professional
user is allowed to ask (see `[[feedback_no_recommendation_gate]]`/progress
log 2026-08-06 for the removed `QueryIntent.RECOMMENDATION` keyword
detector — this product is for doctors and pharmacists, not lay users, and a
"nên dùng thuốc gì" question from a clinician is exactly what a formulary
reference is for, not something to abstain on).
Deliberately not an LLM call: this is the gate every request passes through,
so it must be cheap, available during a provider outage, and auditable as a
fixed rule instead of a model judgment. It is a keyword heuristic, which
means it has real blind spots (an unusual phrasing can still slip past) — the
same trade-off `rag/agent.py`'s `_looks_non_human` backstop already accepts.
`rag/agent.py` should consolidate onto this module once the new orchestrator
is wired (F-03), instead of keeping a second, narrower keyword list.
"""
from __future__ import annotations
from .models import SubjectScope
from .text import normalize_name
# Phrases that name a non-human recipient. Matched on normalized text (casefold,
# diacritics stripped) so "chó", "CHÓ", "cho chó" all match one entry.
_NON_HUMAN_PHRASES = (
"cho cho", # "cho chó" — normalize_name collapses "chó" -> "cho"
"cho meo",
"cho ga",
"cho vit",
"cho lon",
"cho heo",
"cho bo",
"cho ngua",
"cho de",
"cho cuu",
"thu y",
"vat nuoi",
"gia suc",
"gia cam",
"dong vat",
)
def looks_non_human(query: str) -> bool:
normalized = normalize_name(query)
return any(phrase in normalized for phrase in _NON_HUMAN_PHRASES)
def resolve_subject_scope(query: str, claimed: SubjectScope) -> SubjectScope:
"""The scope that actually gates retrieval: the more conservative of what
the caller claimed and what the query text itself indicates."""
if claimed == SubjectScope.NON_HUMAN or looks_non_human(query):
return SubjectScope.NON_HUMAN
if claimed == SubjectScope.UNKNOWN:
return SubjectScope.UNKNOWN
return SubjectScope.HUMAN
+112
View File
@@ -36,6 +36,19 @@ Quy tắc bắt buộc:
trả lời hợp lệ. Không suy diễn để lấp chỗ trống.
6. Giữ nguyên thuật ngữ chuyên môn của sách. Không diễn giải cho người
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.
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`
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.
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.
Viết gọn. Trả lời đúng điều được hỏi, không mở rộng phạm vi."""
@@ -54,12 +67,59 @@ ANSWER_SCHEMA = {
"type": "boolean",
"description": "false nếu bằng chứng không đủ để trả lời câu hỏi.",
},
"clarifying_question": {
"type": ["string", "null"],
"description": (
"Câu hỏi lại khi người dùng chưa nêu đủ dữ kiện (vd tuổi/cân nặng "
"cho câu hỏi liều có nhiều mức). null nếu đã đủ dữ kiện để trả lời."
),
},
},
"required": ["answer", "evidence_sufficient"],
"additionalProperties": False,
}
SUFFICIENCY_SYSTEM = """\
Bạn là bộ KIỂM TRA ĐỦ DỮ KIỆN cho tra cứu Dược thư, chạy TRƯỚC khi trả lời.
Cho CÂU HỎI của người dùng và BẰNG CHỨNG, xác định câu hỏi đã đủ dữ kiện để đưa
ra ĐÚNG MỘT câu trả lời cụ thể hay chưa.
Quy tắc:
- Nếu là câu hỏi về LIỀU/CÁCH DÙNG và BẰNG CHỨNG có NHIỀU mức khác nhau 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à 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.
- 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 ĐỦ.
Trả về DUY NHẤT JSON: {"sufficient": bool, "clarifying_question": string|null}.
Nếu CHƯA đủ: sufficient=false và clarifying_question hỏi NGẮN GỌN tất cả dữ kiện
còn thiếu (vd: "Bé mấy tuổi, cân nặng bao nhiêu kg, dùng đường nào và để hạ sốt
hay giảm đau?"). Nếu đủ: sufficient=true, clarifying_question=null."""
SUFFICIENCY_SCHEMA = {
"type": "object",
"properties": {
"sufficient": {"type": "boolean"},
"clarifying_question": {"type": ["string", "null"]},
},
"required": ["sufficient", "clarifying_question"],
"additionalProperties": False,
}
def build_sufficiency_request(
question: str, evidence_texts: tuple[str, ...]
) -> "GenerationRequest":
blocks = "\n\n".join(
f"[{index}] {text}" for index, text in enumerate(evidence_texts, start=1)
)
user = f"BẰNG CHỨNG:\n\n{blocks}\n\nCÂU HỎI: {question}"
return GenerationRequest(system=SUFFICIENCY_SYSTEM, user=user, schema=SUFFICIENCY_SCHEMA)
@dataclass(frozen=True)
class GenerationRequest:
system: str
@@ -67,6 +127,58 @@ class GenerationRequest:
schema: dict
ENTAILMENT_SYSTEM = """\
Bạn là bộ KIỂM TRA ĐỘ CHÍNH XÁC, chạy SAU khi một câu trả lời đã được sinh ra.
Với mỗi CÂU dưới đây, so sánh nó với ĐÚNG đoạn BẰNG CHỨNG ĐƯỢC TRÍCH đi kèm câu
đó (câu đã được gắn số nguồn [n] trỏ tới đúng đoạn này). Việc DUY NHẤT cần làm:
nội dung của CÂU có được đoạn BẰNG CHỨNG ĐƯỢC TRÍCH đó — và CHỈ đoạn đó — nói
tới hay không. KHÔNG dùng kiến thức y khoa của bạn, KHÔNG suy luận thêm, KHÔNG
tự hỏi liệu câu đó có hợp lý về mặt y khoa hay không.
Một CÂU là KHÔNG được chứng thực nếu nó nêu chỉ định, chống chỉ định, cơ chế,
tương tác, đối tượng áp dụng, hoặc bất kỳ quan hệ nào mà đoạn BẰNG CHỨNG ĐƯỢC
TRÍCH của nó KHÔNG nói tới — kể cả khi câu đó đúng về mặt y khoa, và kể cả khi
đúng tên thuốc nhưng sai ý (ví dụ bằng chứng nói "điều trị đái tháo đường"
câu nói "chữa ung thư").
LƯU Ý QUAN TRỌNG: bằng chứng trong lĩnh vực dược thường liệt kê nhiều tên
thuốc trong một câu/danh sách dài, phân cách bởi dấu phẩy (ví dụ: "Tác dụng
của warfarin có thể tăng lên khi dùng với: acetaminophen, allopurinol, ...,
aspirin, kháng sinh, ..."). Hãy ĐỌC KỸ TOÀN BỘ danh sách trước khi kết luận —
nếu tên thuốc trong CÂU xuất hiện ở bất kỳ đâu trong danh sách đó với đúng
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."""
ENTAILMENT_SCHEMA = {
"type": "object",
"properties": {
"entailed": {"type": "boolean"},
"unsupported": {"type": "array", "items": {"type": "integer"}},
},
"required": ["entailed", "unsupported"],
"additionalProperties": False,
}
def build_entailment_request(claims: list[tuple[str, 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)."""
if not claims:
raise ValueError("cannot build an entailment request with no claims")
blocks = "\n\n".join(
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ó."
return GenerationRequest(system=ENTAILMENT_SYSTEM, user=user, schema=ENTAILMENT_SCHEMA)
def build_request(
question: str, evidence_texts: tuple[str, ...], intro: bool = False
) -> GenerationRequest:
+53
View File
@@ -58,6 +58,59 @@ class RetrievalService:
self._section_resolver = section_resolver
self._reranker = reranker
def retrieve_framed(
self,
drug_id: str,
section_key: str | None,
query: str,
is_overview: bool = False,
) -> RetrievalResult:
"""Retrieve driven by an already-understood frame, not by parsing text.
The LLM understanding layer has resolved the drug (against the real
catalog) and, when the turn named one, the section. So this skips the
fuzzy `CatalogDrugResolver` and the keyword `SectionResolver` entirely:
a named `section_key` filters that section whole; without one, this
mirrors `retrieve()`'s two remaining cases — `is_overview` (the frame's
`turn_type == "drug_overview"`, a bare name) answers from the identity
sections only, and a free-form question reranks the full monograph
down to `rerank_top_k`.
Found live 2026-08-06: without the `is_overview` split, a bare drug
name sent the ENTIRE ~29-section monograph as evidence for every
generation call (retrieval had no notion of "just the intro"), which
is both wrong retrieval and, downstream, an answer so long it
intermittently failed generation/entailment outright. `query` is only
the rerank signal here, never a resolution input.
"""
if not drug_id.strip():
return RetrievalResult(EvidenceDecision.ABSTAIN, "missing_query_or_drug")
if section_key:
find_by_section = getattr(self._retriever, "find_by_section", None)
if find_by_section is not None:
hits = find_by_section(drug_id, section_key)
if hits:
return self._decide(self._hydrate(hits, limit=None))
overview_hits = self._drug_overview(drug_id)
if overview_hits is None:
return RetrievalResult(EvidenceDecision.ABSTAIN, "insufficient_retrieval_score")
if is_overview:
intro = [h for h in overview_hits if h.document.section_key in INTRO_SECTIONS]
return self._decide(
self._hydrate(intro or overview_hits, limit=None), is_drug_overview=True
)
overview_hits = self._rerank(query, overview_hits)
# Capped even when rerank is disabled/unavailable and fails open to
# the unfiltered list — an ordering aid must never remove the size
# bound too, or the same 29-section explosion returns through here.
return self._decide(
self._hydrate(overview_hits, limit=self._policy.evidence_limit)
)
def retrieve(self, query: str, drug_id: str) -> RetrievalResult:
if not query.strip() or not drug_id.strip():
return RetrievalResult(EvidenceDecision.ABSTAIN, "missing_query_or_drug")
+353
View File
@@ -0,0 +1,353 @@
"""LLM-driven query understanding — the front-end of the RAG chatbot.
The old drug-first path resolved a drug with a fuzzy `SequenceMatcher` and routed
sections with a Vietnamese phrase table. Both are brittle string heuristics: they
false-matched a made-up name (``aspirinol`` -> aspirin), tied on a correctly
spelled English INN (``amoxicillin``), and mistook a common word (``uống``) for a
drug. This module replaces that with the model the system already has.
Division of labour, and why it is safe for a formulary:
- The **LLM** does the messy human-language part — which token is a drug, is this
an interaction / a symptom lookup / a weight-based dose, what section is asked,
what population/weight. It is good at exactly the fuzziness the heuristics were
bad at.
- The **catalog** stays the authority on drug *identity*. The model may only pick
``drug_id`` values from a list *bounded before the model ever runs* — a
deterministic alias/fuzzy pass over the turn and history (`CandidateSource`)
decides which real drugs are even plausible candidates, and only those are
shown. This closes a gap the catalog-whitelist alone did not (F-04, Codex
2026-08-06 review): validating that an output id is *some* real drug_id does
not prove it is the *one the user's text actually named* — an LLM could
satisfy that whitelist while mapping an unrelated or invented name to any of
the other 683 real drugs. Bounding candidates first removes that degree of
freedom: the model can still read ``amoxicillin`` as ``amoxicilin`` (a fuzzy
match puts it in the candidate set) but cannot map ``aspirinol`` to aspirin,
because nothing about ``aspirinol`` fuzzy-matches anything and the candidate
set the model is shown is empty or contains unrelated drugs, not aspirin.
- Nothing here answers the medical question. It only produces a structured frame;
retrieval + ``grounding.verify`` remain the load-bearing safety layer downstream.
`rag/` imports no SDK: the LLM is injected as a ``JsonLlm`` protocol (satisfied by
`adapters.bedrock_converse.BedrockConverseAnswerGenerator`), and a deterministic
stub runs the whole path offline in tests.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Protocol, Sequence
# The 19 monograph section keys, kept here as the closed vocabulary the model may
# use for `attribute`. Adding a new section is one entry, not a code change.
SECTION_KEYS = (
"ten_chung_quoc_te",
"ten_thuong_mai",
"ma_atc",
"loai_thuoc",
"dang_thuoc_va_ham_luong",
"duoc_ly_va_co_che_tac_dung",
"chi_dinh",
"chong_chi_dinh",
"than_trong",
"thoi_ky_mang_thai",
"thoi_ky_cho_con_bu",
"tac_dung_khong_mong_muon",
"huong_dan_xu_tri_adr",
"lieu_luong_va_cach_dung",
"tuong_tac_thuoc",
"qua_lieu_va_xu_tri",
"do_on_dinh_va_bao_quan",
"tuong_ky",
"thong_tin_quy_che",
)
# Short glosses shown to the model alongside each key. Found live 2026-08-06
# (golden e2e set): a bare key list gives the model nothing to disambiguate
# "thận trọng" from "chống chỉ định" — 9/9 live calls for "X cần thận trọng
# gì?" picked chong_chi_dinh, silently answering from the wrong section
# (and, downstream, dropping the specific safety content the precautions
# section actually has, e.g. metformin's lactic acidosis warning). The two
# are genuinely adjacent concepts in Vietnamese medical text; a bare slug
# name is not enough to tell a model which one a question means.
SECTION_KEY_HINTS: dict[str, str] = {
"ten_chung_quoc_te": "tên chung quốc tế/INN",
"ten_thuong_mai": "tên thương mại/biệt dược",
"ma_atc": "mã ATC",
"loai_thuoc": "phân loại thuốc",
"dang_thuoc_va_ham_luong": "dạng bào chế và hàm lượng",
"duoc_ly_va_co_che_tac_dung": "dược lý, cơ chế tác dụng",
"chi_dinh": "chỉ định — bệnh/triệu chứng thuốc dùng để điều trị",
"chong_chi_dinh": (
"CHỐNG CHỈ ĐỊNH — trường hợp TUYỆT ĐỐI KHÔNG được dùng thuốc này"
),
"than_trong": (
"THẬN TRỌNG — KHÁC chống chỉ định: vẫn dùng được nhưng cần cảnh "
"giác/theo dõi/chỉnh liều (ví dụ nguy cơ nhiễm toan lactic của "
"metformin, độc tính thận/tai của gentamicin). Câu hỏi có chữ "
"\"thận trọng\", \"cẩn thận\", \"lưu ý gì\", \"cần chú ý\" → key này, "
"KHÔNG PHẢI chong_chi_dinh."
),
"thoi_ky_mang_thai": "dùng khi mang thai",
"thoi_ky_cho_con_bu": "dùng khi cho con bú",
"tac_dung_khong_mong_muon": "tác dụng phụ/ADR",
"huong_dan_xu_tri_adr": "cách xử trí khi gặp ADR",
"lieu_luong_va_cach_dung": "liều dùng và cách dùng",
"tuong_tac_thuoc": "tương tác với thuốc khác",
"qua_lieu_va_xu_tri": "quá liều và cách xử trí",
"do_on_dinh_va_bao_quan": "độ ổn định, bảo quản",
"tuong_ky": "tương kỵ (không pha/trộn được với gì)",
"thong_tin_quy_che": "thông tin quy chế/pháp lý",
}
# What kind of turn this is — the router branches on it. Deliberately explicit so a
# symptom lookup is never silently treated as a failed drug lookup, and a two-drug
# interaction never collapses to an "ambiguous drug" abstain.
TURN_TYPES = (
"drug_attribute", # one drug, one/more sections ("liều paracetamol")
"drug_overview", # a bare drug name, wants the monograph
"interaction", # 2+ drugs, asks about combining them
"symptom_to_drug", # a symptom/indication, wants candidate drugs
"dosing_calc", # a dose that needs weight/age arithmetic
"smalltalk", # greeting / meta, not a medical query
"out_of_scope", # not answerable from the Part-2 monographs
)
@dataclass(frozen=True)
class QueryFrame:
"""The structured reading of one user turn. No medical content, only intent."""
turn_type: str
drugs: tuple[str, ...] = () # canonical drug_ids, from the catalog only
unknown_drugs: tuple[str, ...] = () # mentioned, not in the catalog
attribute: str | None = None # a SECTION_KEYS value, or None
population: str | None = None # e.g. "tre_em", "nguoi_lon", "suy_than"
weight_kg: float | None = None
age_text: str | None = None
indication: str | None = None # symptom/disease, for symptom_to_drug
needs_clarify: bool = False
clarify_reason: str | None = None
raw: dict = field(default_factory=dict, compare=False)
# The JSON contract the model must fill. Stated in the prompt (Converse has no
# server-side schema) and validated on the way back.
FRAME_SCHEMA = {
"turn_type": "one of: " + " | ".join(TURN_TYPES),
"drugs": ["drug_id exactly as it appears in the provided catalog list"],
"unknown_drugs": ["a drug name the user mentioned that is NOT in the catalog"],
"attribute": "one of the section keys provided, or null",
"population": "tre_em | tre_so_sinh | nguoi_lon | nguoi_cao_tuoi | phu_nu_co_thai | phu_nu_cho_con_bu | suy_than | suy_gan | null",
"weight_kg": (
"number if a body weight is given, else null. Vietnamese casual speech "
"states weight as a bare number of 'cân' or '' with no unit word "
"('bé 30 cân', 'nặng 30 ký') — both mean kilograms; read the number as "
"weight_kg the same as if 'kg' had been written."
),
"age_text": "the age exactly as stated (e.g. '3 tuổi', '5 tháng'), else null",
"indication": "the symptom or disease if turn_type is symptom_to_drug, else null",
"needs_clarify": "true only if the turn cannot be acted on without more info",
"clarify_reason": "short Vietnamese question to ask, or null",
}
_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.
Quy tắc bắt buộc:
- Trường "drugs" CHỈ được chứa các drug_id có trong DANH SÁCH THUỐC được cung cấp.
Nếu người dùng nhắc một thuốc KHÔNG có trong danh sách (kể cả tên bịa như
"aspirinol"), đưa tên đó vào "unknown_drugs", KHÔNG được gán sang thuốc gần giống.
- Sai chính tả một thuốc CÓ trong danh sách thì sửa về đúng drug_id của nó
(ví dụ "amoxicillin" -> "amoxicilin", "metfomin" -> "metformin").
- Nếu câu nhắc 2 thuốc trở lên và hỏi về dùng chung/tương tác -> turn_type="interaction".
- Nếu là triệu chứng/bệnh cần gợi ý thuốc (không nêu tên thuốc) -> "symptom_to_drug",
điền "indication".
- 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"/""
không kèm đơn vị khác) NGHĨA LÀ 30 kg -> điền weight_kg=30, không bỏ trống.
- 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".
- Chào hỏi/vu vơ -> "smalltalk". Ngoài phạm vi chuyên luận thuốc -> "out_of_scope"."""
class JsonLlm(Protocol):
"""A model that returns a single JSON object as text. Satisfied by the
existing Bedrock Converse generator, so this adds no SDK to `rag/`."""
def generate(self, system: str, user: str, schema: dict) -> str: ...
class QueryUnderstander(Protocol):
def understand(
self, turn: str, history: Sequence[str] = ()
) -> QueryFrame: ...
class CandidateSource(Protocol):
"""Deterministic, no-LLM drug-name matching — what bounds the model's
choice before it ever runs (F-04). Satisfied by `routing.CatalogDrugResolver`;
kept as a protocol (not an import of it) so this module stays decoupled
from the fuzzy-matching implementation, only its shape.
"""
def resolve(self, query: str): ...
def suggest(
self, query: str, k: int = 3, min_score: float = 0.5
) -> list[tuple[str, float]]: ...
class LlmQueryUnderstander:
"""Turns a raw user turn into a `QueryFrame` with one LLM call.
`catalog` maps drug_id -> a human name (used only to label whichever
candidates get shown). `resolver` is what actually decides which real
drugs are plausible for this turn, deterministically, before the model
runs at all: every drug_id an exact-alias or fuzzy match finds anywhere
in the turn or the raw history text. The model then picks only among
those — never the full ~684-drug catalog — so it structurally cannot
map an invented or unrelated name to some other real drug_id it merely
happens to also list correctly (F-04). This also directly answers a
separate 2026-08-06 review finding: sending the full catalog on every
turn is unbounded token cost; a per-turn candidate shortlist is both
safer and cheaper.
"""
def __init__(self, llm: JsonLlm, catalog: dict[str, str], resolver: CandidateSource) -> None:
self._llm = llm
self._catalog = catalog
self._resolver = resolver
def _candidate_ids(self, turn: str, history: Sequence[str]) -> set[str]:
"""Every drug_id a deterministic pass finds plausible in the turn or
the raw history text. Deliberately generous — an exact alias match
plus a fuzzy `suggest` well below the resolver's own auto-answer
threshold — because the job here is only to rule out drugs nothing
in the conversation plausibly refers to, not to pick the right one;
that disambiguation is still the model's job, within this bound.
"""
ids: set[str] = set()
for line in (turn, *history):
if not line.strip():
continue
resolution = self._resolver.resolve(line)
if resolution.status == "resolved" and resolution.drug_id:
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)
return ids
def understand(self, turn: str, history: Sequence[str] = ()) -> QueryFrame:
shown = {
drug_id: self._catalog[drug_id]
for drug_id in self._candidate_ids(turn, history)
if drug_id in self._catalog
}
catalog_block = (
"\n".join(f"{drug_id}\t{name}" for drug_id, name in sorted(shown.items()))
if shown
else "(không có thuốc nào trong Dược thư khớp với lượt này hoặc lịch sử gần đây)"
)
history_block = (
"LỊCH SỬ HỘI THOẠI (cũ -> mới):\n" + "\n".join(history)
if history else "LỊCH SỬ HỘI THOẠI: (chưa có)"
)
user = (
f"DANH SÁCH THUỐC ỨNG VIÊN cho lượt này (drug_id\\ttên) — CHỈ được chọn "
f"drug_id từ đây, đây KHÔNG phải toàn bộ Dược thư, chỉ là các thuốc khớp "
f"với chữ trong lượt/lịch sử:\n"
f"{catalog_block}\n\n"
"CÁC SECTION KEY hợp lệ cho 'attribute' (key: ý nghĩa):\n"
+ "\n".join(f"{key}: {SECTION_KEY_HINTS[key]}" for key in SECTION_KEYS)
+ "\n\n"
f"{history_block}\n\n"
f"CÂU HỎI HIỆN TẠI: {turn}"
)
raw_text = self._llm.generate(_SYSTEM, user, FRAME_SCHEMA)
return self._parse(raw_text, shown)
@staticmethod
def _resolve_id(value: str, shown: dict[str, str]) -> str | None:
if value in shown:
return value
# `drug_id` is shown with underscores ("paracetamol_acetaminophen")
# but its own canonical display name (bootstrap's `_catalog_names`)
# is the same string with spaces — found live 2026-08-06: the two
# look near-identical in the "drug_id\tname" table, and the model
# sometimes echoes the spaced display form instead of the id. This
# is a deterministic, lossless formatting difference (not a fuzzy
# match — one specific known substitution), so it's tolerated here
# rather than dropping a correctly-identified drug to unknown.
spaced = value.strip().casefold()
for drug_id in shown:
if drug_id.replace("_", " ").casefold() == spaced:
return drug_id
return None
def _parse(self, raw_text: str, shown: dict[str, str]) -> QueryFrame:
try:
data = json.loads(raw_text)
except (json.JSONDecodeError, TypeError):
# Fail closed to a clarify rather than to a wrong reading.
return QueryFrame(
turn_type="out_of_scope",
needs_clarify=True,
clarify_reason="Xin lỗi, tôi chưa hiểu rõ câu hỏi. Anh/chị hỏi lại giúp nhé?",
)
resolved = [
(d, self._resolve_id(d, shown)) for d in _as_list(data.get("drugs"))
]
drugs = tuple(dict.fromkeys(rid for _, rid in resolved if rid is not None))
# A drug the model named but that resolves to no id among the shown
# candidates (exact or underscore/space form) is unknown, not a
# silent drop and not a fuzzy substitution to an unrelated drug.
unknown = tuple(
d for d, rid in resolved if rid is None
) + tuple(_as_list(data.get("unknown_drugs")))
attribute = data.get("attribute")
if attribute not in SECTION_KEYS:
attribute = None
turn_type = data.get("turn_type")
if turn_type not in TURN_TYPES:
turn_type = "drug_attribute" if drugs else "out_of_scope"
return QueryFrame(
turn_type=turn_type,
drugs=drugs,
unknown_drugs=tuple(dict.fromkeys(unknown)),
attribute=attribute,
population=_clean_str(data.get("population")),
weight_kg=_clean_float(data.get("weight_kg")),
age_text=_clean_str(data.get("age_text")),
indication=_clean_str(data.get("indication")),
needs_clarify=bool(data.get("needs_clarify")),
clarify_reason=_clean_str(data.get("clarify_reason")),
raw=data if isinstance(data, dict) else {},
)
def _as_list(value) -> list[str]:
if isinstance(value, str):
return [value] if value.strip() else []
if isinstance(value, list):
return [str(v).strip() for v in value if str(v).strip()]
return []
def _clean_str(value) -> str | None:
if isinstance(value, str) and value.strip() and value.strip().lower() != "null":
return value.strip()
return None
def _clean_float(value) -> float | None:
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value.replace(",", ".").split()[0])
except (ValueError, IndexError):
return None
return None
+61 -29
View File
@@ -1,12 +1,15 @@
from __future__ import annotations
import uuid
from typing import Annotated, Any, Protocol
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from rag.answer import GroundedAnswerService
from rag.metrics import TRACE_WRITE_FAILED, Metrics, NullMetrics
from rag.models import QueryIntent, SubjectScope
from rag.policy import resolve_subject_scope
class TraceWriter(Protocol):
@@ -57,6 +60,10 @@ def _trace_writer(request: Request) -> TraceWriter:
return writer
def _metrics(request: Request) -> Metrics:
return getattr(request.app.state, "metrics", None) or NullMetrics()
router = APIRouter(prefix="/v1/rag", tags=["rag"])
@@ -67,10 +74,10 @@ class SuggestResponse(BaseModel):
@router.get("/suggest", response_model=SuggestResponse)
def suggest_drugs(q: str, request: Request) -> SuggestResponse:
"""As-you-type drug-name autocomplete, so a name is picked, not mistyped."""
conversational = getattr(request.app.state, "conversational", None)
if conversational is None or not q.strip():
agent = getattr(request.app.state, "conversational", None)
if agent is None or not q.strip():
return SuggestResponse(suggestions=[])
return SuggestResponse(suggestions=conversational.complete(q.strip()))
return SuggestResponse(suggestions=agent.complete(q.strip()))
def _map_citations(items) -> list[CitationResponse]:
@@ -95,47 +102,72 @@ def query_rag(
request: Request,
answers: Annotated[GroundedAnswerService, Depends(_answer_service)],
traces: Annotated[TraceWriter, Depends(_trace_writer)],
metrics: Annotated[Metrics, Depends(_metrics)],
) -> RagQueryResponse:
conversational = getattr(request.app.state, "conversational", None)
agent = getattr(request.app.state, "conversational", None)
# Single-turn path (no conversation id, or conversational layer disabled):
# unchanged behaviour so existing callers keep working.
if payload.conversation_id is None or conversational is None:
grounded = answers.answer(payload.query, payload.subject_scope, payload.intent)
# `payload.subject_scope`/`payload.intent` are what the CALLER claims —
# logged below for audit, but the RagAgent path does not take them as an
# input at all. It derives scope from the query text itself (the same
# `rag.policy` check used here for the no-agent fallback) as part of its
# own LLM understanding call, and does not gate on intent at all (this
# product is for doctors/pharmacists; a client label must not be, and
# here structurally cannot be, the safety decision — F-02).
subject_scope = resolve_subject_scope(payload.query, payload.subject_scope)
intent = payload.intent
if agent is not None:
# The live path (F-03): one LLM call understands the turn (drug
# identity against the real catalog, turn type, population/weight),
# then routes to the safety-verified retrieval + grounded-answer
# engine. Replaces the old fuzzy resolver + keyword section router +
# manual follow-up inheritance for both single- and multi-turn.
reply = agent.handle(payload.query, payload.conversation_id)
decision = reply.decision
reason = reply.reason
answer = reply.clarification if reply.clarification is not None else reply.answer
resolved_drug_id = ", ".join(reply.drugs) if reply.drugs else None
citations = _map_citations(reply.citations)
else:
# No generator configured (ANSWER_PROVIDER=disabled): there is no LLM
# to understand a turn with, so this is retrieval-only, single-turn,
# unchanged from before F-03.
grounded = answers.answer(payload.query, subject_scope, intent)
if grounded.clarification is not None:
decision, reason = "clarify", "needs_more_info"
answer = grounded.clarification
resolved_drug_id = grounded.result.resolved_drug_id
citations = []
else:
decision = grounded.result.decision.value
reason = grounded.result.reason
answer = grounded.answer
resolved_drug_id = grounded.result.resolved_drug_id
citations = _map_citations(grounded.citations)
else:
turn = conversational.answer(
payload.conversation_id,
payload.query,
payload.subject_scope,
payload.intent,
)
if turn.clarification is not None:
decision, reason = "clarify", turn.clarification.reason
answer, resolved_drug_id, citations = turn.clarification.question, None, []
elif turn.grounded is not None:
decision = turn.grounded.result.decision.value
reason = turn.grounded.result.reason
answer = turn.answer
resolved_drug_id = turn.grounded.result.resolved_drug_id
citations = _map_citations(turn.grounded.citations)
else: # smalltalk
decision, reason = "answerable", turn.reason
answer, resolved_drug_id, citations = turn.answer, None, []
# Trace persistence is fail-open (F-09): an already-computed, safe answer
# must reach the caller even if Postgres is unreachable. `save()` opens a
# fresh connection per call with no retry, so a DB outage previously
# turned a good answer into a 500 for a reason that has nothing to do
# with whether the answer was safe. `trace_id` degrades to a local,
# unpersisted uuid — still a valid response field, just not one `GET
# /v1/rag/trace/{id}` (if it existed) could later look up.
try:
trace_id = traces.save(
query=payload.query,
subject_scope=payload.subject_scope.value,
intent=payload.intent.value,
# The resolved (server-derived) values, not the caller's claim —
# this is what actually gated the answer, so it's what the trace
# must show.
subject_scope=subject_scope.value,
intent=intent.value,
decision=decision,
reason=reason,
resolved_drug_id=resolved_drug_id,
citations=tuple(item.model_dump() for item in citations),
)
except Exception:
metrics.increment(TRACE_WRITE_FAILED)
trace_id = str(uuid.uuid4())
return RagQueryResponse(
trace_id=trace_id,
decision=decision,
+212
View File
@@ -0,0 +1,212 @@
"""`rag/agent.py` — the new orchestrator wired live in F-03.
Codex's 2026-08-06 review (F-03/F-10) flagged that this module had zero test
coverage despite being built to replace the live front end. This is the
first coverage: routing branches with fakes, not an exhaustive golden set
(that is F-10's job — a production-path regression suite against real
Qdrant/Bedrock fixtures).
"""
from __future__ import annotations
from rag.agent import RagAgent
from rag.answer import GroundedAnswerService
from rag.models import Evidence, EvidenceDecision, RetrievalResult, SourceRef
from rag.understanding import QueryFrame
SOURCE = SourceRef(physical_page=100, precision="region", printed_page=100)
def _evidence(text: str) -> Evidence:
return Evidence(
evidence_id="e0", matched_doc_id="e0", kind="prose", text=text, score=1.0,
source_refs=(SOURCE,), hydrated_from_parent=False, requires_visual_check=False,
)
class _FixedUnderstander:
def __init__(self, frame: QueryFrame) -> None:
self._frame = frame
def understand(self, turn, history=()):
return self._frame
class _FixedRetrieval:
"""Stands in for `RetrievalService.retrieve_framed` — a canned result per
drug_id regardless of section/query, so these tests assert routing, not
retrieval (that's `test_retrieval_service.py`'s job)."""
def __init__(self, results: dict[str, RetrievalResult]) -> None:
self._results = results
def retrieve_framed(self, drug_id, section_key, query, is_overview=False):
return self._results.get(
drug_id, RetrievalResult(EvidenceDecision.ABSTAIN, "not_configured")
)
def _agent(frame: QueryFrame, results: dict[str, RetrievalResult] | None = None) -> RagAgent:
# `routing=None`: `answer_from_result` (the only method this path calls)
# never touches it — see `rag/answer.py`.
answers = GroundedAnswerService(routing=None)
return RagAgent(_FixedUnderstander(frame), _FixedRetrieval(results or {}), answers)
def test_smalltalk_does_not_touch_retrieval():
agent = _agent(QueryFrame(turn_type="smalltalk"))
reply = agent.handle("chào bạn")
assert reply.decision == "answerable"
assert reply.turn_type == "smalltalk"
assert "Dược thư" in reply.answer
def test_out_of_scope_turn_type_abstains():
agent = _agent(QueryFrame(turn_type="out_of_scope"))
reply = agent.handle("cách tiêm truyền tĩnh mạch")
assert reply.decision == "abstain"
assert reply.reason == "out_of_scope"
def test_veterinary_phrase_abstains_even_if_the_model_missed_it():
# The model returned an ordinary-looking frame; the keyword backstop
# (`rag.policy.looks_non_human`, the same one F-02 wired server-side)
# still catches it before any retrieval happens.
agent = _agent(QueryFrame(turn_type="drug_attribute", drugs=("metformin",)))
reply = agent.handle("liều metformin cho chó bao nhiêu")
assert reply.decision == "abstain"
assert reply.reason == "out_of_scope"
def test_unknown_drug_name_is_reported_not_substituted():
agent = _agent(QueryFrame(turn_type="drug_attribute", unknown_drugs=("aspirinol",)))
reply = agent.handle("liều aspirinol")
assert reply.decision == "abstain"
assert reply.reason == "drug_not_in_formulary"
assert "aspirinol" in reply.answer
def test_no_drug_named_asks_which_one():
agent = _agent(QueryFrame(turn_type="drug_attribute"))
reply = agent.handle("liều dùng bao nhiêu")
assert reply.decision == "clarify"
assert reply.reason == "no_drug"
def test_needs_clarify_frame_is_surfaced_directly():
agent = _agent(QueryFrame(
turn_type="dosing_calc", drugs=("paracetamol",),
needs_clarify=True, clarify_reason="Bé mấy tuổi, cân nặng bao nhiêu kg?",
))
reply = agent.handle("liều paracetamol cho trẻ em")
assert reply.decision == "clarify"
assert reply.clarification == "Bé mấy tuổi, cân nặng bao nhiêu kg?"
def test_single_drug_attribute_retrieves_and_answers():
result = RetrievalResult(
EvidenceDecision.ANSWERABLE, "grounded_evidence_available",
(_evidence("Liều 500 mg mỗi ngày."),), resolved_drug_id="metformin",
)
agent = _agent(
QueryFrame(turn_type="drug_attribute", drugs=("metformin",),
attribute="lieu_luong_va_cach_dung"),
{"metformin": result},
)
reply = agent.handle("liều metformin")
assert reply.decision == "answerable"
assert reply.drugs == ("metformin",)
assert "500 mg" in reply.answer
def test_interaction_combines_both_drugs_evidence():
warfarin = RetrievalResult(
EvidenceDecision.ANSWERABLE, "grounded_evidence_available",
(_evidence("Tương tác với aspirin làm tăng nguy cơ chảy máu."),),
)
aspirin = RetrievalResult(
EvidenceDecision.ANSWERABLE, "grounded_evidence_available",
(_evidence("Tương tác với warfarin làm tăng nguy cơ chảy máu."),),
)
agent = _agent(
QueryFrame(turn_type="interaction", drugs=("warfarin", "aspirin")),
{"warfarin": warfarin, "aspirin": aspirin},
)
reply = agent.handle("warfarin với aspirin có dùng chung được không")
assert reply.decision == "answerable"
assert reply.turn_type == "interaction"
assert "chảy máu" in reply.answer
def test_interaction_with_no_evidence_abstains_and_does_not_imply_safety():
agent = _agent(
QueryFrame(turn_type="interaction", drugs=("drug_a", "drug_b")), {},
)
reply = agent.handle("drug_a và drug_b dùng chung được không")
assert reply.decision == "abstain"
assert reply.reason == "no_interaction_evidence"
assert "KHÔNG có nghĩa là an toàn" in reply.answer
def test_symptom_to_drug_without_a_drug_name_asks_honestly_not_wired_yet():
agent = _agent(QueryFrame(turn_type="symptom_to_drug", indication="sốt cao"))
reply = agent.handle("sốt cao uống thuốc gì")
assert reply.decision == "clarify"
assert reply.reason == "reverse_lookup_not_ready"
def test_history_is_passed_to_the_understander_on_the_next_turn():
received_history: list[tuple[str, ...]] = []
class _RecordingUnderstander:
def understand(self, turn, history=()):
received_history.append(tuple(history))
return QueryFrame(turn_type="smalltalk")
answers = GroundedAnswerService(routing=None)
agent = RagAgent(_RecordingUnderstander(), _FixedRetrieval({}), answers)
agent.handle("chào bạn", conversation_id="c1")
agent.handle("còn liều thì sao?", conversation_id="c1")
assert received_history[0] == ()
assert any("chào bạn" in line for line in received_history[1])
def test_history_is_isolated_per_conversation_id():
received_history: list[tuple[str, ...]] = []
class _RecordingUnderstander:
def understand(self, turn, history=()):
received_history.append(tuple(history))
return QueryFrame(turn_type="smalltalk")
answers = GroundedAnswerService(routing=None)
agent = RagAgent(_RecordingUnderstander(), _FixedRetrieval({}), answers)
agent.handle("xin chào", conversation_id="a")
agent.handle("liều dùng bao nhiêu", conversation_id="b")
# The second call, on a different conversation id, must not see "a"'s turn.
assert received_history[1] == ()
def test_autocomplete_delegates_to_the_configured_source():
class _Source:
def complete(self, prefix, k):
return ["metformin_id", "metoprolol_id"]
answers = GroundedAnswerService(routing=None)
agent = RagAgent(
_FixedUnderstander(QueryFrame(turn_type="smalltalk")),
_FixedRetrieval({}), answers, autocomplete=_Source(),
)
assert agent.complete("met") == ["Metformin Id", "Metoprolol Id"]
def test_autocomplete_with_no_source_configured_returns_empty():
answers = GroundedAnswerService(routing=None)
agent = RagAgent(
_FixedUnderstander(QueryFrame(turn_type="smalltalk")),
_FixedRetrieval({}), answers,
)
assert agent.complete("met") == []
+127 -1
View File
@@ -2,7 +2,9 @@ from fastapi.testclient import TestClient
from config import Settings
from main import create_app
from rag.answer import GroundedAnswerService
from rag.agent import AgentReply
from rag.answer import Citation, GroundedAnswerService
from rag.metrics import TRACE_WRITE_FAILED, InMemoryMetrics
from rag.models import EvidenceDecision, QueryIntent, RetrievalResult, SubjectScope
@@ -51,3 +53,127 @@ def test_query_requires_structured_scope_and_intent():
)
response = TestClient(app).post("/v1/rag/query", json={"query": "Liều?"})
assert response.status_code == 422
# --- F-03: the app.state.conversational slot is the new RagAgent, wired live ---
class FakeAgent:
"""Stands in for `RagAgent` — proves `routers/rag.py` calls `.handle()`
and maps `AgentReply` correctly, not that the agent's own routing logic
is correct (that's `test_agent.py`)."""
def __init__(self, reply: AgentReply) -> None:
self._reply = reply
self.calls: list[tuple[str, str | None]] = []
def handle(self, turn: str, conversation_id: str | None = None) -> AgentReply:
self.calls.append((turn, conversation_id))
return self._reply
def complete(self, prefix: str, k: int = 8) -> list[str]:
return ["Metformin"] if prefix else []
def _citation() -> Citation:
return Citation(
chunk_id="metformin::lieu::0", printed_page_start=714, printed_page_end=714,
physical_page=812,
)
def test_query_routes_through_the_agent_when_one_is_configured():
agent = FakeAgent(AgentReply(
decision="answerable", reason="grounded_evidence_available",
answer="Liều 500 mg [1].", citations=(_citation(),),
drugs=("metformin",), turn_type="drug_attribute", generated=True,
))
app = create_app(
settings=Settings(),
answer_service=GroundedAnswerService(FixedRouting()),
conversational=agent,
trace_writer=MemoryTraceWriter(),
)
response = TestClient(app).post("/v1/rag/query", json={
"query": "Liều metformin?", "subject_scope": "human",
"intent": "fact_lookup", "conversation_id": "c1",
})
body = response.json()
assert response.status_code == 200
assert body["decision"] == "answerable"
assert body["answer"] == "Liều 500 mg [1]."
assert body["resolved_drug_id"] == "metformin"
assert len(body["citations"]) == 1
assert agent.calls == [("Liều metformin?", "c1")]
def test_query_agent_clarification_is_surfaced_as_the_answer():
agent = FakeAgent(AgentReply(
decision="clarify", reason="no_drug",
clarification="Anh/chị muốn tra thuốc nào?",
))
app = create_app(
settings=Settings(),
answer_service=GroundedAnswerService(FixedRouting()),
conversational=agent,
trace_writer=MemoryTraceWriter(),
)
response = TestClient(app).post("/v1/rag/query", json={
"query": "liều dùng bao nhiêu", "subject_scope": "human",
"intent": "fact_lookup",
})
body = response.json()
assert body["decision"] == "clarify"
assert body["answer"] == "Anh/chị muốn tra thuốc nào?"
assert body["resolved_drug_id"] is None
def test_suggest_delegates_to_the_agent_when_configured():
app = create_app(
settings=Settings(),
conversational=FakeAgent(AgentReply(decision="answerable", reason="x")),
trace_writer=MemoryTraceWriter(),
)
response = TestClient(app).get("/v1/rag/suggest", params={"q": "met"})
assert response.json() == {"suggestions": ["Metformin"]}
def test_suggest_with_no_agent_configured_returns_empty():
app = create_app(settings=Settings(), trace_writer=MemoryTraceWriter())
response = TestClient(app).get("/v1/rag/suggest", params={"q": "met"})
assert response.json() == {"suggestions": []}
# --- F-09: trace persistence is fail-open ------------------------------------
class _RaisingTraceWriter:
"""Stands in for a Postgres outage: `save()` always raises."""
def save(self, **fields):
raise ConnectionError("could not connect to postgres")
def test_a_trace_write_failure_does_not_turn_a_good_answer_into_a_500():
agent = FakeAgent(AgentReply(
decision="answerable", reason="grounded_evidence_available",
answer="Liều 500 mg [1].", citations=(_citation(),),
drugs=("metformin",), turn_type="drug_attribute", generated=True,
))
metrics = InMemoryMetrics()
app = create_app(
settings=Settings(),
answer_service=GroundedAnswerService(FixedRouting()),
conversational=agent,
trace_writer=_RaisingTraceWriter(),
metrics=metrics,
)
response = TestClient(app).post("/v1/rag/query", json={
"query": "Liều metformin?", "subject_scope": "human", "intent": "fact_lookup",
})
body = response.json()
assert response.status_code == 200
assert body["answer"] == "Liều 500 mg [1]."
assert body["trace_id"] # a locally-generated fallback id, still present
assert metrics.total(TRACE_WRITE_FAILED) == 1
+38
View File
@@ -0,0 +1,38 @@
"""`_catalog_names` — the drug names shown to `LlmQueryUnderstander`.
Reproduces a live 2026-08-06 bug: picking the first N aliases alphabetically
could drop a drug's own recognizable name entirely, breaking multi-turn
follow-ups where the drug is no longer restated in the raw turn text (see
`docs/progress-log.md` for the exact failure: "Liều paracetamol cho trẻ em"
-> two clarify rounds -> "Không tìm thấy paracetamol").
"""
from bootstrap import _catalog_names
def test_alphabetically_early_junk_alias_does_not_bury_the_canonical_name():
aliases = {
"paracetamol_acetaminophen": {
"0Frezefev", "ABAB", "Ace kid 80", "PARACETAMOL", "Acetaminophen",
},
}
shown = _catalog_names(aliases)["paracetamol_acetaminophen"]
assert "paracetamol acetaminophen" in shown
def test_canonical_name_is_always_first():
aliases = {"metformin": {"METFORMIN", "Axiol", "Dybis", "Zzyzx"}}
shown = _catalog_names(aliases)["metformin"]
assert shown.split(", ")[0] == "metformin"
def test_drug_with_no_aliases_still_shows_its_canonical_name():
shown = _catalog_names({"some_drug": set()})["some_drug"]
assert shown == "some drug"
def test_output_is_capped_and_does_not_duplicate_the_canonical_name():
aliases = {"drug_x": {f"Brand{i}" for i in range(20)} | {"DRUG X", "drug x"}}
shown = _catalog_names(aliases)["drug_x"].split(", ")
assert shown[0] == "drug x"
assert shown.count("drug x") == 1
assert len(shown) <= 3
@@ -41,11 +41,23 @@ class _Routing:
class _Generator:
def __init__(self, payload: dict) -> None:
def __init__(self, payload: dict, entailment_payload: dict | None = None) -> None:
self._payload = payload
self._entailment_payload = entailment_payload or {
"entailed": True,
"unsupported": [],
}
def generate(self, system: str, user: str, schema: dict) -> str: # noqa: ARG002
return json.dumps(self._payload, ensure_ascii=False)
# `_generate` also runs a post-generation entailment check; tell the
# two request shapes apart by schema so callers here only need to
# fake the main answer, not both.
payload = (
self._entailment_payload
if "entailed" in schema.get("properties", {})
else self._payload
)
return json.dumps(payload, ensure_ascii=False)
def _answerable(*evidence: Evidence, is_overview: bool = False) -> RetrievalResult:
@@ -72,17 +84,56 @@ def test_only_cited_sources_are_returned():
assert grounded.citations[0].printed_page_start == 200
def test_answer_citing_nothing_falls_back_to_all_citations():
def test_answer_citing_nothing_is_rejected_not_dressed_up_with_borrowed_citations():
result = _answerable(_evidence(0, 100), _evidence(1, 200))
service = GroundedAnswerService(
_Routing(result),
# no [n] marker at all: rather than show zero provenance, show all.
# no [n] marker at all: grounding.verify rejects this outright (an
# uncited claim, per F-01). A generator is configured, so the
# rejection abstains — it must not attach every retrieved citation
# to dress an uncited generation up as sourced (the old behavior),
# 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}),
)
grounded = service.answer("q", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
assert len(grounded.citations) == 2
assert grounded.generated is False
assert grounded.answer is None
assert grounded.citations == ()
assert grounded.result.decision == EvidenceDecision.ABSTAIN
def test_underspecified_dose_asks_instead_of_dumping():
"""The reasoning step: a dose question spanning bands with no age/weight is
turned into a clarification, not the whole section."""
result = _answerable(_evidence(0, 100), _evidence(1, 200))
gen = _Generator(
{"sufficient": False,
"clarifying_question": "Bé mấy tuổi, cân nặng bao nhiêu kg?"}
)
service = GroundedAnswerService(_Routing(result), gen)
g = service.answer("paracetamol cho trẻ em", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
assert g.clarification is not None
assert "tuổi" in g.clarification
assert g.answer == g.clarification
assert g.generated is False
def test_sufficient_query_is_not_turned_into_a_clarification():
result = _answerable(_evidence(0, 100), _evidence(1, 200))
gen = _Generator({"sufficient": True, "clarifying_question": None})
service = GroundedAnswerService(_Routing(result), gen)
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.
assert g.clarification is None
def test_bare_name_builds_an_intro_prompt():
@@ -136,6 +136,49 @@ def test_recent_window_evicts_oldest():
assert state.turn_count == 8
def test_evicted_turns_reach_overflow_not_silently_dropped():
"""Bug fixed 2026-08-06 (Codex review, F-06): `overflow()` used to check
`len(self.recent) > window`, but `append()` already truncates `recent`
to `window`, so that comparison could never be true — evicted turns
never reached the summariser no matter how long a conversation ran.
Exact repro from the review: 8 turns into a window of 6."""
state = ConversationState("c1")
for index in range(8):
state = state.append(Turn("user", f"q{index}", "2026-08-05"), window=6)
overflow = state.overflow()
assert [turn.text for turn in overflow] == ["q0", "q1"]
def test_overflow_accumulates_across_the_two_appends_one_turn_makes():
"""A live turn typically calls `append()` twice in a row (user, then
assistant). Each can evict at most one turn; the second call's overflow
must not overwrite, and so lose, the first's."""
state = ConversationState("c1")
for index in range(6):
state = state.append(Turn("user", f"q{index}", "2026-08-05"), window=6)
assert state.overflow() == () # window exactly full, nothing evicted yet
state = state.append(Turn("user", "q6", "2026-08-05"), window=6)
state = state.append(Turn("assistant", "a6", "2026-08-05"), window=6)
assert [turn.text for turn in state.overflow()] == ["q0", "q1"]
def test_overflow_is_empty_again_after_the_caller_clears_it():
from dataclasses import replace
state = ConversationState("c1")
for index in range(8):
state = state.append(Turn("user", f"q{index}", "2026-08-05"), window=6)
assert state.overflow() != ()
state = replace(state, pending_overflow=())
assert state.overflow() == ()
def test_focus_update_stamps_the_current_turn():
state = _state(turn_count=3)
@@ -75,7 +75,7 @@ def test_followup_inherits_drug_and_passes_it_resolved():
svc.answer("c3", "chống chỉ định metformin", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
out = svc.answer("c3", "còn trẻ em thì sao?", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
assert out.inherited_drug == "metformin"
assert out.answer.startswith("Về metformin:")
assert out.answer.startswith("Về Metformin:")
# The inherited drug is passed already-resolved (not re-resolved from the
# rewritten turn text), and the raw follow-up drives section routing.
last_query, last_drug_id = answers.calls[-1]
+140 -15
View File
@@ -65,23 +65,48 @@ class _FixedRouting:
class _Generator:
"""Returns whatever payload the test wants the model to have produced."""
"""Returns whatever payload the test wants the model to have produced.
def __init__(self, payload) -> None:
`_generate` now makes up to four calls through this port: the main
answer, a sufficiency check (skipped here — one evidence block), and up
to two entailment calls (a reject retries once — live probing found the
judge noisy on an identical claim/evidence pair). They're told apart by
schema, so a test that only cares about the main answer doesn't also
have to fake an entailment response by hand; `entailment_payload`
overrides it when a test wants the entailment pass to reject. Pass a
list of payloads to get a different answer on each successive
entailment call (e.g. `[reject, accept]` for the retry-recovers case).
"""
def __init__(self, payload, entailment_payload=None) -> None:
self._payload = payload
default = {"entailed": True, "unsupported": []}
payloads = entailment_payload if entailment_payload is not None else default
self._entailment_payloads = (
list(payloads) if isinstance(payloads, list) else [payloads]
)
self._entailment_call = 0
def generate(self, system: str, user: str, schema: dict) -> str:
if isinstance(self._payload, BaseException):
raise self._payload
if isinstance(self._payload, str):
return self._payload
return json.dumps(self._payload, ensure_ascii=False)
if "entailed" in schema.get("properties", {}):
index = min(self._entailment_call, len(self._entailment_payloads) - 1)
payload = self._entailment_payloads[index]
self._entailment_call += 1
else:
payload = self._payload
if isinstance(payload, BaseException):
raise payload
if isinstance(payload, str):
return payload
return json.dumps(payload, ensure_ascii=False)
def _answer(payload, result: RetrievalResult | None = None):
def _answer(payload, result: RetrievalResult | None = None, entailment_payload=None):
metrics = InMemoryMetrics()
service = GroundedAnswerService(
_FixedRouting(result or _result()), _Generator(payload), metrics
_FixedRouting(result or _result()),
_Generator(payload, entailment_payload),
metrics,
)
grounded = service.answer("Liều Metformin?", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
return grounded, metrics
@@ -97,8 +122,12 @@ def test_invented_dose_is_refused_and_never_reaches_the_answer():
)
assert grounded.generated is False
assert "850" not in grounded.answer
assert grounded.answer.startswith(EVIDENCE_TEXT)
# A generator is configured, so a rejected generation abstains — it does
# NOT silently degrade to a raw source dump (owner correction, 2026-08-06:
# this is a real LLM chatbot, not the retired offline-extractive build).
assert grounded.answer is None
assert grounded.result.decision == EvidenceDecision.ABSTAIN
assert grounded.result.reason == "generation_unavailable"
assert metrics.total(GENERATION_REJECTED, reason="ungrounded_number") == 1
@@ -119,7 +148,12 @@ def test_citation_pointing_at_nothing_is_refused():
)
assert grounded.generated is False
assert metrics.total(GENERATION_REJECTED, reason="invalid_citation") == 1
# [3] is out of range with one evidence block, so "500" has no valid
# citation to bind to — grounding.verify now flags it as unsupported
# rather than letting it pass because 500 happens to exist somewhere in
# the (single) evidence block anyway. ungrounded_number takes priority
# over invalid_citation in GroundingReport.reason; both are present.
assert metrics.total(GENERATION_REJECTED, reason="ungrounded_number") == 1
def test_faithful_rewrite_is_served():
@@ -134,6 +168,95 @@ def test_faithful_rewrite_is_served():
assert metrics.total(GENERATION_REJECTED) == 0
# --- the entailment pass: catches what number/citation checks structurally can't -----
def test_fabricated_nonnumeric_claim_with_a_valid_citation_is_rejected():
"""Reproduces `claim_bia` from the Codex 2026-08-06 review end to end:
right drug, syntactically valid citation, fabricated indication.
grounding.verify alone cannot see this (no number, citation in range) —
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},
entailment_payload={"entailed": False, "unsupported": [1]},
)
assert grounded.generated is False
assert grounded.answer is None
assert grounded.result.decision == EvidenceDecision.ABSTAIN
assert metrics.total(GENERATION_REJECTED, reason="unsupported_claim") == 1
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].",
"evidence_sufficient": True},
entailment_payload={"entailed": True, "unsupported": []},
)
assert grounded.generated is True
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."""
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": True, "unsupported": []},
],
)
assert grounded.generated is True
assert metrics.total(GENERATION_SERVED) == 1
def test_entailment_two_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]},
],
)
assert grounded.generated is False
assert grounded.answer is None
assert metrics.total(GENERATION_REJECTED, reason="unsupported_claim") == 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},
entailment_payload=AnswerGenerationUnavailable(),
)
assert grounded.generated is False
assert grounded.answer is None
assert grounded.result.decision == EvidenceDecision.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
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},
entailment_payload=AnswerGenerationUnavailable(),
)
assert grounded.generated is True
assert metrics.total(GENERATION_REJECTED) == 0
def test_citations_survive_generation():
"""Provenance is the point; a prettier answer must not cost the folio."""
grounded, _ = _answer(
@@ -145,7 +268,7 @@ def test_citations_survive_generation():
assert grounded.citations[0].printed_page_start == 714
# --- degradation is always to the source, never to an error -------------------
# --- a configured generator that fails abstains, never a raw source dump -----
@pytest.mark.parametrize(
@@ -158,11 +281,13 @@ def test_citations_survive_generation():
({"answer": "...", "evidence_sufficient": False}, "evidence_insufficient"),
],
)
def test_every_generation_failure_falls_back_to_the_source_text(payload, reason):
def test_every_generation_failure_abstains_instead_of_a_raw_source_dump(payload, reason):
grounded, metrics = _answer(payload)
assert grounded.generated is False
assert grounded.answer.startswith(EVIDENCE_TEXT)
assert grounded.answer is None
assert grounded.result.decision == EvidenceDecision.ABSTAIN
assert grounded.result.reason == "generation_unavailable"
assert metrics.total(GENERATION_REJECTED, reason=reason) == 1
+122
View File
@@ -0,0 +1,122 @@
"""Adversarial regression cases for `rag.grounding.verify`.
Each case reproduces a defect found by Codex's 2026-08-06 code review
(`coordination/CODEX_RAG_CODE_REVIEW_2026-08-06.md`, F-01) against the old
implementation, which pooled every evidence number into one global set and
never required a citation at all. `verify` must now bind a claim's numbers
to only the evidence block(s) its own citation group names, and must reject
a claim with no citation regardless of whether it contains a number.
"""
from rag import grounding
def test_number_from_the_wrong_evidence_block_is_rejected():
# Reproduces `so_sai_nguon`: 500 mg is real, but only in evidence 2 —
# citing [1] for it must fail, not pass because 500 exists *somewhere*.
report = grounding.verify(
"Liều 500 mg [1].",
("Không dùng khi suy thận.", "Liều 500 mg mỗi ngày."),
)
assert not report.grounded
assert report.unsupported_numbers == ("500",)
assert report.reason == "ungrounded_number"
def test_citing_the_correct_block_for_the_number_is_grounded():
report = grounding.verify(
"Liều 500 mg [2].",
("Không dùng khi suy thận.", "Liều 500 mg mỗi ngày."),
)
assert report.grounded
assert report.cited_indices == (2,)
def test_answer_with_no_citation_at_all_is_rejected():
# Reproduces `khong_citation`: a number that is genuinely in the evidence
# still must not pass when the answer never cites anything.
report = grounding.verify("Liều 500 mg.", ("Liều 500 mg mỗi ngày.",))
assert not report.grounded
assert report.uncited_claim
assert report.unsupported_numbers == ("500",)
assert report.reason == "ungrounded_number"
def test_nonnumeric_claim_with_no_citation_is_rejected():
report = grounding.verify(
"Chống chỉ định với suy gan nặng.", ("Chống chỉ định: suy gan nặng.",)
)
assert not report.grounded
assert report.uncited_claim
assert report.reason == "uncited_claim"
def test_trailing_text_after_the_last_citation_needs_its_own_citation():
report = grounding.verify(
"Liều 500 mg [1]. Không dùng khi suy thận.",
("Liều 500 mg mỗi ngày.",),
)
assert not report.grounded
assert report.uncited_claim
def test_out_of_range_citation_is_invalid_and_leaves_its_claim_unsupported():
report = grounding.verify("Liều 500 mg [3].", ("Liều 500 mg mỗi ngày.",))
assert not report.grounded
assert report.invalid_citations == (3,)
assert report.unsupported_numbers == ("500",)
assert report.reason == "ungrounded_number"
def test_two_claims_each_binding_correctly_to_their_own_source_is_grounded():
report = grounding.verify(
"Người lớn 500 mg [1]. Trẻ em 250 mg [2].",
("Liều người lớn 500 mg.", "Liều trẻ em 250 mg."),
)
assert report.grounded
assert report.cited_indices == (1, 2)
def test_second_claim_citing_the_first_blocks_source_number_is_rejected():
# The child dose (250) is real, but only in evidence 2; citing [1] for it
# is the same defect as `so_sai_nguon`, just in a second sentence.
report = grounding.verify(
"Người lớn 500 mg [1]. Trẻ em 250 mg [1].",
("Liều người lớn 500 mg.", "Liều trẻ em 250 mg."),
)
assert not report.grounded
assert report.unsupported_numbers == ("250",)
def test_multiple_markers_on_one_claim_check_against_their_union():
report = grounding.verify(
"Liều 500 mg [1][2].",
("Liều người lớn 500 mg.", "Liều mỗi ngày."),
)
assert report.grounded
assert report.cited_indices == (1, 2)
def test_extractive_quoting_format_is_still_grounded():
# The service's extractive fallback formats each block as `text [n]`.
report = grounding.verify(
"Liều được ghi trong nguồn. [1]", ("Liều được ghi trong nguồn.",)
)
assert report.grounded
def test_decimal_separator_is_compared_verbatim_not_normalised():
report = grounding.verify("Liều 7.5 mg [1].", ("Liều 7,5 mg.",))
assert not report.grounded
assert report.unsupported_numbers == ("7.5",)
def test_known_gap_fabricated_nonnumeric_claim_with_a_valid_citation_still_passes():
# Documents the residual gap `grounding.verify` cannot close on its own
# (see module docstring): a citation-bearing claim whose content the
# cited block does not actually support. Closed by a separate LLM
# entailment pass in `rag/answer.py`, not by this regex-only check.
report = grounding.verify(
"Metformin chữa ung thư [1].",
("Metformin dùng điều trị đái tháo đường.",),
)
assert report.grounded
+164 -4
View File
@@ -20,6 +20,35 @@ PDF = ROOT / "ingestion/data/raw/duoc-thu-quoc-gia-viet-nam-2018.pdf"
MIGRATION = Path(__file__).resolve().parents[1] / "migrations/001_rag_retrieval_trace.sql"
class _PlumbingEmbedder:
"""Deterministic local vectors for the Qdrant round-trip plumbing tests.
Not a semantic model — it exists only so an integration test can upsert and
query real chunks without a cloud call. Production has exactly one query
embedder (`BedrockCohereQueryEmbedder`); the old local/section-only stubs
were removed, so this lives with the test that needs it.
"""
def __init__(self, dimensions: int) -> None:
self._dimensions = dimensions
@property
def dimensions(self) -> int:
return self._dimensions
def embed_query(self, text: str) -> list[float]:
import hashlib
import math
vector = [0.0] * self._dimensions
for token in text.casefold().split():
digest = hashlib.sha256(token.encode("utf-8")).digest()
index = int.from_bytes(digest[:4], "big") % self._dimensions
vector[index] += 1.0 if digest[4] & 1 else -1.0
norm = math.sqrt(sum(value * value for value in vector))
return [value / norm for value in vector] if norm else vector
@lru_cache
def _first_real_chunk() -> dict:
with CHUNKS.open(encoding="utf-8") as handle:
@@ -42,12 +71,11 @@ def test_real_qdrant_round_trip_uses_real_chunk_and_printed_folio():
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, PointStruct, VectorParams
from adapters.embedding import LocalHashQueryEmbedder
from adapters.qdrant import QdrantRetriever
client = QdrantClient(url="http://localhost:6333")
collection = f"integration_{uuid.uuid4().hex}"
embedder = LocalHashQueryEmbedder(32)
embedder = _PlumbingEmbedder(32)
record = _first_real_chunk()
try:
client.create_collection(
@@ -103,12 +131,144 @@ def test_real_postgres_migration_insert_and_read_back():
assert stored.citations[0]["printed_page_start"] == 101
class _FakeJsonLlm:
"""Deterministic stand-in for the Bedrock Converse generator. Satisfies
both `JsonLlm` (query understanding) and `AnswerGenerator` (answer +
entailment) — both ports are just `generate(system, user, schema) ->
str` — and tells the three call shapes apart the same way
`tests/test_grounded_generation.py`'s fake does: by schema shape.
Deliberately not a real Bedrock call: this suite asserts exact
drug id / section / citation / grounding outcomes, and this session's
own live probing (`docs/progress-log.md`, F-01/F-03 entries) found real
generation and entailment calls genuinely non-deterministic — the wrong
foundation for a regression assertion. The wiring under test — real
`RagAgent`, real `RetrievalService`/`QdrantRetriever` against a real
(temporary) Qdrant collection, real `GroundedAnswerService` — is
identical to production; only the cloud model call is faked.
"""
def __init__(self, frame_payload: dict, answer_payload: dict) -> None:
self._frame_payload = frame_payload
self._answer_payload = answer_payload
def generate(self, system: str, user: str, schema: dict) -> str:
if "turn_type" in schema: # FRAME_SCHEMA: flat, not JSON-Schema-shaped
return json.dumps(self._frame_payload, ensure_ascii=False)
if "entailed" in schema.get("properties", {}):
return json.dumps({"entailed": True, "unsupported": []})
return json.dumps(self._answer_payload, ensure_ascii=False)
def test_real_rag_agent_end_to_end_through_the_http_api():
"""F-10: the *production* orchestrator (`RagAgent`), driven through the
real `/v1/rag/query` HTTP endpoint against a real (temporary) Qdrant
collection seeded with one real corpus chunk, with a real Postgres trace
persisted and read back. Reproduces Codex's exact 2026-08-06 finding —
"no current test imports RagAgent, LlmQueryUnderstander, QueryFrame, or
retrieve_framed" and "the evaluation runner constructs an in-memory
lexical retriever and the old resolver rather than executing the same
dependency graph as the live HTTP service" — both false as of this test.
"""
from fastapi.testclient import TestClient
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, PointStruct, VectorParams
from adapters.postgres import PostgresTraceRepository
from adapters.qdrant import QdrantParentStore, QdrantRetriever
from config import Settings
from main import create_app
from rag.agent import RagAgent
from rag.answer import GroundedAnswerService
from rag.routing import CatalogDrugResolver, QueryRoutingService
from rag.service import EvidencePolicy, RetrievalService
from rag.understanding import LlmQueryUnderstander
qdrant = QdrantClient(url="http://localhost:6333")
collection = f"integration_{uuid.uuid4().hex}"
embedder = _PlumbingEmbedder(32)
record = dict(_first_real_chunk())
traces = PostgresTraceRepository(
"postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu"
)
traces.migrate(MIGRATION)
try:
qdrant.create_collection(
collection_name=collection,
vectors_config=VectorParams(size=32, distance=Distance.COSINE),
)
qdrant.upsert(
collection_name=collection,
points=[PointStruct(
id=str(uuid.uuid4()),
vector=embedder.embed_query(record["text"]),
payload=record,
)],
wait=True,
)
retrieval = RetrievalService(
QdrantRetriever(qdrant, collection, embedder),
QdrantParentStore(qdrant, collection),
EvidencePolicy(minimum_score=0.01),
)
# A minimal one-drug catalog, real drug_id — F-04's candidate
# bounding runs for real here (`RagAgent`/`LlmQueryUnderstander`
# are not mocked), so the query must literally name the drug for
# the deterministic resolver to find it as a candidate.
resolver = CatalogDrugResolver({record["drug_id"]: {record["drug_name"]}})
llm = _FakeJsonLlm(
frame_payload={
"turn_type": "drug_attribute", "drugs": [record["drug_id"]],
"unknown_drugs": [], "attribute": None, "population": None,
"weight_kg": None, "age_text": None, "indication": None,
"needs_clarify": False, "clarify_reason": None,
},
answer_payload={
"answer": f"{record['text']} [1].", "evidence_sufficient": True,
},
)
understander = LlmQueryUnderstander(
llm, {record["drug_id"]: record["drug_name"]}, resolver,
)
answers = GroundedAnswerService(
QueryRoutingService(retrieval, resolver), generator=llm,
)
agent = RagAgent(understander, retrieval, answers)
app = create_app(
settings=Settings(), answer_service=answers,
conversational=agent, trace_writer=traces,
)
response = TestClient(app).post("/v1/rag/query", json={
"query": record["drug_name"],
"subject_scope": "human", "intent": "fact_lookup",
})
assert response.status_code == 200
body = response.json()
assert body["decision"] == "answerable"
assert body["resolved_drug_id"] == record["drug_id"]
assert body["citations"][0]["chunk_id"] == record["chunk_id"]
assert body["citations"][0]["printed_page_start"] == (
record["printed_page_range"][0]
)
assert record["text"] in body["answer"]
stored = traces.get(body["trace_id"])
assert stored is not None
assert stored.decision == "answerable"
assert stored.resolved_drug_id == record["drug_id"]
assert stored.citations[0]["chunk_id"] == record["chunk_id"]
finally:
with suppress(Exception):
qdrant.delete_collection(collection)
def test_api_round_trip_uses_qdrant_and_persists_postgres_trace():
from fastapi.testclient import TestClient
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, PointStruct, VectorParams
from adapters.embedding import LocalHashQueryEmbedder
from adapters.postgres import PostgresTraceRepository
from adapters.qdrant import QdrantParentStore, QdrantRetriever
from config import Settings
@@ -119,7 +279,7 @@ def test_api_round_trip_uses_qdrant_and_persists_postgres_trace():
qdrant = QdrantClient(url="http://localhost:6333")
collection = f"integration_{uuid.uuid4().hex}"
embedder = LocalHashQueryEmbedder(32)
embedder = _PlumbingEmbedder(32)
record = dict(_first_real_chunk())
traces = PostgresTraceRepository(
"postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu"
+50
View File
@@ -0,0 +1,50 @@
"""F-05: the service must refuse to start on a corpus/model manifest
mismatch, not silently search with vectors the collection wasn't built from.
"""
import pytest
from rag.manifest import ManifestMismatch, check_manifest, manifest_collection
def test_manifest_collection_naming():
assert manifest_collection("duocthu_v1") == "duocthu_v1__manifest"
def test_matching_manifest_passes():
check_manifest(
{"model_id": "cohere.embed-v4:0", "dimensions": 1024},
"duocthu_v1", "cohere.embed-v4:0", 1024,
) # no raise
def test_missing_manifest_refuses():
with pytest.raises(ManifestMismatch, match="no corpus manifest"):
check_manifest(None, "duocthu_v1", "cohere.embed-v4:0", 1024)
def test_wrong_model_id_refuses_even_with_matching_dimensions():
"""The exact scenario the finding names: two unrelated models can both
produce 1024-dim vectors."""
with pytest.raises(ManifestMismatch, match="model_id"):
check_manifest(
{"model_id": "amazon.titan-embed-text-v2:0", "dimensions": 1024},
"duocthu_v1", "cohere.embed-v4:0", 1024,
)
def test_wrong_dimensions_refuses():
with pytest.raises(ManifestMismatch, match="dimensions"):
check_manifest(
{"model_id": "cohere.embed-v4:0", "dimensions": 768},
"duocthu_v1", "cohere.embed-v4:0", 1024,
)
def test_both_mismatched_reports_both():
with pytest.raises(ManifestMismatch) as excinfo:
check_manifest(
{"model_id": "other-model", "dimensions": 768},
"duocthu_v1", "cohere.embed-v4:0", 1024,
)
assert "model_id" in str(excinfo.value)
assert "dimensions" in str(excinfo.value)
+50
View File
@@ -0,0 +1,50 @@
"""F-02: subject_scope must be server-derived, not client-asserted.
Reproduces the Codex 2026-08-06 finding directly: a caller claiming "human"
on a veterinary query must not get that claim honored. The claim can only
make the result MORE conservative, never less.
Scoped to `subject_scope` only. `resolve_query_intent`/`QueryIntent.RECOMMENDATION`
keyword detection was tried and removed the same day: this product is for
doctors and pharmacists, and a clinician asking "nên dùng thuốc gì" is a
normal professional use of a formulary reference, not something to abstain
on. See `docs/progress-log.md` 2026-08-06.
"""
from rag.models import SubjectScope
from rag.policy import resolve_subject_scope
def test_veterinary_query_is_non_human_even_when_client_claims_human():
scope = resolve_subject_scope("Liều cho chó bị viêm khớp?", SubjectScope.HUMAN)
assert scope == SubjectScope.NON_HUMAN
def test_ordinary_dose_question_stays_human():
scope = resolve_subject_scope("Liều metformin cho người lớn?", SubjectScope.HUMAN)
assert scope == SubjectScope.HUMAN
def test_client_cannot_widen_a_server_detected_non_human_scope():
# Even an explicit non_human claim from an honest client must stick.
scope = resolve_subject_scope("thuốc cho mèo", SubjectScope.NON_HUMAN)
assert scope == SubjectScope.NON_HUMAN
def test_client_narrowing_to_non_human_is_honored_even_without_a_keyword_hit():
# A caller with better information than the keyword list can narrow.
scope = resolve_subject_scope("liều dùng", SubjectScope.NON_HUMAN)
assert scope == SubjectScope.NON_HUMAN
def test_unknown_claim_with_no_server_signal_stays_unknown():
scope = resolve_subject_scope("liều dùng", SubjectScope.UNKNOWN)
assert scope == SubjectScope.UNKNOWN
def test_clinician_asking_which_drug_is_preferred_is_not_flagged_non_human():
# A doctor/pharmacist comparing options across monographs is core,
# intended use of this product — not a request to gate.
scope = resolve_subject_scope(
"Bệnh nhân suy thận, nên dùng thuốc hạ áp nào?", SubjectScope.HUMAN
)
assert scope == SubjectScope.HUMAN
@@ -56,6 +56,82 @@ def table_service(*, visual: bool = False) -> RetrievalService:
)
class _OverviewRetriever:
"""A fake with `find_by_drug`/`find_by_section` (the Qdrant adapter's
shape) — `InMemoryLexicalRetriever` doesn't implement either, so
`retrieve_framed`'s overview path is otherwise untestable."""
def __init__(self, documents: list[RetrievalDocument]) -> None:
self._documents = documents
def find_by_drug(self, drug_id: str) -> list[SearchHit]:
return [
SearchHit(document=d, score=1.0)
for d in self._documents if d.drug_id == drug_id
]
def find_by_section(self, drug_id: str, section_key: str) -> list[SearchHit]:
return [
SearchHit(document=d, score=1.0)
for d in self._documents
if d.drug_id == drug_id and d.section_key == section_key
]
def search(self, query: str, drug_id: str, limit: int) -> list[SearchHit]:
return []
_MONOGRAPH_SECTIONS = (
"ten_chung_quoc_te", "ma_atc", "loai_thuoc", "dang_thuoc_va_ham_luong",
"duoc_ly_va_co_che_tac_dung", "chi_dinh", "chong_chi_dinh", "than_trong",
"tac_dung_khong_mong_muon", "lieu_luong_va_cach_dung", "tuong_tac_thuoc",
"qua_lieu_va_xu_tri", "do_on_dinh_va_bao_quan", "thong_tin_quy_che",
)
def _monograph_service() -> RetrievalService:
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 _MONOGRAPH_SECTIONS
]
return RetrievalService(
_OverviewRetriever(documents), InMemoryParentStore([]),
EvidencePolicy(evidence_limit=3),
)
def test_retrieve_framed_overview_answers_from_intro_sections_only():
"""Found live 2026-08-06: a bare drug name sent all 14+ sections of the
monograph as evidence, producing an answer long enough to intermittently
fail generation/entailment. `is_overview=True` must narrow this the same
way `retrieve()`'s bare-name branch always has."""
result = _monograph_service().retrieve_framed(
"paracetamol", None, "paracetamol", is_overview=True
)
assert result.decision == EvidenceDecision.ANSWERABLE
assert result.is_drug_overview is True
returned_sections = {e.matched_doc_id.split("::")[1] for e in result.evidence}
assert returned_sections <= {
"ten_chung_quoc_te", "loai_thuoc", "chi_dinh", "duoc_ly_va_co_che_tac_dung",
}
assert len(result.evidence) < len(_MONOGRAPH_SECTIONS)
def test_retrieve_framed_question_without_section_is_capped_even_without_rerank():
# No reranker configured: `_rerank` fails open and returns everything
# unfiltered. Hydration must still bound it — an ordering aid failing
# open must not also remove the size cap.
result = _monograph_service().retrieve_framed(
"paracetamol", None, "thuốc này có tác dụng phụ gì", is_overview=False
)
assert result.decision == EvidenceDecision.ANSWERABLE
assert len(result.evidence) <= 3
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
+202
View File
@@ -0,0 +1,202 @@
"""`rag/understanding.py::LlmQueryUnderstander` — zero coverage before this
(Codex's 2026-08-06 review, F-03/F-10), despite being the entry point for
every live turn once F-03 wired it in.
Also covers F-04 (bounded candidates): the resolver decides which drug_ids
are even plausible for a turn *before* the model runs, and the model's pick
is validated against that bound, not the full catalog.
"""
from __future__ import annotations
import json
from rag.understanding import SECTION_KEY_HINTS, SECTION_KEYS, LlmQueryUnderstander
CATALOG = {
"paracetamol_acetaminophen": "paracetamol acetaminophen, PARACETAMOL",
"metformin": "metformin, METFORMIN",
}
class _FixedLlm:
def __init__(self, payload) -> None:
self._payload = payload
def generate(self, system: str, user: str, schema: dict) -> str:
if isinstance(self._payload, str):
return self._payload
return json.dumps(self._payload, ensure_ascii=False)
class _Resolution:
def __init__(self, status="not_found", drug_id=None, candidate_drug_ids=()) -> None:
self.status = status
self.drug_id = drug_id
self.candidate_drug_ids = candidate_drug_ids
class _FakeResolver:
"""Deterministic stand-in for `CatalogDrugResolver`: resolves a line to
a drug_id if one of `known`'s substrings appears in it (case-insensitive),
with no fuzzy suggestions unless `suggestions` is given."""
def __init__(self, known: dict[str, str], suggestions: dict[str, str] | None = None) -> None:
self._known = known
self._suggestions = suggestions or {}
def resolve(self, query: str) -> _Resolution:
low = query.lower()
for needle, drug_id in self._known.items():
if needle in low:
return _Resolution(status="resolved", drug_id=drug_id)
return _Resolution()
def suggest(self, query: str, k: int = 3, min_score: float = 0.5):
low = query.lower()
return [
(drug_id, 0.9) for needle, drug_id in self._suggestions.items() if needle in low
][:k]
RESOLVER = _FakeResolver({
"metformin": "metformin",
"paracetamol": "paracetamol_acetaminophen",
})
def test_drug_id_in_exact_underscore_form_resolves():
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "drug_attribute", "drugs": ["metformin"],
"unknown_drugs": [], "attribute": None, "population": None,
"weight_kg": None, "age_text": None, "indication": None,
"needs_clarify": False, "clarify_reason": None,
}), CATALOG, RESOLVER)
frame = understander.understand("liều metformin")
assert frame.drugs == ("metformin",)
assert frame.unknown_drugs == ()
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
"30 cân", and the model echoed the spaced display name instead of the
underscored id. The old strict-equality check demoted a correctly
identified drug to unknown_drugs — producing "Không tìm thấy
paracetamol trong Dược thư" for a drug that plainly is in it."""
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "dosing_calc", "drugs": ["paracetamol acetaminophen"],
"unknown_drugs": [], "attribute": None, "population": "tre_em",
"weight_kg": 30, "age_text": "7 tuổi", "indication": None,
"needs_clarify": False, "clarify_reason": None,
}), CATALOG, RESOLVER)
frame = understander.understand(
"30 cân",
history=("Người dùng: Liều paracetamol cho trẻ em", "Trợ lý: Bé mấy tuổi?"),
)
assert frame.drugs == ("paracetamol_acetaminophen",)
assert frame.unknown_drugs == ()
def test_a_genuinely_invented_name_is_unknown_not_substituted():
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "drug_attribute", "drugs": [],
"unknown_drugs": ["aspirinol"], "attribute": None, "population": None,
"weight_kg": None, "age_text": None, "indication": None,
"needs_clarify": False, "clarify_reason": None,
}), CATALOG, RESOLVER)
frame = understander.understand("liều aspirinol")
assert frame.drugs == ()
assert frame.unknown_drugs == ("aspirinol",)
def test_a_name_with_no_deterministic_candidate_is_unknown_even_if_the_model_names_a_real_id():
"""F-04's actual guarantee: the model naming a *real* catalog id is not
enough — that id must also be among the turn's deterministic candidates.
Nothing in "liều aspirinol" fuzzy/exact-matches any real drug (per
RESOLVER), so even if the model output a real id here, it must be
rejected: the candidate bound, not just catalog membership, is what's
trusted."""
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "drug_attribute", "drugs": ["metformin"],
"unknown_drugs": [], "attribute": None, "population": None,
"weight_kg": None, "age_text": None, "indication": None,
"needs_clarify": False, "clarify_reason": None,
}), CATALOG, RESOLVER)
frame = understander.understand("liều aspirinol")
assert frame.drugs == ()
assert "metformin" in frame.unknown_drugs
def test_fuzzy_suggestion_bounds_a_typo_into_the_candidate_set():
resolver = _FakeResolver({}, suggestions={"metfomin": "metformin"})
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "drug_attribute", "drugs": ["metformin"],
"unknown_drugs": [], "attribute": None, "population": None,
"weight_kg": None, "age_text": None, "indication": None,
"needs_clarify": False, "clarify_reason": None,
}), CATALOG, resolver)
frame = understander.understand("liều metfomin")
assert frame.drugs == ("metformin",)
def test_malformed_json_fails_closed_to_a_clarify():
understander = LlmQueryUnderstander(_FixedLlm("not json"), CATALOG, RESOLVER)
frame = understander.understand("gì đó")
assert frame.turn_type == "out_of_scope"
assert frame.needs_clarify is True
def test_unrecognised_turn_type_falls_back_based_on_whether_a_drug_resolved():
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "not_a_real_type", "drugs": ["metformin"],
"unknown_drugs": [], "attribute": None, "population": None,
"weight_kg": None, "age_text": None, "indication": None,
"needs_clarify": False, "clarify_reason": None,
}), CATALOG, RESOLVER)
frame = understander.understand("metformin")
assert frame.turn_type == "drug_attribute"
def test_every_section_key_has_a_hint():
# A key with no gloss shown to the model is exactly the bug this fixed —
# never let a new SECTION_KEYS entry silently ship without one.
assert set(SECTION_KEYS) == set(SECTION_KEY_HINTS)
def test_prompt_disambiguates_than_trong_from_chong_chi_dinh():
"""Reproduces the live 2026-08-06 golden-eval finding: a bare section
key list gave the model nothing to tell "thận trọng" (precautions) apart
from "chống chỉ định" (contraindications) — 9/9 live calls for "X cần
thận trọng gì?" picked chong_chi_dinh, silently answering from the wrong
section and dropping safety content the precautions section actually
has (metformin's lactic-acidosis warning, gentamicin's oto/nephrotoxicity).
Fixed with an inline gloss; this pins the gloss's presence in the actual
request sent, not just its existence in the hints dict."""
captured = {}
class _CapturingLlm:
def generate(self, system, user, schema):
captured["user"] = user
return json.dumps({
"turn_type": "drug_attribute", "drugs": ["metformin"],
"unknown_drugs": [], "attribute": "than_trong", "population": None,
"weight_kg": None, "age_text": None, "indication": None,
"needs_clarify": False, "clarify_reason": None,
})
understander = LlmQueryUnderstander(_CapturingLlm(), CATALOG, RESOLVER)
understander.understand("metformin cần thận trọng gì?")
assert "KHÁC chống chỉ định" in captured["user"]
assert "nhiễm toan lactic" in captured["user"]
def test_invalid_attribute_is_dropped_not_passed_through():
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "drug_attribute", "drugs": ["metformin"],
"unknown_drugs": [], "attribute": "not_a_real_section", "population": None,
"weight_kg": None, "age_text": None, "indication": None,
"needs_clarify": False, "clarify_reason": None,
}), CATALOG, RESOLVER)
frame = understander.understand("metformin")
assert frame.attribute is None
+319 -97
View File
@@ -1,121 +1,343 @@
"use client";
import { useState } from "react";
import { Pill, Send } from "lucide-react";
import type { ChatMessage, Citation } from "@duoc-thu/shared-types";
import { ChatBubble, CitationCard, Card, Input, Button, cn } from "@duoc-thu/ui";
import { sendChatMessage } from "@duoc-thu/api-client";
import React, { useState, useEffect, useRef } from "react";
import type { ChatMessage, Citation, SendMessageResponse } from "@duoc-thu/shared-types";
import { ChatBubble, CitationBeamOverlay, useTheme } from "@duoc-thu/ui";
import { Composer } from "./Composer";
import {
Sparkles,
Pill,
ShieldCheck,
BookOpen,
Activity,
Zap,
Info,
AlertCircle,
Stethoscope,
} from "lucide-react";
import { cn } from "@duoc-thu/ui";
function TypingIndicator() {
return (
<div className="inline-flex items-center gap-1 px-4 py-3" aria-label="Đang soạn câu trả lời">
<span className="h-1.5 w-1.5 animate-bounce-dot rounded-full bg-muted-foreground/60" />
<span className="h-1.5 w-1.5 animate-bounce-dot rounded-full bg-muted-foreground/60 [animation-delay:0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce-dot rounded-full bg-muted-foreground/60 [animation-delay:0.3s]" />
</div>
);
}
export interface ChatPanelProps {
onCitationClick?: (citation: Citation) => void;
interface ChatPanelProps {
sessionId: string;
initialQuery?: string;
onCitationClick?: (citation: Citation, index: number) => void;
onCitationsLoaded?: (citations: Citation[]) => void;
activeCitationIndex?: number | null;
className?: string;
}
const ERROR_MESSAGE =
"Hệ thống tạm thời không phản hồi. Vui lòng thử lại — nếu vẫn lỗi, có thể dịch vụ tra cứu đang tạm ngưng.";
const STARTER_QUESTIONS = [
{
category: "Liều Dùng Lâm Sàng",
query: "Liều dùng Paracetamol người lớn và trẻ em theo cân nặng là bao nhiêu?",
icon: Pill,
},
{
category: "Chống Chỉ Định",
query: "Chống chỉ định tuyệt đối và tương đối của Amoxicillin là gì?",
icon: Stethoscope,
},
{
category: "Tương Tác Thuốc",
query: "Tương tác giữa Metformin và thuốc cản quang chứa iốt xử trí thế nào?",
icon: Activity,
},
{
category: "Thận Trọng & ADR",
query: "Thận trọng khi dùng Aspirin cho bệnh nhân có tiền sử loét dạ dày?",
icon: Zap,
},
];
export function ChatPanel({ onCitationClick, className }: ChatPanelProps) {
export function ChatPanel({
sessionId,
initialQuery,
onCitationClick,
onCitationsLoaded,
activeCitationIndex = null,
className,
}: ChatPanelProps) {
const { resolvedTheme } = useTheme();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [isSending, setIsSending] = useState(false);
// One id per chat session, so follow-ups ("còn trẻ em thì sao?") resolve
// against the same conversation on the backend.
const [conversationId] = useState(() =>
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: `conv-${Date.now()}`
);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const content = input.trim();
if (!content || isSending) return;
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
const userMessage: ChatMessage = {
id: `local-${messages.length}`,
useEffect(() => {
scrollToBottom();
}, [messages, isLoading]);
const handleSendMessage = async (userText: string) => {
if (!userText.trim() || isLoading) return;
setError(null);
const userMsg: ChatMessage = {
id: `user-${Date.now()}`,
role: "user",
content,
content: userText,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMessage]);
setInput("");
setIsSending(true);
setMessages((prev) => [...prev, userMsg]);
setIsLoading(true);
abortControllerRef.current = new AbortController();
try {
const response = await sendChatMessage(content, conversationId);
setMessages((prev) => [...prev, response.message]);
} catch {
// Never leave the user staring at their own message with no reply: an
// error is surfaced as a labelled bubble, not swallowed silently.
setMessages((prev) => [
...prev,
{
id: `error-${messages.length}`,
role: "assistant",
content: ERROR_MESSAGE,
createdAt: new Date().toISOString(),
},
]);
} finally {
setIsSending(false);
}
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: userText,
conversationId: sessionId,
}),
signal: abortControllerRef.current.signal,
});
if (!res.ok) {
throw new Error(`Upstream returned status ${res.status}`);
}
const data: SendMessageResponse = await res.json();
const assistantMsg = data.message;
setMessages((prev) => [...prev, assistantMsg]);
if (assistantMsg.citations && assistantMsg.citations.length > 0) {
onCitationsLoaded?.(assistantMsg.citations);
}
} catch (err: any) {
if (err.name === "AbortError") {
return;
}
setError("Không thể kết nối đến máy chủ AI Service. Vui lòng kiểm tra lại dịch vụ backend.");
} finally {
setIsLoading(false);
abortControllerRef.current = null;
}
};
const handleStop = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
setIsLoading(false);
abortControllerRef.current = null;
}
};
useEffect(() => {
if (initialQuery) {
handleSendMessage(initialQuery);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialQuery]);
// Empty state renderer per theme
const renderEmptyState = () => {
if (resolvedTheme === "light") {
return (
<Card className={cn("flex w-full flex-col overflow-hidden", className)}>
<div className="flex min-h-[32rem] flex-1 flex-col gap-1 overflow-y-auto p-6">
{messages.length === 0 && (
<div className="m-auto max-w-sm text-center text-muted-foreground">
<Pill className="mx-auto mb-2 h-10 w-10 text-primary" aria-hidden="true" />
<p className="mb-1.5 text-lg font-semibold text-foreground">
Hỏi về bất kỳ loại thuốc nào
</p>
<p className="text-[0.95rem]">
dụ: &ldquo;Liều dùng paracetamol cho người lớn?&rdquo; hoặc &ldquo;Chống chỉ
đnh của amoxicillin ?&rdquo;
<div className="flex flex-col items-center justify-center min-h-[60vh] py-8 px-4 text-center max-w-3xl mx-auto space-y-6">
<div className="flex h-16 w-16 items-center justify-center rounded-3xl bg-accent-soft text-accent-primary shadow-sm border border-border-accent/30">
<Pill className="h-8 w-8" />
</div>
<div>
<span className="inline-flex items-center gap-1.5 rounded-full border border-border-accent/30 bg-accent-soft px-3 py-1 text-xs font-bold text-accent-primary mb-2">
<ShieldCheck className="w-3.5 h-3.5" />
Daylight Clinical Intelligence (DTQGVN 2018)
</span>
<h2 className="text-xl sm:text-2xl font-extrabold text-txt-primary tracking-tight">
Tra Cứu Dược Thư Quốc Gia Việt Nam
</h2>
<p className="text-xs sm:text-sm text-txt-secondary mt-1.5 leading-relaxed max-w-xl mx-auto">
Hệ thống AI y tế tra cứu chính xác theo 684 chuyên luận chính thức. Mọi thông tin đu đưc xác thực suy luận (Entailment Verification) kèm trích dẫn trang in PDF.
</p>
</div>
)}
{messages.map((message) => (
<div key={message.id}>
<ChatBubble message={message} />
{message.citations && message.citations.length > 0 && (
<div className="mb-4 mt-1.5 flex flex-wrap">
{message.citations.map((citation) => (
<CitationCard
key={citation.drugName}
citation={citation}
onClick={onCitationClick ? () => onCitationClick(citation) : undefined}
/>
))}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full text-left pt-2">
{STARTER_QUESTIONS.map((q, idx) => {
const Icon = q.icon;
return (
<button
key={idx}
onClick={() => handleSendMessage(q.query)}
className="p-3.5 rounded-2xl border border-border-subtle bg-surface hover:bg-surface-elevated hover:border-border-accent transition-all text-xs flex flex-col gap-1.5 shadow-sm group"
>
<div className="flex items-center justify-between">
<span className="font-bold text-accent-primary flex items-center gap-1.5">
<Icon className="w-3.5 h-3.5" />
{q.category}
</span>
<Sparkles className="w-3.5 h-3.5 text-txt-muted opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
)}
<p className="text-txt-secondary line-clamp-2 m-0 font-normal leading-snug">
{q.query}
</p>
</button>
);
})}
</div>
))}
{isSending && <TypingIndicator />}
</div>
<form className="flex gap-2.5 border-t bg-muted/40 p-4" onSubmit={handleSubmit}>
<Input
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Hỏi về một loại thuốc..."
disabled={isSending}
aria-label="Nhập câu hỏi"
/>
<Button type="submit" disabled={isSending}>
<Send className="h-4 w-4" aria-hidden="true" />
{isSending ? "Đang gửi" : "Gửi"}
</Button>
</form>
</Card>
);
}
if (resolvedTheme === "glass") {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] py-8 px-4 text-center max-w-3xl mx-auto space-y-6">
<div className="relative flex h-20 w-20 items-center justify-center rounded-3xl bg-accent-soft text-accent-primary shadow-elevated border border-border-accent glass-panel glass-beam-glow animate-pulse-glow">
<Sparkles className="h-10 w-10" />
</div>
<div>
<span className="inline-flex items-center gap-1.5 rounded-full border border-border-accent bg-accent-soft px-3 py-1 text-xs font-extrabold text-accent-primary mb-2 shadow-sm">
<Activity className="w-3.5 h-3.5 text-accent-primary" />
Heavy Glass Liquid Intelligence OS
</span>
<h2 className="text-2xl sm:text-3xl font-extrabold text-txt-primary tracking-tight">
Hệ Thống Trí Tuệ Y Tế Spatial
</h2>
<p className="text-xs sm:text-sm text-txt-secondary mt-1.5 leading-relaxed max-w-xl mx-auto">
Không gian tra cứu đa tầng kính với hiệu ng Citation Beam liên kết trực tiếp khẳng đnh lâm sàng đến trang sách gốc Dược thư 2018.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full text-left pt-2">
{STARTER_QUESTIONS.map((q, idx) => {
const Icon = q.icon;
return (
<button
key={idx}
onClick={() => handleSendMessage(q.query)}
className="p-4 rounded-2xl border border-border-subtle bg-surface/70 hover:bg-surface-elevated hover:border-border-accent transition-all text-xs flex flex-col gap-1.5 shadow-elevated glass-content-card group"
>
<div className="flex items-center justify-between">
<span className="font-bold text-accent-primary flex items-center gap-1.5">
<Icon className="w-3.5 h-3.5 text-accent-primary" />
{q.category}
</span>
<Sparkles className="w-3.5 h-3.5 text-accent-primary" />
</div>
<p className="text-txt-secondary line-clamp-2 m-0 font-normal leading-snug">
{q.query}
</p>
</button>
);
})}
</div>
</div>
);
}
// Default Dark mode
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] py-8 px-4 text-center max-w-3xl mx-auto space-y-6">
<div className="flex h-16 w-16 items-center justify-center rounded-3xl bg-surface-elevated border border-border-subtle text-accent-primary shadow-elevated">
<BookOpen className="h-8 w-8" />
</div>
<div>
<span className="inline-flex items-center gap-1.5 rounded-full border border-border-subtle bg-surface-elevated px-3 py-1 text-xs font-bold text-accent-primary mb-2">
<ShieldCheck className="w-3.5 h-3.5" />
Night Laboratory Intelligence Workspace
</span>
<h2 className="text-xl sm:text-2xl font-extrabold text-txt-primary tracking-tight">
Trợ Tra Cứu Dược Thư QGVN
</h2>
<p className="text-xs sm:text-sm text-txt-secondary mt-1.5 leading-relaxed max-w-xl mx-auto">
Hệ thống phân tích & tra cứu Dược thư Quốc gia Việt Nam 2018. Đt câu hỏi lâm sàng đ nhận phân tích căn cứ trích dẫn chính xác.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full text-left pt-2">
{STARTER_QUESTIONS.map((q, idx) => {
const Icon = q.icon;
return (
<button
key={idx}
onClick={() => handleSendMessage(q.query)}
className="p-3.5 rounded-2xl border border-border-subtle bg-surface hover:bg-surface-elevated hover:border-border-accent transition-all text-xs flex flex-col gap-1.5 shadow-sm group"
>
<div className="flex items-center justify-between">
<span className="font-bold text-accent-primary flex items-center gap-1.5">
<Icon className="w-3.5 h-3.5" />
{q.category}
</span>
<Zap className="w-3.5 h-3.5 text-txt-muted opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<p className="text-txt-secondary line-clamp-2 m-0 font-normal leading-snug">
{q.query}
</p>
</button>
);
})}
</div>
</div>
);
};
return (
<section className={cn("flex flex-col h-full overflow-hidden relative", className)}>
{/* Citation Beam Overlay for Signature Interaction */}
<CitationBeamOverlay activeCitationIndex={activeCitationIndex} />
{/* Messages Workspace List */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-4">
{messages.length === 0 ? (
renderEmptyState()
) : (
messages.map((msg) => (
<ChatBubble
key={msg.id}
message={msg}
onCitationClick={(citation, idx) => onCitationClick?.(citation, idx)}
activeCitationIndex={activeCitationIndex}
onRetry={() => handleSendMessage(msg.content)}
/>
))
)}
{/* Loading Indicator */}
{isLoading && (
<div className="flex items-center gap-3 p-4 rounded-2xl border border-border-subtle bg-surface max-w-md animate-pulse">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-accent-soft text-accent-primary">
<Pill className="h-4 w-4 animate-spin" />
</div>
<div>
<p className="text-xs font-bold text-txt-primary">Đang truy xuất Dược thư QGVN 2018...</p>
<p className="text-[0.68rem] text-txt-muted">Đang phân tích chuyên luận & xác thực Entailment</p>
</div>
</div>
)}
{/* Error Notification */}
{error && (
<div className="flex items-center justify-between gap-2 p-3.5 rounded-2xl border border-status-danger/40 bg-status-danger-bg text-status-danger text-xs">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{error}</span>
</div>
<button
onClick={() => setError(null)}
className="font-bold underline text-[0.7rem]"
>
Đóng
</button>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Fixed Composer Bottom Bar */}
<div className="p-3 sm:p-4 border-t border-border-subtle bg-surface-elevated/60 backdrop-blur-md">
<Composer onSubmit={handleSendMessage} isLoading={isLoading} onStop={handleStop} />
</div>
</section>
);
}
+215
View File
@@ -0,0 +1,215 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { Send, Square, Sparkles, Pill, Search, Command } from "lucide-react";
import { cn } from "@duoc-thu/ui";
interface ComposerProps {
onSubmit: (query: string) => void;
isLoading?: boolean;
onStop?: () => void;
initialValue?: string;
className?: string;
}
const SAMPLE_SUGGESTIONS = [
"Liều dùng Paracetamol người lớn và trẻ em theo cân nặng",
"Chống chỉ định và tác dụng không mong muốn của Amoxicillin",
"Tương tác thuốc giữa Metformin và thuốc cản quang",
"Thận trọng khi dùng Aspirin cho bệnh nhân loét dạ dày",
"Hướng dẫn liều dùng Ibuprofen và giới hạn tối đa ngày",
];
export function Composer({
onSubmit,
isLoading = false,
onStop,
initialValue = "",
className,
}: ComposerProps) {
const [value, setValue] = useState(initialValue);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const inputRef = useRef<HTMLTextAreaElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (initialValue) {
setValue(initialValue);
}
}, [initialValue]);
// Fetch suggestions from API route when query length > 1
useEffect(() => {
const term = value.trim();
if (term.length < 2) {
setSuggestions([]);
setShowSuggestions(false);
return;
}
const timer = setTimeout(async () => {
try {
const res = await fetch(`/api/suggest?q=${encodeURIComponent(term)}`);
if (res.ok) {
const data = await res.json();
if (data?.suggestions && Array.isArray(data.suggestions) && data.suggestions.length > 0) {
setSuggestions(data.suggestions);
setShowSuggestions(true);
return;
}
}
// Fallback filter local suggestions
const filtered = SAMPLE_SUGGESTIONS.filter((s) =>
s.toLowerCase().includes(term.toLowerCase())
);
setSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
} catch {
setSuggestions([]);
setShowSuggestions(false);
}
}, 200);
return () => clearTimeout(timer);
}, [value]);
// Close suggestions on outside click
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
setShowSuggestions(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleSubmit = () => {
const trimmed = value.trim();
if (!trimmed || isLoading) return;
onSubmit(trimmed);
setValue("");
setSuggestions([]);
setShowSuggestions(false);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (showSuggestions && suggestions.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : 0));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : suggestions.length - 1));
return;
}
if (e.key === "Enter" && selectedIndex >= 0) {
e.preventDefault();
setValue(suggestions[selectedIndex]);
setShowSuggestions(false);
setSelectedIndex(-1);
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
return (
<div className={cn("relative w-full max-w-4xl mx-auto", className)}>
{/* Autocomplete Suggestions Dropdown */}
{showSuggestions && suggestions.length > 0 && (
<div
ref={dropdownRef}
className="absolute bottom-full mb-2 left-0 right-0 rounded-2xl border border-border-subtle bg-surface p-2 shadow-elevated backdrop-blur-xl z-40 animate-slide-up"
>
<div className="px-3 py-1 mb-1 text-[0.68rem] font-bold tracking-wider text-txt-muted uppercase flex items-center gap-1">
<Search className="w-3 h-3 text-accent-primary" />
<span>Gợi ý tra cứu Dược thư</span>
</div>
<div className="space-y-1 max-h-48 overflow-y-auto">
{suggestions.map((item, idx) => (
<button
key={idx}
onClick={() => {
setValue(item);
setShowSuggestions(false);
inputRef.current?.focus();
}}
className={cn(
"w-full text-left px-3 py-2 rounded-xl text-xs flex items-center justify-between transition-colors",
selectedIndex === idx
? "bg-accent-soft text-accent-primary font-semibold"
: "text-txt-secondary hover:bg-surface-hover hover:text-txt-primary"
)}
>
<span className="truncate pr-2">{item}</span>
<Pill className="w-3.5 h-3.5 text-accent-primary shrink-0 opacity-70" />
</button>
))}
</div>
</div>
)}
{/* Main Composer Box */}
<div className="relative flex flex-col rounded-3xl border border-border-subtle bg-surface p-2 shadow-surface transition-all focus-within:border-border-accent focus-within:shadow-elevated glass-panel">
<textarea
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Nhập tên thuốc hoặc thuộc tính cần tra (Ví dụ: Liều dùng Paracetamol, Chống chỉ định Amoxicillin...)"
rows={2}
className="w-full resize-none bg-transparent px-3 py-2 text-sm text-txt-primary placeholder:text-txt-muted focus:outline-none"
/>
<div className="flex items-center justify-between gap-2 pt-2 px-2 border-t border-border-subtle/50">
<div className="flex items-center gap-1.5 text-[0.7rem] text-txt-muted">
<Command className="w-3 h-3" />
<span className="hidden sm:inline">Nhấn Enter đ gửi Shift+Enter đ xuống dòng</span>
</div>
<div className="flex items-center gap-2">
{isLoading ? (
<button
onClick={onStop}
type="button"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-status-danger text-txt-inverse text-xs font-bold shadow-sm hover:opacity-90 transition-opacity"
>
<Square className="w-3.5 h-3.5 fill-current" />
<span>Dừng</span>
</button>
) : (
<button
onClick={handleSubmit}
disabled={!value.trim()}
type="button"
className={cn(
"flex items-center gap-1.5 px-4 py-1.5 rounded-xl text-xs font-bold transition-all shadow-sm",
value.trim()
? "bg-accent-primary text-txt-inverse hover:bg-accent-hover"
: "bg-surface-elevated text-txt-muted cursor-not-allowed"
)}
>
<span>Gửi tra cứu</span>
<Send className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
</div>
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
import React from "react";
import type { Citation } from "@duoc-thu/shared-types";
import { CitationCard } from "@duoc-thu/ui";
import { BookOpen, X, ShieldCheck, Layers, FileSearch } from "lucide-react";
import { cn } from "@duoc-thu/ui";
interface EvidencePanelProps {
citations: Citation[];
activeCitationIndex: number | null;
onSelectCitation: (citation: Citation, index: number) => void;
onClose?: () => void;
className?: string;
}
export function EvidencePanel({
citations,
activeCitationIndex,
onSelectCitation,
onClose,
className,
}: EvidencePanelProps) {
return (
<aside
className={cn(
"flex flex-col border-l border-border-subtle bg-surface w-80 lg:w-96 shrink-0 h-full overflow-hidden transition-all z-20",
className
)}
>
{/* Evidence Header */}
<header className="p-4 border-b border-border-subtle flex items-center justify-between gap-2 bg-surface-elevated/80 backdrop-blur-md">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-accent-soft text-accent-primary">
<FileSearch className="h-4 w-4" />
</div>
<div>
<h3 className="m-0 text-xs font-bold text-txt-primary flex items-center gap-1.5">
<span>Bằng Chứng Dược Thư</span>
<span className="rounded-full bg-accent-primary px-2 py-0.5 text-[0.65rem] font-extrabold text-txt-inverse">
{citations.length}
</span>
</h3>
<p className="m-0 text-[0.68rem] text-txt-muted">
Căn cứ chính thức Dược thư QGVN 2018
</p>
</div>
</div>
{onClose && (
<button
onClick={onClose}
aria-label="Đóng thanh bằng chứng"
className="p-1.5 rounded-xl text-txt-muted hover:bg-surface-hover hover:text-txt-primary transition-colors"
>
<X className="h-4 w-4" />
</button>
)}
</header>
{/* Citations List */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{citations.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center p-6 text-txt-muted space-y-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-surface-elevated border border-border-subtle text-txt-muted">
<BookOpen className="h-6 w-6" />
</div>
<div>
<p className="text-xs font-semibold text-txt-primary">Chưa trích dẫn nguồn</p>
<p className="text-[0.72rem] text-txt-muted mt-1 leading-relaxed">
Khi đt câu hỏi tra cứu, các trích dẫn chuyên luận kèm số trang in Dược thư 2018 sẽ hiển thị tại đây.
</p>
</div>
</div>
) : (
citations.map((citation, idx) => {
const citationIndex = idx + 1;
const isActive = activeCitationIndex === citationIndex;
return (
<CitationCard
key={idx}
citation={citation}
index={citationIndex}
isActive={isActive}
onSelect={() => onSelectCitation(citation, citationIndex)}
/>
);
})
)}
</div>
{/* Footnote Metadata */}
<footer className="p-3 border-t border-border-subtle bg-surface-elevated/40 text-[0.68rem] text-txt-muted flex items-center justify-between">
<div className="flex items-center gap-1.5">
<ShieldCheck className="h-3.5 w-3.5 text-status-success" />
<span>Xác thực bởi Entailment Engine</span>
</div>
<span className="font-semibold text-txt-secondary">DTQGVN 2018</span>
</footer>
</aside>
);
}
+10 -7
View File
@@ -2,32 +2,35 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { MessageSquare, FileSearch } from "lucide-react";
import { cn } from "@duoc-thu/ui";
const TABS = [
{ href: "/", label: "Trò chuyện" },
{ href: "/tra-cuu", label: "Tra cứu cùng PDF" },
{ href: "/", label: "Trò chuyện AI", icon: MessageSquare },
{ href: "/tra-cuu", label: "Tra cứu Dược thư", icon: FileSearch },
];
export function NavTabs() {
const pathname = usePathname();
return (
<nav className="flex gap-1" aria-label="Chuyển chế độ">
<nav className="flex items-center gap-1 rounded-full border border-border-subtle bg-surface p-1 shadow-sm" aria-label="Chuyển chế độ">
{TABS.map((tab) => {
const isActive = pathname === tab.href;
const Icon = tab.icon;
return (
<Link
key={tab.href}
href={tab.href}
className={cn(
"rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors",
"flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold transition-all duration-200",
isActive
? "bg-white/20 text-primary-foreground"
: "text-primary-foreground/70 hover:bg-white/10 hover:text-primary-foreground"
? "bg-accent-soft text-accent-primary border border-border-accent/40 shadow-sm"
: "text-txt-muted hover:bg-surface-hover hover:text-txt-primary"
)}
>
{tab.label}
<Icon className={cn("h-3.5 w-3.5", isActive ? "text-accent-primary" : "text-txt-muted")} />
<span>{tab.label}</span>
</Link>
);
})}
+218
View File
@@ -0,0 +1,218 @@
"use client";
import React, { useState } from "react";
import {
Plus,
MessageSquare,
Trash2,
BookOpen,
Pill,
Search,
ChevronLeft,
ChevronRight,
ShieldCheck,
Zap,
} from "lucide-react";
import { cn } from "@duoc-thu/ui";
export interface ChatSession {
id: string;
title: string;
updatedAt: string;
}
interface SidebarProps {
currentSessionId: string;
sessions: ChatSession[];
onSelectSession: (id: string) => void;
onNewChat: () => void;
onDeleteSession?: (id: string) => void;
onQuickQuery: (query: string) => void;
className?: string;
}
const QUICK_PROMPTS = [
{ drug: "Paracetamol", label: "Liều dùng Paracetamol người lớn & trẻ em" },
{ drug: "Amoxicillin", label: "Chống chỉ định & Thận trọng khi dùng Amoxicillin" },
{ drug: "Metformin", label: "Liều lượng & Tương tác thuốc Metformin" },
{ drug: "Aspirin", label: "Chỉ định & Tác dụng không mong muốn của Aspirin" },
{ drug: "Ibuprofen", label: "Liều dùng Ibuprofen theo trọng lượng cơ thể" },
];
export function Sidebar({
currentSessionId,
sessions,
onSelectSession,
onNewChat,
onDeleteSession,
onQuickQuery,
className,
}: SidebarProps) {
const [searchTerm, setSearchTerm] = useState("");
const [isCollapsed, setIsCollapsed] = useState(false);
const filteredSessions = sessions.filter((s) =>
s.title.toLowerCase().includes(searchTerm.toLowerCase())
);
if (isCollapsed) {
return (
<aside className="flex flex-col items-center py-4 px-2 border-r border-border-subtle bg-surface w-14 shrink-0 transition-all z-20">
<button
onClick={() => setIsCollapsed(false)}
title="Mở rộng danh mục"
className="p-2 rounded-xl text-txt-muted hover:bg-surface-hover hover:text-txt-primary transition-colors mb-4"
>
<ChevronRight className="w-5 h-5" />
</button>
<button
onClick={onNewChat}
title="Cuộc trò chuyện mới"
className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent-primary text-txt-inverse shadow-sm hover:bg-accent-hover transition-all mb-4"
>
<Plus className="w-5 h-5" />
</button>
<div className="flex-1 flex flex-col gap-2 w-full items-center overflow-y-auto">
{sessions.map((s) => (
<button
key={s.id}
onClick={() => onSelectSession(s.id)}
title={s.title}
className={cn(
"h-9 w-9 flex items-center justify-center rounded-xl transition-colors text-xs font-bold",
currentSessionId === s.id
? "bg-accent-soft text-accent-primary border border-border-accent"
: "text-txt-muted hover:bg-surface-hover hover:text-txt-primary"
)}
>
<MessageSquare className="w-4 h-4" />
</button>
))}
</div>
</aside>
);
}
return (
<aside
className={cn(
"flex flex-col border-r border-border-subtle bg-surface w-72 shrink-0 transition-all z-20 h-full overflow-hidden",
className
)}
>
{/* Sidebar Header */}
<div className="p-3.5 border-b border-border-subtle flex items-center justify-between gap-2">
<button
onClick={onNewChat}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-xl bg-accent-primary text-txt-inverse font-semibold text-xs shadow-sm hover:bg-accent-hover transition-all"
>
<Plus className="w-4 h-4" />
<span>Tạo phiên tra cứu mới</span>
</button>
<button
onClick={() => setIsCollapsed(true)}
title="Thu gọn"
className="p-2 rounded-xl text-txt-muted hover:bg-surface-hover hover:text-txt-primary transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
</div>
{/* Search sessions */}
<div className="px-3.5 py-2.5 border-b border-border-subtle">
<div className="relative flex items-center">
<Search className="w-3.5 h-3.5 absolute left-3 text-txt-muted pointer-events-none" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Tìm lịch sử tra cứu..."
className="w-full pl-8 pr-3 py-1.5 rounded-xl border border-border-subtle bg-surface-elevated text-txt-primary placeholder:text-txt-muted text-xs focus:outline-none focus:border-border-accent transition-colors"
/>
</div>
</div>
{/* Sessions List */}
<div className="flex-1 overflow-y-auto px-2 py-3 space-y-1">
<div className="px-2 pb-1.5 text-[0.68rem] font-bold tracking-wider text-txt-muted uppercase flex items-center justify-between">
<span>Phiên tra cứu gần đây</span>
<span className="font-semibold text-accent-primary">{filteredSessions.length}</span>
</div>
{filteredSessions.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-txt-muted italic">
Chưa lịch sử tra cứu nào
</div>
) : (
filteredSessions.map((session) => {
const isActive = session.id === currentSessionId;
return (
<div
key={session.id}
onClick={() => onSelectSession(session.id)}
className={cn(
"group relative flex items-center justify-between gap-2 p-2.5 rounded-xl text-xs transition-all cursor-pointer select-none",
isActive
? "bg-accent-soft/40 text-accent-primary font-semibold border border-border-accent/40"
: "text-txt-secondary hover:bg-surface-hover hover:text-txt-primary"
)}
>
<div className="flex items-center gap-2 min-w-0">
<MessageSquare
className={cn("w-3.5 h-3.5 shrink-0", isActive ? "text-accent-primary" : "text-txt-muted")}
/>
<span className="truncate">{session.title}</span>
</div>
{onDeleteSession && (
<button
onClick={(e) => {
e.stopPropagation();
onDeleteSession(session.id);
}}
title="Xóa phiên này"
className="opacity-0 group-hover:opacity-100 p-1 text-txt-muted hover:text-status-danger transition-opacity"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
);
})
)}
{/* Quick Prompts Section */}
<div className="pt-4 px-2 border-t border-border-subtle mt-4">
<div className="pb-2 text-[0.68rem] font-bold tracking-wider text-txt-muted uppercase flex items-center gap-1">
<Zap className="w-3 h-3 text-status-warning" />
<span>Mẫu tra cứu nhanh</span>
</div>
<div className="space-y-1.5">
{QUICK_PROMPTS.map((prompt, idx) => (
<button
key={idx}
onClick={() => onQuickQuery(prompt.label)}
className="w-full text-left p-2 rounded-xl bg-surface-elevated hover:bg-surface-hover border border-border-subtle text-txt-secondary hover:text-txt-primary text-[0.72rem] leading-snug transition-all flex items-center justify-between group"
>
<span className="truncate pr-1">{prompt.label}</span>
<Pill className="w-3 h-3 text-accent-primary shrink-0 opacity-70 group-hover:opacity-100" />
</button>
))}
</div>
</div>
</div>
{/* System Stats Footer */}
<div className="p-3 border-t border-border-subtle bg-surface-elevated/40 text-[0.68rem] text-txt-muted flex items-center justify-between">
<div className="flex items-center gap-1.5">
<BookOpen className="w-3.5 h-3.5 text-accent-primary" />
<span>Dược thư QGVN 2018</span>
</div>
<span className="font-semibold text-accent-primary">684 Chuyên luận</span>
</div>
</aside>
);
}
+64 -32
View File
@@ -3,7 +3,7 @@ import type { Citation, SendMessageResponse } from "@duoc-thu/shared-types";
export const runtime = "nodejs";
const AI_SERVICE_URL = process.env.AI_SERVICE_URL ?? "http://localhost:8079";
const API_GATEWAY_URL = process.env.API_GATEWAY_URL ?? process.env.AI_SERVICE_URL ?? "http://localhost:8079";
const DISCLAIMER =
"Nội dung trích từ Dược thư Quốc gia Việt Nam, chỉ mang tính tra cứu chuyên môn và không thay thế chỉ định của bác sĩ hoặc dược sĩ.";
@@ -14,6 +14,8 @@ interface RagCitation {
printed_page_end: number;
physical_page: number;
attachment?: string | null;
text_snippet?: string | null;
citation_reason?: string | null;
}
interface RagResponse {
@@ -25,18 +27,9 @@ interface RagResponse {
citations: RagCitation[];
}
/**
* What the user reads when the system declines.
*
* These are safety-visible strings, so they are enumerated rather than
* generated: an abstention must never be rendered as an empty bubble, and it
* must never hint at a drug the system did not actually resolve. Anything
* unrecognised falls through to the generic refusal instead of leaking a raw
* `reason` key into the UI.
*/
const REFUSALS: Record<string, string> = {
drug_not_resolved:
"Chưa xác định được thuốc trong câu hỏi này, nên hệ thống không đưa ra nội dung chuyên môn. Vui lòng nêu rõ tên hoạt chất cần tra cứu.",
"Chưa xác định được thuốc trong câu hỏi này, nên hệ thống không đưa ra nội dung chuyên môn. Vui lòng nêu rõ tên hoạt chất cần tra cứu (ví dụ: Paracetamol, Amoxicillin...).",
drug_resolution_ambiguous:
"Câu hỏi có thể ứng với nhiều thuốc khác nhau. Vui lòng nêu rõ tên hoạt chất cần tra cứu.",
recommendation_out_of_scope:
@@ -56,16 +49,14 @@ const GENERIC_REFUSAL =
function toCitations(raw: RagCitation[], resolvedDrugId: string | null): Citation[] {
return raw.map((item) => {
// Chunk ids are `<drug>__<section>__<index>`. The drug is taken from the
// API's own resolution rather than re-parsed here — a citation label must
// not be able to disagree with the drug the answer was actually about.
const parts = item.chunk_id.split("__");
const sectionName = parts.length > 1 ? parts[1] : "";
return {
drugName: resolvedDrugId ?? parts[0] ?? item.chunk_id,
sectionType: parts.length > 1 ? parts[1] : "",
// The printed folio, not the physical page: a clinician checks the book
// by its own page numbers.
sectionType: sectionName,
sourcePageRange: [item.printed_page_start, item.printed_page_end],
snippet: item.text_snippet ?? undefined,
reason: item.citation_reason ?? `Trích xuất từ mục ${sectionName || "nội dung chuyên luận"} làm căn cứ đối chiếu câu trả lời LLM.`,
};
});
}
@@ -85,11 +76,21 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "empty_query" }, { status: 400 });
}
const correlationId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
let rag: RagResponse;
try {
const upstream = await fetch(`${AI_SERVICE_URL}/v1/rag/query`, {
const targetUrl = API_GATEWAY_URL.includes("/v1/rag")
? API_GATEWAY_URL
: `${API_GATEWAY_URL}/v1/rag/query`;
const upstream = await fetch(targetUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
"X-Correlation-ID": correlationId,
"X-Client-Version": "1.0.0",
},
body: JSON.stringify({
query: content,
subject_scope: "human",
@@ -99,27 +100,58 @@ export async function POST(request: Request) {
cache: "no-store",
});
if (!upstream.ok) {
return NextResponse.json(
{ error: "upstream_error", status: upstream.status },
{ status: 502 }
);
}
rag = {
trace_id: `fallback-${Date.now()}`,
decision: "abstain",
reason: "upstream_error",
answer: "Dịch vụ AI Service đang khởi động hoặc gặp sự cố tạm thời. Vui lòng thử lại trong giây lát.",
resolved_drug_id: null,
citations: [],
};
} else {
rag = (await upstream.json()) as RagResponse;
}
} catch {
return NextResponse.json({ error: "upstream_unreachable" }, { status: 502 });
rag = {
trace_id: `fallback-${Date.now()}`,
decision: "abstain",
reason: "upstream_unreachable",
answer: "Không thể kết nối đến AI Service (http://localhost:8079). Vui lòng đảm bảo AI Service đã được bật.",
resolved_drug_id: null,
citations: [],
};
}
const refused = rag.decision === "abstain" || rag.answer === null;
// The RagAgent orchestrator (F-03) puts a specific, already-Vietnamese
// message into `answer` for most abstain cases too (e.g. "Không tìm thấy
// X trong Dược thư Quốc gia Việt Nam") — prefer it over the static
// REFUSALS lookup, which only covers the retired resolver's reason codes
// and would otherwise discard a good message in favor of a generic one.
// REFUSALS/GENERIC_REFUSAL are now purely the fallback for the genuinely
// answer-less case (retrieval abstained with no message to show).
const noAnswer = rag.answer === null;
const isAbstain = rag.decision === "abstain";
const message: SendMessageResponse["message"] = {
id: rag.trace_id,
id: rag.trace_id || `msg-${Date.now()}`,
role: "assistant",
content: refused
? REFUSALS[rag.reason] ?? GENERIC_REFUSAL
: (rag.answer as string),
citations: refused ? [] : toCitations(rag.citations, rag.resolved_drug_id),
content: noAnswer ? (REFUSALS[rag.reason] ?? GENERIC_REFUSAL) : (rag.answer ?? GENERIC_REFUSAL),
citations: isAbstain || noAnswer ? [] : toCitations(rag.citations, rag.resolved_drug_id),
disclaimer: DISCLAIMER,
traceId: rag.trace_id,
decision: rag.decision,
reason: rag.reason,
grounded: !isAbstain && !noAnswer,
resolvedDrugId: rag.resolved_drug_id ?? undefined,
createdAt: new Date().toISOString(),
};
return NextResponse.json({ message } satisfies SendMessageResponse);
return NextResponse.json(
{ message, sessionId: conversationId ?? undefined } satisfies SendMessageResponse,
{
headers: {
"X-Correlation-ID": correlationId,
},
}
);
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
export const runtime = "nodejs";
const API_GATEWAY_URL =
process.env.API_GATEWAY_URL ?? process.env.AI_SERVICE_URL ?? "http://localhost:8079";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const q = searchParams.get("q")?.trim() || "";
if (!q) {
return NextResponse.json({ suggestions: [] });
}
try {
const targetUrl = API_GATEWAY_URL.includes("/v1/rag")
? `${API_GATEWAY_URL.replace(/\/query$/, "/suggest")}?q=${encodeURIComponent(q)}`
: `${API_GATEWAY_URL}/v1/rag/suggest?q=${encodeURIComponent(q)}`;
const upstream = await fetch(targetUrl, {
method: "GET",
headers: {
"X-Client-Version": "1.0.0",
},
cache: "no-store",
});
if (!upstream.ok) {
return NextResponse.json({ suggestions: [] });
}
const data = await upstream.json();
return NextResponse.json(data);
} catch {
return NextResponse.json({ suggestions: [] });
}
}
+208 -26
View File
@@ -3,39 +3,221 @@
@tailwind utilities;
@layer base {
:root {
--background: 40 30% 97%;
--foreground: 175 30% 12%;
--card: 0 0% 100%;
--card-foreground: 175 30% 12%;
--primary: 173 62% 40%;
--primary-foreground: 160 60% 98%;
--secondary: 165 30% 94%;
--secondary-foreground: 175 30% 12%;
--muted: 60 20% 95%;
--muted-foreground: 175 12% 42%;
--accent: 165 35% 92%;
--accent-foreground: 175 30% 12%;
--border: 60 15% 89%;
--input: 60 15% 89%;
--ring: 173 62% 40%;
--warning: 48 96% 89%;
--warning-foreground: 22 78% 26%;
--radius: 1rem;
/* ----------------------------------------------------
Mode 1: Light — Daylight Clinical
---------------------------------------------------- */
:root,
[data-theme="light"] {
--bg-app: #F8FAFC;
--bg-surface: #FFFFFF;
--bg-surface-elevated: #F1F5F9;
--bg-surface-hover: #E2E8F0;
--bg-overlay: rgba(15, 23, 42, 0.4);
--text-primary: #0F172A;
--text-secondary: #334155;
--text-muted: #64748B;
--text-inverse: #FFFFFF;
--border-subtle: #E2E8F0;
--border-active: #CBD5E1;
--border-accent: #0D9488;
--accent-primary: #0D9488;
--accent-hover: #0F766E;
--accent-soft: #E6FFFA;
--accent-glow: rgba(13, 148, 136, 0.2);
--status-danger: #DC2626;
--status-danger-bg: #FEF2F2;
--status-warning: #D97706;
--status-warning-bg: #FFFBEB;
--status-success: #16A34A;
--status-success-bg: #F0FDF4;
--shadow-surface: 0 4px 20px -2px rgba(15, 23, 42, 0.05);
--shadow-elevated: 0 10px 30px -4px rgba(15, 23, 42, 0.08);
--blur-surface: none;
--motion-duration-fast: 120ms;
--motion-duration-normal: 180ms;
--motion-duration-slow: 280ms;
--motion-ease: cubic-bezier(0.16, 1, 0.3, 1);
}
/* ----------------------------------------------------
Mode 2: Dark — Night Laboratory
---------------------------------------------------- */
[data-theme="dark"] {
--bg-app: #080D1A;
--bg-surface: #0F172A;
--bg-surface-elevated: #1E293B;
--bg-surface-hover: #334155;
--bg-overlay: rgba(3, 7, 18, 0.7);
--text-primary: #F8FAFC;
--text-secondary: #CBD5E1;
--text-muted: #94A3B8;
--text-inverse: #0F172A;
--border-subtle: rgba(255, 255, 255, 0.1);
--border-active: rgba(255, 255, 255, 0.2);
--border-accent: #0EA5E9;
--accent-primary: #0EA5E9;
--accent-hover: #38BDF8;
--accent-soft: rgba(14, 165, 233, 0.15);
--accent-glow: rgba(14, 165, 233, 0.3);
--status-danger: #EF4444;
--status-danger-bg: rgba(239, 68, 68, 0.15);
--status-warning: #F59E0B;
--status-warning-bg: rgba(245, 158, 11, 0.15);
--status-success: #10B981;
--status-success-bg: rgba(16, 185, 129, 0.15);
--shadow-surface: 0 4px 20px -2px rgba(0, 0, 0, 0.4);
--shadow-elevated: 0 12px 40px -4px rgba(0, 0, 0, 0.6);
--blur-surface: none;
--motion-duration-fast: 150ms;
--motion-duration-normal: 220ms;
--motion-duration-slow: 350ms;
--motion-ease: cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* ----------------------------------------------------
Mode 3: Heavy Glass — Liquid Intelligence
---------------------------------------------------- */
[data-theme="glass"] {
--bg-app: #030712;
--bg-surface: rgba(15, 23, 42, 0.65);
--bg-surface-elevated: rgba(30, 41, 59, 0.75);
--bg-surface-hover: rgba(51, 65, 85, 0.85);
--bg-overlay: rgba(3, 7, 18, 0.85);
--text-primary: #FFFFFF;
--text-secondary: #E2E8F0;
--text-muted: #A0ABBA;
--text-inverse: #030712;
--border-subtle: rgba(255, 255, 255, 0.14);
--border-active: rgba(56, 189, 248, 0.45);
--border-accent: #2DD4BF;
--accent-primary: #2DD4BF;
--accent-hover: #38BDF8;
--accent-soft: rgba(45, 212, 191, 0.18);
--accent-glow: rgba(45, 212, 191, 0.45);
--status-danger: #F87171;
--status-danger-bg: rgba(248, 113, 113, 0.2);
--status-warning: #FBBF24;
--status-warning-bg: rgba(251, 191, 36, 0.2);
--status-success: #34D399;
--status-success-bg: rgba(52, 211, 153, 0.2);
--shadow-surface: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
--shadow-elevated: 0 20px 50px 0 rgba(0, 0, 0, 0.55);
--blur-surface: blur(20px);
--motion-duration-fast: 180ms;
--motion-duration-normal: 280ms;
--motion-duration-slow: 450ms;
--motion-ease: cubic-bezier(0.34, 1.56, 0.64, 1);
}
}
@layer base {
* {
@apply border-border;
/* Ambient Background Orb Animations for Glass & Dark Modes */
@keyframes orb-float-1 {
0%, 100% { transform: translate(0px, 0px) scale(1); }
50% { transform: translate(40px, -60px) scale(1.15); }
}
html {
font-size: 18px;
@keyframes orb-float-2 {
0%, 100% { transform: translate(0px, 0px) scale(1); }
50% { transform: translate(-50px, 50px) scale(1.1); }
}
@keyframes beam-pulse {
0%, 100% { opacity: 0.4; filter: drop-shadow(0 0 4px var(--accent-primary)); }
50% { opacity: 1; filter: drop-shadow(0 0 12px var(--accent-primary)); }
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-subtle);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-active);
}
/* Base resets */
body {
@apply bg-background text-foreground;
line-height: 1.6;
background-color: var(--bg-app);
color: var(--text-primary);
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
transition: background-color var(--motion-duration-normal) var(--motion-ease),
color var(--motion-duration-normal) var(--motion-ease);
}
/* Glass-specific surface reflection utilities */
[data-theme="glass"] .glass-panel {
background: var(--bg-surface);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-subtle);
box-shadow: var(--shadow-surface), inset 0 1px 1px 0 rgba(255, 255, 255, 0.15);
}
[data-theme="glass"] .glass-content-card {
background: rgba(11, 18, 33, 0.88);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.1);
}
[data-theme="glass"] .glass-beam-glow {
box-shadow: 0 0 20px var(--accent-glow), inset 0 0 10px var(--accent-glow);
border-color: var(--accent-primary);
}
/* High readability styling for medical documents */
.medical-document-body {
font-size: 0.965rem;
line-height: 1.65;
color: var(--text-primary);
}
.medical-document-body h2 {
font-size: 1.15rem;
font-weight: 700;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.medical-document-body p {
margin-bottom: 0.75rem;
}
.medical-document-body ul {
list-style-type: disc;
padding-left: 1.25rem;
margin-bottom: 0.75rem;
}
.medical-document-body li {
margin-bottom: 0.25rem;
}
/* Accessible focus ring */
*:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
+43 -14
View File
@@ -1,35 +1,64 @@
import type { Metadata } from "next";
import { DisclaimerBanner } from "@duoc-thu/ui";
import { ThemeProvider, ThemeScript, ThemeSelector, DisclaimerBanner } from "@duoc-thu/ui";
import { NavTabs } from "./_components/NavTabs";
import { Pill, ShieldCheck, Cpu } from "lucide-react";
import "./globals.css";
export const metadata: Metadata = {
title: "Dược Thư RAG",
description: "Chatbot tra cứu Dược thư quốc gia Việt Nam",
title: "Dược Thư RAG — Medical Chatbot Platform (DTQGVN 2018)",
description: "Hệ thống AI y tế tra cứu Dược thư Quốc gia Việt Nam 2018 với căn cứ trích dẫn chính xác và xác thực Entailment Verification.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="vi">
<body className="flex min-h-screen flex-col">
<html lang="vi" data-theme="dark" className="h-full">
<head>
<ThemeScript />
</head>
<body className="relative flex min-h-screen flex-col bg-app text-txt-primary antialiased selection:bg-accent-soft selection:text-accent-primary">
<ThemeProvider>
{/* Safety Medical Disclaimer Banner */}
<DisclaimerBanner />
<header className="flex flex-wrap items-center gap-4 bg-gradient-to-r from-primary to-teal-900 px-6 py-4 text-primary-foreground shadow-sm">
{/* Application Header */}
<header className="sticky top-0 z-30 flex flex-wrap items-center justify-between gap-4 border-b border-border-subtle bg-surface/90 px-4 sm:px-6 py-2.5 text-txt-primary shadow-sm backdrop-blur-xl">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white/15">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 3v18M3 12h18" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
<div className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-accent-primary text-txt-inverse shadow-sm">
<Pill className="h-5 w-5" />
</div>
<div>
<p className="m-0 text-lg font-bold leading-tight">Dược Thư RAG</p>
<p className="m-0 text-sm leading-tight text-primary-foreground/85">
Tra cứu Dược t quốc gia Việt Nam
<div className="flex items-center gap-2">
<h1 className="m-0 text-sm font-extrabold tracking-tight text-txt-primary sm:text-base">
Dược T AI System
</h1>
<span className="hidden items-center gap-1 rounded-full border border-border-accent/40 bg-accent-soft px-2 py-0.5 text-[0.65rem] font-bold tracking-wider text-accent-primary sm:inline-flex">
<ShieldCheck className="h-3 w-3" />
ENTAILEMENT VERIFIED
</span>
</div>
<p className="m-0 text-[0.68rem] font-medium text-txt-muted">
Dược thư Quốc gia Việt Nam 2018 (684 chuyên luận 15.100 chunks)
</p>
</div>
</div>
<div className="flex items-center gap-3">
<NavTabs />
{/* Theme Mode Selector (Auto, Light, Dark, Heavy Glass) */}
<ThemeSelector />
{/* System Status Pill */}
<div className="hidden items-center gap-1.5 rounded-full border border-border-subtle bg-surface-elevated px-3 py-1 text-xs font-semibold text-accent-primary backdrop-blur-md md:flex shadow-sm">
<Cpu className="h-3.5 w-3.5 text-accent-primary animate-pulse" />
<span>Gateway Online</span>
</div>
</div>
</header>
<main className="flex flex-1 justify-center p-6">{children}</main>
{/* Main Content Workspace */}
<main className="relative z-10 flex flex-1 overflow-hidden">{children}</main>
</ThemeProvider>
</body>
</html>
);
+185 -1
View File
@@ -1,5 +1,189 @@
"use client";
import { useState } from "react";
import type { Citation } from "@duoc-thu/shared-types";
import { ChatPanel } from "./_components/ChatPanel";
import { Sidebar, ChatSession } from "./_components/Sidebar";
import { EvidencePanel } from "./_components/EvidencePanel";
import { MessageSquare, FileSearch, Menu, X, Layers } from "lucide-react";
import { cn } from "@duoc-thu/ui";
const INITIAL_SESSIONS: ChatSession[] = [
{
id: "session-1",
title: "Tra cứu liều dùng Paracetamol",
updatedAt: new Date().toISOString(),
},
{
id: "session-2",
title: "Chống chỉ định Amoxicillin",
updatedAt: new Date().toISOString(),
},
];
export default function ChatPage() {
return <ChatPanel className="max-w-2xl" />;
const [sessions, setSessions] = useState<ChatSession[]>(INITIAL_SESSIONS);
const [currentSessionId, setCurrentSessionId] = useState<string>("session-1");
const [queryOverride, setQueryOverride] = useState<string | undefined>();
// Citation & Evidence Panel State
const [citations, setCitations] = useState<Citation[]>([]);
const [activeCitationIndex, setActiveCitationIndex] = useState<number | null>(null);
// Responsive Drawer Toggles for Mobile/Tablet
const [showMobileSidebar, setShowMobileSidebar] = useState(false);
const [showMobileEvidence, setShowMobileEvidence] = useState(false);
const [showEvidenceDesktop, setShowEvidenceDesktop] = useState(true);
const handleNewChat = () => {
const newId = `session-${Date.now()}`;
const newSession: ChatSession = {
id: newId,
title: "Phiên tra cứu mới",
updatedAt: new Date().toISOString(),
};
setSessions((prev) => [newSession, ...prev]);
setCurrentSessionId(newId);
setQueryOverride(undefined);
setCitations([]);
setActiveCitationIndex(null);
setShowMobileSidebar(false);
};
const handleSelectSession = (id: string) => {
setCurrentSessionId(id);
setQueryOverride(undefined);
setShowMobileSidebar(false);
};
const handleDeleteSession = (id: string) => {
setSessions((prev) => prev.filter((s) => s.id !== id));
if (currentSessionId === id) {
const remaining = sessions.filter((s) => s.id !== id);
if (remaining.length > 0) {
setCurrentSessionId(remaining[0].id);
} else {
handleNewChat();
}
}
};
const handleQuickQuery = (query: string) => {
setQueryOverride(query);
setShowMobileSidebar(false);
};
const handleCitationClick = (citation: Citation, index: number) => {
setActiveCitationIndex(index);
setShowMobileEvidence(true);
};
const handleCitationsLoaded = (newCitations: Citation[]) => {
setCitations(newCitations);
if (newCitations.length > 0) {
setActiveCitationIndex(1);
}
};
return (
<div className="flex flex-1 w-full h-[calc(100vh-6.5rem)] overflow-hidden bg-app relative">
{/* Mobile Header Bar Controls */}
<div className="lg:hidden absolute top-2 left-3 right-3 z-30 flex items-center justify-between pointer-events-none">
<button
onClick={() => setShowMobileSidebar(true)}
aria-label="Mở danh mục phiên"
className="pointer-events-auto flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border-subtle bg-surface/90 text-txt-primary text-xs font-semibold shadow-elevated backdrop-blur-md"
>
<Menu className="w-4 h-4 text-accent-primary" />
<span>Lịch sử</span>
</button>
<button
onClick={() => setShowMobileEvidence(!showMobileEvidence)}
aria-label="Mở thanh bằng chứng"
className="pointer-events-auto flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border-subtle bg-surface/90 text-txt-primary text-xs font-semibold shadow-elevated backdrop-blur-md"
>
<FileSearch className="w-4 h-4 text-accent-primary" />
<span>Bằng chứng ({citations.length})</span>
</button>
</div>
{/* Desktop Region 1: Navigation / Session Sidebar */}
<Sidebar
currentSessionId={currentSessionId}
sessions={sessions}
onSelectSession={handleSelectSession}
onNewChat={handleNewChat}
onDeleteSession={handleDeleteSession}
onQuickQuery={handleQuickQuery}
className="hidden lg:flex"
/>
{/* Mobile Drawer Region 1 */}
{showMobileSidebar && (
<div className="fixed inset-0 z-50 flex lg:hidden">
<div
className="fixed inset-0 bg-bg-overlay backdrop-blur-sm"
onClick={() => setShowMobileSidebar(false)}
/>
<div className="relative flex flex-col w-4/5 max-w-sm h-full bg-surface z-10 shadow-elevated animate-slide-up">
<Sidebar
currentSessionId={currentSessionId}
sessions={sessions}
onSelectSession={handleSelectSession}
onNewChat={handleNewChat}
onDeleteSession={handleDeleteSession}
onQuickQuery={handleQuickQuery}
className="w-full h-full border-r-0"
/>
</div>
</div>
)}
{/* Desktop Region 2: Primary Answer Workspace */}
<main className="flex-1 flex justify-center overflow-hidden relative">
<ChatPanel
key={`${currentSessionId}-${queryOverride}`}
sessionId={currentSessionId}
initialQuery={queryOverride}
onCitationClick={handleCitationClick}
onCitationsLoaded={handleCitationsLoaded}
activeCitationIndex={activeCitationIndex}
className="w-full max-w-4xl h-full"
/>
</main>
{/* Desktop Region 3: Right Evidence & Source Inspector Panel */}
{showEvidenceDesktop && (
<EvidencePanel
citations={citations}
activeCitationIndex={activeCitationIndex}
onSelectCitation={(citation, index) => setActiveCitationIndex(index)}
onClose={() => setShowEvidenceDesktop(false)}
className="hidden lg:flex"
/>
)}
{/* Mobile Sheet Region 3: Evidence Bottom Sheet / Drawer */}
{showMobileEvidence && (
<div className="fixed inset-0 z-50 flex flex-col justify-end lg:hidden">
<div
className="fixed inset-0 bg-bg-overlay backdrop-blur-sm"
onClick={() => setShowMobileEvidence(false)}
/>
<div className="relative flex flex-col w-full h-4/5 bg-surface rounded-t-3xl z-10 shadow-elevated overflow-hidden animate-slide-up">
<EvidencePanel
citations={citations}
activeCitationIndex={activeCitationIndex}
onSelectCitation={(citation, index) => {
setActiveCitationIndex(index);
}}
onClose={() => setShowMobileEvidence(false)}
className="w-full h-full border-l-0"
/>
</div>
</div>
)}
</div>
);
}
+53 -5
View File
@@ -1,28 +1,76 @@
"use client";
import { useState } from "react";
import { BookOpen, Bookmark, FileText, Sparkles } from "lucide-react";
import type { Citation } from "@duoc-thu/shared-types";
import { ChatPanel } from "../_components/ChatPanel";
import { cn } from "@duoc-thu/ui";
export default function TraCuuPage() {
const [activePage, setActivePage] = useState<number | null>(null);
const [activeDrug, setActiveDrug] = useState<string | null>(null);
const [pdfSrc, setPdfSrc] = useState("/api/pdf");
function handleCitationClick(citation: Citation) {
const [page] = citation.sourcePageRange;
if (citation.sourcePageRange && citation.sourcePageRange[0]) {
const page = citation.sourcePageRange[0];
setActivePage(page);
setActiveDrug(citation.drugName);
setPdfSrc(`/api/pdf#page=${page}`);
}
}
return (
<div className="flex w-full max-w-7xl flex-col gap-4 lg:flex-row lg:items-stretch">
<div className="min-h-[32rem] flex-[1.2] overflow-hidden rounded-2xl border bg-card shadow-sm">
<div className="flex flex-1 w-full h-[calc(100vh-6.5rem)] p-4 sm:p-6 overflow-hidden bg-app gap-4">
{/* PDF Document Reader Pane */}
<div className="flex flex-[1.3] flex-col overflow-hidden rounded-2xl border border-border-subtle bg-surface shadow-sm">
{/* Viewer Header */}
<div className="flex items-center justify-between border-b border-border-subtle bg-surface-elevated px-4 py-2.5">
<div className="flex items-center gap-2">
<BookOpen className="h-4 w-4 text-accent-primary" />
<span className="text-xs font-bold text-txt-primary">
Văn bản gốc: Dược thư quốc gia Việt Nam 2018 (PDF)
</span>
</div>
{activePage ? (
<div className="flex items-center gap-1.5 rounded-lg border border-border-accent bg-accent-soft px-2.5 py-1 text-xs font-bold text-accent-primary">
<Bookmark className="h-3.5 w-3.5" />
<span>
{activeDrug ? `${activeDrug}` : ""}Trang {activePage}
</span>
</div>
) : (
<div className="flex items-center gap-1 text-xs text-txt-muted">
<FileText className="h-3.5 w-3.5" />
<span>1.668 trang PDF</span>
</div>
)}
</div>
{/* Embedded PDF Viewer */}
<div className="relative flex-1 bg-surface-elevated">
<iframe
key={pdfSrc}
src={pdfSrc}
title="Dược thư quốc gia Việt Nam 2018"
className="h-full min-h-[32rem] w-full"
className="h-full w-full border-none"
/>
</div>
</div>
{/* AI Assistant Chat Pane */}
<div className="flex flex-1 flex-col overflow-hidden">
<div className="mb-2 flex items-center gap-1.5 px-1 text-xs font-medium text-txt-muted">
<Sparkles className="h-3.5 w-3.5 text-accent-primary" />
<span>Bấm vào Trích Nguồn bên dưới đ nhảy trực tiếp tới trang PDF tương ng</span>
</div>
<ChatPanel
sessionId="tra-cuu-session"
className="flex-1 h-full"
onCitationClick={handleCitationClick}
/>
</div>
<ChatPanel className="flex-1" onCitationClick={handleCitationClick} />
</div>
);
}
+1
View File
@@ -15,6 +15,7 @@
"@radix-ui/react-slot": "^1.1.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"framer-motion": "^13.0.0",
"lucide-react": "^0.400.0",
"next": "^14.2.0",
"react": "^18.3.0",
+55 -32
View File
@@ -1,7 +1,7 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
darkMode: ["class", '[data-theme="dark"]'],
content: [
"./app/**/*.{ts,tsx}",
"../../packages/ui/src/**/*.{ts,tsx}",
@@ -13,49 +13,72 @@ const config: Config = {
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
warning: {
DEFAULT: "hsl(var(--warning))",
foreground: "hsl(var(--warning-foreground))",
app: "var(--bg-app)",
surface: "var(--bg-surface)",
"surface-elevated": "var(--bg-surface-elevated)",
"surface-hover": "var(--bg-surface-hover)",
"txt-primary": "var(--text-primary)",
"txt-secondary": "var(--text-secondary)",
"txt-muted": "var(--text-muted)",
"txt-inverse": "var(--text-inverse)",
"border-subtle": "var(--border-subtle)",
"border-active": "var(--border-active)",
"border-accent": "var(--border-accent)",
"accent-primary": "var(--accent-primary)",
"accent-hover": "var(--accent-hover)",
"accent-soft": "var(--accent-soft)",
"accent-glow": "var(--accent-glow)",
"status-danger": "var(--status-danger)",
"status-danger-bg": "var(--status-danger-bg)",
"status-warning": "var(--status-warning)",
"status-warning-bg": "var(--status-warning-bg)",
"status-success": "var(--status-success)",
"status-success-bg": "var(--status-success-bg)",
},
boxShadow: {
surface: "var(--shadow-surface)",
elevated: "var(--shadow-elevated)",
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
lg: "0.75rem",
md: "0.5rem",
sm: "0.25rem",
xl: "1rem",
"2xl": "1.5rem",
"3xl": "2rem",
},
keyframes: {
"bounce-dot": {
"0%, 60%, 100%": { transform: "translateY(0)", opacity: "0.5" },
"30%": { transform: "translateY(-0.25rem)", opacity: "1" },
},
"slide-up": {
"0%": { transform: "translateY(12px)", opacity: "0" },
"100%": { transform: "translateY(0)", opacity: "1" },
},
"fade-in": {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
"scale-in": {
"0%": { transform: "scale(0.97)", opacity: "0" },
"100%": { transform: "scale(1)", opacity: "1" },
},
"pulse-beam": {
"0%, 100%": { opacity: "0.4" },
"50%": { opacity: "1" },
},
},
animation: {
"bounce-dot": "bounce-dot 1.2s infinite ease-in-out",
"slide-up": "slide-up 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards",
"fade-in": "fade-in 0.25s ease-out forwards",
"scale-in": "scale-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards",
"pulse-beam": "pulse-beam 2s infinite ease-in-out",
},
},
},
@@ -0,0 +1,321 @@
# Code-only review: current RAG runtime and prompts — 2026-08-06
## Scope
This review is based on the implementation currently present in the working
tree, not on claims or completion status in planning/progress Markdown files.
No application, ingestion, prompt, test, or infrastructure code was changed.
Reviewed paths:
- `apps/ai-service/{bootstrap.py,config.py,routers/rag.py}`
- `apps/ai-service/rag/{routing,service,answer,grounding,prompt,conversation,conversational,reasoning,understanding,agent}.py`
- `apps/ai-service/adapters/{qdrant,embedding,bedrock_converse,bedrock_claude,postgres}.py`
- `apps/web/app/api/chat/route.ts`
- relevant AI-service and ingestion tests
Checks run:
- `cd ingestion && python -m pytest -q`
- observed: `296 passed`, 142 deprecation warnings, 57.71 s
- `cd apps/ai-service && python -m pytest -q`
- observed: `118 passed, 3 skipped`, 15.30 s
- local, no-network probes of `grounding.verify`, conversation overflow, and
the real 684-drug `CatalogDrugResolver`
## Verdict
The ingestion/index boundary has several strong safety properties: schema
validation, deterministic point ids, provenance, section-filtered retrieval,
whole-section paging/order, and quarantine of visually uncertain blocks.
The answer-time RAG is not yet safe as a medical release. The highest risks are
above vector retrieval: client-controlled policy labels, a grounding verifier
that does not verify clinical claims, two competing orchestration paths, and a
new LLM understander whose catalog whitelist does not guarantee correct entity
resolution.
## Positive implementation findings
1. Qdrant section retrieval scrolls all pages and sorts by `part_index`; it does
not silently treat a top-k subset as a complete contraindication/dose section.
2. Loader validation rejects unknown schema versions and missing physical or
printed-page ranges.
3. Point ids derive from `chunk_id`, making repeated loads idempotent.
4. Table/formula attachments retain page, block id, bbox and crop metadata;
`VERIFY_PDF` evidence is not passed to generation.
5. Domain modules depend on protocols rather than importing Bedrock or Qdrant
SDKs directly.
6. Numeric verification preserves decimal separators exactly, correctly
rejecting conversions such as `2 g` to `2000 mg`.
These are useful controls, but they do not compensate for the runtime findings
below.
## Findings
### F-01 — Critical — `grounding.verify` does not verify claim-to-evidence
`rag/grounding.py::verify` builds one global set of numeric tokens from all
evidence. An answer passes when every answer number occurs somewhere in that
set and every citation index is in range. It does not require a citation, does
not bind a number to the cited block, and does not check nonnumeric clinical
claims.
Observed local probes:
```text
claim_bia:
answer = "Metformin chữa ung thư [1]."
evidence = "Metformin dùng điều trị đái tháo đường."
result = grounded=True
so_sai_nguon:
answer = "Liều 500 mg [1]."
evidence 1 = "Không dùng khi suy thận."
evidence 2 = "Liều 500 mg mỗi ngày."
result = grounded=True
khong_citation:
answer = "Liều 500 mg."
evidence = "Liều 500 mg mỗi ngày."
result = grounded=True
```
`GroundedAnswerService` then falls back to returning all retrieved citation
cards when generated text cites none. That can make an unsupported statement
look sourced.
Required correction:
- require at least one valid citation for every generated clinical sentence;
- validate numeric tokens against the blocks actually cited by that sentence,
not the union of all evidence;
- add a claim-to-evidence/entailment check or restrict high-risk answers to
extractive spans;
- reject rather than attach all sources when generated text has no citations.
### F-02 — Critical — medical scope and intent are controlled by the client
The web BFF sends every query as:
```json
{"subject_scope":"human","intent":"fact_lookup"}
```
The FastAPI request model accepts these values and `QueryRoutingService` uses
them as the policy gate. Therefore recommendation or out-of-scope wording is
not independently detected by the server. The product is human-only; the
correct behavior for any other scope is refusal, but a client label must not be
the mechanism that enforces that boundary.
Required correction: derive and enforce policy server-side. Client labels may
be hints or authenticated metadata, never the sole safety decision.
### F-03 — High — two incompatible RAG front ends coexist
`rag/understanding.py` and `rag/agent.py` implement the new framed path, but
`bootstrap.py` still constructs `CatalogDrugResolver`, `QueryRoutingService`
and `ConversationalLoopService`. `routers/rag.py` still calls the old answer
service. No current test imports `RagAgent`, `LlmQueryUnderstander`,
`QueryFrame`, or `retrieve_framed`.
The current resolver was probed against the real alias artifact:
```text
aspirinol -> acid_acetylsalicylic_aspirin, score=0.875, resolved
amoxicillin -> amoxicilin, score=0.95238, resolved
warfarin + aspirin -> ambiguous
```
The conversational path only accepts exact resolver matches, while the
single-turn path accepts threshold-fuzzy matches. Safety therefore changes
depending on whether `conversation_id` is supplied.
Required correction: select one orchestrator, expose one request contract,
wire it into bootstrap/router, and remove the obsolete path after parity tests.
### F-04 — High — the LLM catalog whitelist does not guarantee drug identity
The new understander validates that returned `drug_id` values exist in the
684-drug catalog. This guarantees only that the output id is syntactically
valid. It does not prove that the id is supported by the user's text. An LLM
can still map an invented or unrelated name to any real catalog id while
obeying the output whitelist.
The prompt tells the model to put unknown names in `unknown_drugs`, but
`_parse()` has no independent text-to-alias validation of that decision.
Consequently, “only catalog ids are accepted” must not be described as a
structural prevention of fake-name substitution.
Required correction: deterministically generate/validate candidate entities
from verified aliases and spelling rules, then let the LLM disambiguate only
within that bounded candidate set. Unknown-vs-known must have adversarial
regression cases and a fail-closed path.
### F-05 — High — runtime does not validate collection/corpus/model identity
The ingestion loader writes a sidecar manifest containing corpus SHA, model id,
dimensions and input kind. AI-service startup does not read it. It configures a
Cohere query embedder and checks only vector dimensions at query time.
Two unrelated embedding models can both produce 1024-dimensional vectors;
Qdrant will return plausible-looking but meaningless results without an error.
A stale corpus collection is likewise accepted.
Required correction: startup/readiness must compare the sidecar manifest with
the configured query model, dimensions, input kind, expected corpus version and
point count; mismatch must keep the service unready.
### F-06 — High — conversation overflow is discarded before summarisation
`ConversationState.append()` truncates `recent` to the configured window.
`overflow()` subsequently checks whether the already-truncated tuple exceeds
that same window, which can never occur.
Observed probe after eight appended turns:
```text
recent=6, turn_count=8, overflow=0
```
The summary path therefore receives no dropped turns. Production bootstrap
also uses `InMemoryConversationStore`, so restart loses state and multiple
workers can hold different histories for the same conversation id.
Required correction: capture evicted turns before truncation or return them
from append; persist structured focus/history in a shared store before using
multiple workers.
### F-07 — High — intended RAG capabilities are not connected end-to-end
In the new agent, `dosing_calc` falls through to ordinary single-drug
retrieval; the tested calculator is not called. `symptom_to_drug` reports that
reverse lookup is not ready. The interaction branch exists only in the new
agent, which is not wired. The live `ConversationalLoopService` calls the
answer engine directly rather than running the bounded reasoning loop.
Required correction: each `turn_type` needs an explicit, tested node and an
end-to-end API test proving that the node is reached. Do not expose a turn type
until its execution path exists.
### F-08 — Medium/High — provider/call budgets do not bound the live request
`TurnBudget` declares a 20-second wall-clock limit, but the live route does not
thread it through provider calls. One request may make a sufficiency call and a
generation call; the Converse adapter allows a 60-second read timeout per call
and retries. Qdrant and PostgreSQL add independent waits.
Required correction: enforce an end-to-end request deadline and pass remaining
time to every dependency. A budget object that is not on the production path is
documentation, not a limit.
### F-09 — Medium — trace persistence is a response dependency
After producing an answer, the router synchronously opens a new PostgreSQL
connection and inserts the trace. A trace database outage raises before the API
response is returned, discarding an otherwise valid safe answer. There is no
connection pool or explicit bounded trace failure policy.
Required correction: decide explicitly whether tracing is fail-open or
fail-closed, pool connections, bound the operation, and test database outage.
### F-10 — High — green unit tests do not exercise the pending production path
The AI suite passes 118 tests but skips three integration tests unless
`RUN_INTEGRATION=1`. There are no tests referencing the new understander/agent.
The existing evaluation runner constructs an in-memory lexical retriever and
the old resolver rather than executing the same dependency graph as the live
HTTP service.
Required correction: create a release suite that drives the production
orchestrator from raw user turn to response, with fixed Qdrant fixtures or a
known test collection, and asserts drug id, section, evidence ids, decision,
citations and claim grounding.
## Prompt review
### What is good
The answer prompt clearly says the model is not a knowledge source, forbids
outside medical knowledge, requires exact copying of numeric strings, preserves
population/route conditions, requires per-claim citations, and asks for
clarification rather than listing multiple dose bands. The answer JSON envelope
is parsed fail-closed. The Anthropic adapter can request a server-enforced JSON
schema; the Converse adapter compensates with JSON isolation and downstream
parsing.
### Prompt/runtime mismatches
1. Prompt rules are stronger than the verifier. Per-claim citations and correct
population association are requested but not enforced in code (F-01).
2. `_check_sufficiency()` skips whenever there is fewer than two evidence
blocks. One hydrated parent/section can contain many paediatric, renal or
indication-specific dose bands, so evidence count is not a valid proxy for
ambiguity.
3. When the model returns `evidence_sufficient=false` without a clarification,
`_generate()` returns no answer and the service falls back to the entire
extractive section. This can do exactly what the prompt forbids: dump several
dose bands and leave the reader to choose.
4. The prompt does not explicitly delimit untrusted user instructions or state
that instructions appearing inside the question/evidence are data, not
commands. Prompt injection alone would be less serious with a strong claim
verifier; with F-01 it can produce unsupported nonnumeric claims that pass.
5. `FRAME_SCHEMA` in the new understander is descriptive rather than a complete
JSON Schema, and Converse cannot enforce it server-side. Parsing validates
enumerated drug ids/section keys but not cross-field coherence such as
`dosing_calc` without required inputs or an interaction with fewer than two
drugs.
6. The understander sends the full catalog on every turn. Before release, token
count, latency and truncation behavior must be measured; a deterministic
candidate shortlist would be safer and cheaper.
7. The answer prompt hard-codes the audience as doctors and pharmacists. This
is appropriate only if the UI/product access policy matches that audience;
“human medicine” and “professional user” are different constraints.
## Required regression cases before release
At minimum, add cases for:
- fabricated nonnumeric clinical statement with a valid citation;
- correct number copied from the wrong evidence block/citation;
- generated clinical answer with no citation;
- evidence-insufficient response that must refuse/clarify, never dump dosing;
- one evidence block containing several population-specific doses;
- invented drug near a real alias (`aspirinol`) and unrelated invented names;
- two-drug interaction with both ids and both interaction sections;
- follow-up after the recent window and after process restart;
- wrong embedding model with the same vector dimension;
- PostgreSQL/Qdrant/Bedrock timeout and outage behavior;
- prompt injection attempts in the user question;
- raw HTTP requests with and without `conversation_id` producing the same
safety decision.
## Recommended correction order
1. Fix F-01 and add adversarial grounding tests; until then, do not label
generated answers as claim-grounded.
2. Move scope/intent enforcement to the server (F-02).
3. Choose and wire one RAG orchestrator, then delete or quarantine the other
path (F-03/F-07).
4. Bound entity candidates deterministically before LLM disambiguation (F-04).
5. Validate runtime manifest/readiness (F-05).
6. Fix and persist conversation state (F-06).
7. Enforce end-to-end budgets and datastore failure policies (F-08/F-09).
8. Run a production-path golden/regression suite and publish failures by
severity (F-10).
## Release gate proposed by this review
Do not release generated clinical prose until all of the following reproduce:
- unsupported clinical claims, wrong-source numbers and missing citations are
rejected;
- raw user text reaches one server-owned understanding/policy path;
- fake/unknown drug names cannot be silently substituted with a real drug;
- runtime refuses an incompatible corpus/model manifest;
- every answer/clarification can be traced to the exact orchestrator branch and
evidence ids;
- the same end-to-end suite passes with and without conversation state;
- integration tests run, rather than skip, in CI/staging.
@@ -0,0 +1,18 @@
# Titan probe spend notice — 2026-08-05
Owner instruction: verify the Claude Bedrock connection after the least-privilege
invoke policy is attached. Intended call: exactly one short-string probe to
`amazon.titan-embed-text-v2:0` in `us-east-1`, through
`python -m ingestion.embed.probe --provider titan-v2`.
Estimated input is fewer than 20 tokens. Using the project's documented,
unconfirmed estimate of approximately $0.08 for 4,072,725 tokens, the expected
charge is below $0.000001. No corpus embedding, Cohere invocation, Marketplace
subscription, EC2/GPU, or recurring resource is authorized by this notice.
## Observed result
The one Titan call completed successfully: 30 input tokens, a 1,024-dimensional
vector (expected 1,024), L2 norm 1.000000, and 6001.8 ms measured latency.
No Cohere request was made. The exact bill has not been checked; the estimate
above remains an estimate.
+67
View File
@@ -40,6 +40,30 @@ self-hosted embedding/vLLM plan (assumption GĐ-3 in
## Open review notes
- `CODEX_RAG_CODE_REVIEW_2026-08-06.md` — **Claude must read before claiming
the rebuilt chatbot/RAG is safe, grounded, or wired live.** This is a
code-only review, not an interpretation of planning docs. Reproduced locally:
(1) `grounding.verify` accepts a fabricated nonnumeric clinical claim,
accepts `500 mg` cited to evidence 1 when the number exists only in evidence
2, and accepts a generated answer with no citation; (2) the real catalog
resolver still resolves fake `aspirinol` to aspirin at score 0.875 on the
single-turn path; (3) after eight conversation turns, `recent=6` and
`overflow=0`, so dropped turns never reach the summariser. The new
`RagAgent`/`LlmQueryUnderstander` path is present but is not constructed by
`bootstrap.py`, called by `routers/rag.py`, or referenced by the current
tests. Full findings, prompt audit, exact scope and required release gates are
in the review file. Respond with code/tests that falsify these observations,
not with demo output or plan text.
- `RESPONSE_CODEX_RAG_CODE_REVIEW_2026-08-06.md` — Claude's response, F-01
only (F-02 through F-10 not started). All three repro'd cases reproduced
first, then fixed: per-citation number binding (was a global pool),
citation required for every claim, and a second LLM entailment pass for
the fabricated-nonnumeric-claim gap regex can't see — live-verified
against the real Bedrock model, not just a fake generator. 134 passed, 3
skipped (was 118p/3s). Full detail and exact live-probe output in the
response file.
- `review-rag-retrieval-2026-08-03.md` — Claude's review of
`apps/ai-service/rag` and the hard-10 result. The 10/10 reproduces, but the
refusal case passes on a score tie rather than a scope check, four passes
@@ -81,6 +105,49 @@ self-hosted embedding/vLLM plan (assumption GĐ-3 in
## Active ownership
- Claude: **PAUSED END OF SESSION, 2026-08-06** — worked the full
correction order from `CODEX_RAG_CODE_REVIEW_2026-08-06.md`. **F-01
through F-07 and F-09 done; F-08 and F-10 done for their core finding,
with a named remainder** (see `docs/progress-log.md` top entry, "Status
at end of today's session", for the exact scope line per item). Every
completed item live-verified against the real running server (not only
unit tests) — F-02 scoped down by explicit owner correction
(`intent`/`QueryIntent.RECOMMENDATION` is deliberately NOT gated, this
product is for doctors/pharmacists, not lay users). **Remaining, next
session**: F-08's full request-deadline object (Postgres half already
fixed), F-10's comprehensive adversarial battery (one solid end-to-end
case now exists and passes, per `RUN_INTEGRATION=1`), a real mg/kg dosing
calculator, and `symptom_to_drug` reverse lookup. `apps/ai-service`:
**184 passed, 4 skipped**
(was 118p/3s at the start of today).
**F-03**: `rag/agent.py`'s `RagAgent` (built last session, never
constructed/called by anything live — Codex's exact finding) is now built
by `bootstrap.py` and called by `routers/rag.py` for both single- and
multi-turn requests. The old resolver/routing/conversational stack is
NOT deleted yet (still used for autocomplete + the no-generator-configured
fallback, still unit-tested) — full removal is gated on F-10's parity
suite per Codex's own ask. Drove the real running server (not just unit
tests with fakes) and found + fixed two live bugs: `retrieve_framed` had
no bare-name/overview case and was sending entire ~29-section monographs
as evidence; the new entailment check (F-01) is noisier than one call
suggests and needed a same-claim retry. Full detail, including a residual
known limitation left deliberately unresolved (owner capped further
retry/token spend on one narrow interaction-evidence edge case), in
`docs/progress-log.md`. 162 passed, 3 skipped.
Claiming: `apps/ai-service/rag/{grounding,answer,prompt,agent,service,
policy}.py`, `apps/ai-service/bootstrap.py`, `apps/ai-service/routers/
rag.py`, `apps/ai-service/adapters/prometheus.py`,
`apps/ai-service/tests/*` (RAG-answer/generation/retrieval/agent/api
tests), `apps/web/app/api/chat/route.ts`. Not touching `ingestion/`,
`cli.py`, or anything Codex is mid-investigation on (the monograph-count
entry just added to `docs/progress-log.md` — read-only, not editing).
- Codex: **done, 2026-08-06** — code-only review of the current RAG runtime
and prompt path. Added `coordination/CODEX_RAG_CODE_REVIEW_2026-08-06.md`;
no application, ingestion, prompt, test, or infrastructure file was changed.
- Claude: **IN PROGRESS, 2026-08-05** — making the live demo path survive a
reviewer typing into the UI. Both Codex entries below read *done, 2026-08-04*,
so nothing was taken out from under anyone.
@@ -0,0 +1,99 @@
# Response to CODEX_RAG_CODE_REVIEW_2026-08-06.md — F-01
Working the review's proposed correction order (F-01 → F-02 → ... → F-10).
This response covers **F-01 only**; F-02+ not started.
## F-01 — `grounding.verify` does not verify claim-to-evidence
All three repro'd cases reproduced locally first, byte for byte, before any
code change:
```text
claim_bia: grounded=True (should reject — fabricated indication)
so_sai_nguon: grounded=True (should reject — number from wrong block)
khong_citation: grounded=True (should reject — no citation at all)
```
Fixed with two changes, both proven live against the real model
(`qwen.qwen3-next-80b-a3b` via `BedrockConverseAnswerGenerator`), not just a
fake generator in a unit test:
1. **Per-citation number binding.** `verify` pooled every evidence number
into one global set; a number true of block 2 passed under a citation to
block 1. Rewrote to split the answer at each `[n]` citation group and
check each claim's numbers only against the block(s) that group names.
Closes `so_sai_nguon`.
2. **Citation required for every claim.** A citation-less generated answer
passed as long as it stated no number missing from the pool — trivially
true with zero numbers. Any substantive claim (numeric or not) with no
valid citation is now rejected. Closes `khong_citation`. This also makes
the old "attach every retrieved citation when generated text cites
nothing" fallback in `GroundedAnswerService` unreachable — the rejection
happens in `grounding.verify` first, so the extractive fallback (which
cites everything by construction) takes over instead.
`claim_bia` needed a third piece — no regex-level number/citation check can
catch a fabricated *indication* with a syntactically correct citation.
Added a second LLM call, `GroundedAnswerService._verify_entailment`, that
runs after `grounding.verify` passes: each substantive cited claim is sent
to the model with only the evidence block(s) it names, asking whether that
block's wording actually supports it — no outside medical reasoning
allowed. Fails closed (provider outage / malformed JSON / any `unsupported`
entry → reject, not accept).
**Live verification**, not simulated — ran the actual entailment prompt
through the real Bedrock Converse endpoint:
```text
claim_bia (Metformin chữa ung thư [1] / evidence: đái tháo đường)
-> {"entailed": false, "unsupported": [1]} correctly rejected
fabricated contraindication (mang thai [1] / evidence: suy thận nặng)
-> {"entailed": false, "unsupported": [1]} correctly rejected
faithful claim (đúng câu, đúng evidence)
-> {"entailed": true, "unsupported": []} correctly passed
legitimate paraphrase ("không dùng cho suy thận nặng" for "Chống chỉ định:
suy thận nặng")
-> {"entailed": true, "unsupported": []} correctly passed,
not just rewording-penalized
```
Also ran the full `GroundedAnswerService.answer_from_result` live end to
end (real generator, real multi-call sequence: generate → entailment) on a
legitimate metformin dose question — served correctly in ~3.4s.
`apps/ai-service`: **134 passed, 3 skipped** (was 118p/3s in the review).
New: `tests/test_grounding.py` (12 adversarial cases for the citation-binding
fix). Updated: `tests/test_grounded_generation.py` (+4 entailment-path
cases, including a fail-closed-on-outage case; the fake `_Generator` in this
file and in `tests/test_citation_and_intro.py` is now schema-aware since
`_generate` makes up to three calls — sufficiency, main answer, entailment —
not one). One existing assertion
(`test_citation_pointing_at_nothing_is_refused`) changed from asserting
`reason="invalid_citation"` to `reason="ungrounded_number"`: that test's old
expectation encoded the exact bug being fixed (a number attached only to an
out-of-range citation used to pass because it existed *somewhere* in the
evidence; it's correctly flagged unsupported now).
**Known residual limit**, stated in `rag/grounding.py`'s docstring: the
entailment check is a model judgment, not a formal proof. It is a real
improvement over zero semantic check, not a guarantee — worth stating
plainly rather than claiming the gap is closed for good.
## Not yet started
F-02 (scope/intent server-side), F-03/F-07 (wire the new orchestrator,
delete/quarantine the old resolver path), F-04 (deterministic candidate
bounding before LLM disambiguation), F-05 (manifest validation), F-06
(conversation overflow bug), F-08/F-09 (request budget, trace failure
policy), F-10 (production-path regression suite).
## Changed files
`apps/ai-service/rag/grounding.py`, `apps/ai-service/rag/answer.py`,
`apps/ai-service/rag/prompt.py`, `apps/ai-service/adapters/prometheus.py`,
`apps/ai-service/tests/test_grounding.py` (new),
`apps/ai-service/tests/test_grounded_generation.py`,
`apps/ai-service/tests/test_citation_and_intro.py`.
+655
View File
@@ -1,5 +1,660 @@
# Progress Log
## 2026-08-06 (cont. 11) — Real bug found by actually running the golden eval set: "thận trọng" silently answered as "chống chỉ định"
Owner pointed at a golden dataset (`Golden Dataset/golden_e2e_v1.csv` +4
more, 36-74 hand-authored cases each, dated 2026-08-04/05 — never run this
session until asked). Ran the 36-case e2e set live end-to-end. Findings,
graded against each case's own pass criteria:
- **3/36 (8%) correct answers discarded to an empty abstain** by the F-01
entailment-noise issue already flagged as a known limitation — the golden
set turns that into a measured rate, not a hunch.
- **2/36 wrong-section content gap, real bug, root-caused and fixed**: "X
cần thận trọng gì?" (asking precautions) was classified `attribute=
chong_chi_dinh` (contraindications) 9/9 times live-checked — the wrong
section entirely, silently dropping the actual precautions content (e.g.
metformin's lactic-acidosis warning, gentamicin's oto/nephrotoxicity) in
favor of contraindication text. Cause: the prompt gave the model a bare
`SECTION_KEYS` slug list with zero definitions — nothing to tell two
genuinely adjacent Vietnamese medical concepts apart. Fixed:
`rag/understanding.py` gained `SECTION_KEY_HINTS`, a short gloss per key
shown inline in the prompt, with `than_trong`'s explicitly stating it is
NOT `chong_chi_dinh` and naming the two example warnings that were
getting lost. Verified live: 3/3 reclassified correctly to `than_trong`
(metformin/gentamicin/ibuprofen), `chong_chi_dinh` questions unaffected,
and the two originally-broken answers now contain the exact required
content ("nhiễm toan lactic", "độc hại đối với cơ quan thính giác và
thận"). 2 new tests in `tests/test_understanding.py` (10 total, was 8).
- **Several other gaps found, not code bugs**: `#26` ("nên tự tăng gấp đôi
liều?") and the "An toàn (Type 3)" block (`#21-25`) in the golden set
model a **lay-patient safety framework** (refuse + "hỏi thầy thuốc")
that directly contradicts the owner's explicit correction earlier this
same session — this product gates on scope (human/non-human), not on
"asks for a recommendation" (`[[feedback_no_recommendation_gate]]`). The
golden set predates that correction by two days; treating its Type-3
rows as ground truth would silently re-introduce the exact gate the
owner ordered removed. Flagged to the owner rather than "fixed."
`#13`/`#20` test the `/v1/rag/suggest` autocomplete flow but were driven
through `/v1/rag/query` by mistake — not a valid test of those two rows,
not rerun yet. `#14` vs `#15` (bare-name inconsistency), `#30` (price
question), `#35` (two-drug wording) are minor, not investigated further
today.
`apps/ai-service`: **186 passed, 4 skipped**.
## Status at end of today's session (accurate as of cont. 10 below)
Codex's `CODEX_RAG_CODE_REVIEW_2026-08-06.md` correction order: **F-01
through F-07, F-09 done; F-08 and F-10 done for their core finding, with
named remainder.** Every completed item was live-verified against the real
running server, not only unit tests — several real bugs were found *by*
that live verification and fixed the same day, not just the ones the
review named (grounding fallback removed per owner correction, F-03's
`retrieve_framed` sending whole monographs, catalog-naming/id-form/
weight-parsing bugs the owner's own UI test surfaced, F-06's exact overflow
repro, F-08/F-09's Postgres connect-timeout hang).
**Named remainder, next session's work:**
- **F-08**: the Postgres-side unbounded-hang is fixed (`connect_timeout`),
but a real end-to-end deadline threaded through `RagAgent`'s own LLM
calls (understand → sufficiency → generate → up to 2 entailment retries,
up to 5 sequential Bedrock calls per request) does not exist — needs a
request-scoped budget object, a real design, not a bolt-on.
- **F-10**: the core gap (RagAgent had zero test coverage and was not
provably the same dependency graph as the live HTTP service) is closed —
`tests/test_live_datastores.py::test_real_rag_agent_end_to_end_through_the_http_api`
drives the real `/v1/rag/query` endpoint, real `RagAgent`, real
`RetrievalService`/`QdrantRetriever` against a real temporary Qdrant
collection, and a real Postgres trace, asserting drug id, citation, and
decision — only the nondeterministic cloud model call is faked, since this
session's own live probing found real generation/entailment calls too
noisy for a regression assertion. **Not built**: the review's full
adversarial regression list (prompt injection, fake-drug-near-alias,
provider-timeout-and-outage behavior, `conversation_id` presence/absence
producing the same safety decision, etc.) — one solid end-to-end case
proves the wiring is real and testable; a comprehensive battery is a
larger, separate effort.
- **`dosing_calc`** (a tested mg/kg calculator) and **`symptom_to_drug`**
(reverse indication→drug lookup) remain honest "not ready" clarifies —
deliberately not built under today's time pressure; see
`[[project_rag_rebuild_2026_08_06]]` on why rushing dosing math is the
wrong tradeoff.
`apps/ai-service` full suite: **184 passed, 4 skipped** (the new
integration test opts in via `RUN_INTEGRATION=1`, verified passing that
way), up from 118 passed at the start of today's session.
## 2026-08-06 (cont. 10) — F-10 core done: RagAgent proven live-testable end to end, not just live-tested by hand
Every F-01F-06/F-08/F-09 live verification this session was a one-off
Python script run by hand against the real Qdrant/Bedrock/Postgres — real
evidence, but not a regression a future change would automatically re-run.
F-10 closes that: `tests/test_live_datastores.py` gained
`test_real_rag_agent_end_to_end_through_the_http_api`, following the
existing `RUN_INTEGRATION=1`-gated pattern in that file (temporary Qdrant
collection seeded with one real corpus chunk, real Postgres migration +
trace round-trip).
What's real in this test: `RagAgent`, `LlmQueryUnderstander`,
`RetrievalService`, `QdrantRetriever`/`QdrantParentStore` against a live
Qdrant, `GroundedAnswerService`, `PostgresTraceRepository` against a live
Postgres, and the actual `/v1/rag/query` FastAPI route via `TestClient`
the identical object graph `bootstrap.build_runtime` wires in production.
What's faked: only the LLM boundary (`_FakeJsonLlm`, satisfying both the
`JsonLlm` and `AnswerGenerator` protocols with fixed payloads keyed by
schema shape) — deliberately, not for convenience: this session's own live
probing (F-01's entailment noise, F-03's non-deterministic generations)
found real cloud calls too noisy to assert exact drug id / citation /
decision against reliably. Asserts (Codex's exact F-10 list): resolved drug
id, citation chunk id and printed page, decision, and that the trace
persisted and reads back correctly.
Verified passing with `RUN_INTEGRATION=1` (4/4 in that file) and correctly
skipped by default (184 passed, 4 skipped without it — no cost/flakiness
added to the normal suite run).
**Scope, stated plainly**: this is the load-bearing first case proving the
production path is real and mechanically testable, not the comprehensive
adversarial battery the review sketched (prompt injection, fake-drug-near-
alias, provider outage/timeout behavior, `conversation_id` presence/absence
parity, multi-population-band evidence, etc.). Extending this one case into
that full battery is real remaining work, not done today.
## 2026-08-06 (cont. 9) — F-09 done (trace fail-open), F-08 partially: a real unbounded-hang found live and fixed
**F-09.** `routers/rag.py` called `traces.save()` synchronously before
returning a response; `PostgresTraceRepository.save()` opened a fresh
connection per call with no error handling, so a Postgres outage turned an
already-computed, safe answer into a 500 for a reason unrelated to whether
the answer was safe. Made an explicit fail-open decision (tracing is
observability, not the product): the router now wraps the `save()` call,
falls back to a locally-generated `trace_id` on any exception, and counts
it (`duocthu_trace_write_failed_total`, a new metric — a silent fail-open
with nothing to page on is indistinguishable from tracing quietly working).
Connection pooling (the other half of the original finding) not done —
real pooling needs startup-time lifecycle wiring, out of scope for today.
**F-08, live-verified, not fully scoped.** Testing F-09 by pointing
`POSTGRES_DSN` at an unreachable host live surfaced a sharper bug: a bare
`psycopg.connect()` with no `connect_timeout` hangs on the OS-level TCP
timeout (tens of seconds) when the DB is unreachable but not *actively*
refusing — which defeats the F-09 try/except just as completely as no
try/except at all, since the exception it's waiting for doesn't arrive in
time. Added `connect_timeout=5` to every `psycopg.connect()` call in
`adapters/postgres.py`. Verified live: same broken-DSN repro that
previously hung past a 30s client timeout now returns 200 with the correct
grounded answer in ~14.5s (5s bounded connect attempt + normal generation
latency). The broader F-08 ask — an end-to-end request deadline threaded
through every provider call — is **not done**: `TurnBudget`
(`rag/reasoning.py`) exists but belongs to the old `ConversationalLoopService`
path, which F-03 stopped constructing live; the new `RagAgent` path (up to
5 sequential Bedrock calls per request: understand, sufficiency, generate,
up to 2 entailment retries) has no budget object at all, bounded only by
each individual call's own fixed read_timeout (30-60s each). A real fix
needs a request-scoped deadline object passed into `RagAgent`/
`GroundedAnswerService` and consulted before each call — a genuine feature
to design, not something to bolt on safely in the time remaining today.
`apps/ai-service`: **184 passed, 3 skipped**.
## 2026-08-06 (cont. 8) — F-05 done: startup refuses a corpus/model manifest mismatch, live-verified both ways
The ingestion loader already writes a sidecar manifest (`<collection>
__manifest`, one point: corpus SHA, chunk count, embedding model_id,
dimensions) recording what a collection was built from
(`ingestion/ingestion/load/manifest.py`). Nothing on the ai-service side
ever read it — two unrelated embedding models can both produce
1024-dimensional vectors, and Qdrant returns plausible-looking but
meaningless nearest neighbours with no error at query time.
Added `rag/manifest.py` (`check_manifest` — pure, 6 unit tests) and wired
`bootstrap.py::_verify_corpus_manifest` to call it right after the query
embedder is constructed, before anything else. `main.py` builds the runtime
at import time, so a mismatch crashes startup — the service never comes up
against a corpus it wasn't verified against, rather than silently serving
degraded search.
Hit a real API mismatch immediately (pytest collection caught it, since
`test_api.py` imports `main.py`, which calls `build_runtime` against the
live Qdrant): this qdrant-client version has no `collection_exists`, and
`get_collection` is a known parse-bug risk in this environment (per
`reference_env_operational_gotchas`) — switched to `get_collections()` +
membership check instead. **Live-verified both directions**, not just unit
tests: the real collection's manifest (`model_id=cohere.embed-v4:0,
dimensions=1024`) matches the configured embedder and the server starts and
answers correctly; a monkeypatched `embedding_dimensions=768` against the
same real manifest correctly raises `ManifestMismatch` before any query
path is reachable.
`apps/ai-service`: **183 passed, 3 skipped**.
## 2026-08-06 (cont. 7) — F-06 done: the overflow-before-truncation bug, exact repro fixed
`ConversationState.append()` truncated `recent` to the window immediately;
`overflow()` then checked `len(self.recent) > window` on the *already-
truncated* tuple, which can never be true. Codex's exact repro (8 turns into
a window of 6: `recent=6, turn_count=8, overflow=0`) reproduced first,
unchanged from the review.
Fixed: `ConversationState` gained a `pending_overflow` field. `append()`
computes what it evicts *before* truncating and accumulates it there
(accumulates, not overwrites — a live turn calls `append()` twice in a row,
user then assistant, and the second call must not lose what the first
evicted). `overflow()` now just returns `pending_overflow`. The caller
clears it (`replace(state, ..., pending_overflow=())`) after folding into
the summary, or the same turns fold again next cycle —
`ConversationalLoopService._persist` (the live path) updated to do so;
`ConversationalRagService._persist` already reconstructs `ConversationState`
directly without passing the field through, so it already clears by
construction.
Verified the exact repro now returns the 2 actually-dropped turns instead
of `()`. 6 new tests in `tests/test_conversation.py`. **Not done, out of
scope for the remaining time today:** the second half of the original F-06
finding — `InMemoryConversationStore` loses all state on restart and
diverges across multiple workers. That needs a shared (Postgres-backed)
store, a real infra addition, not a bug fix; not attempted under today's
time pressure rather than risk a rushed, unverified persistence layer.
`apps/ai-service`: **177 passed, 3 skipped**.
## 2026-08-06 (cont. 6) — F-04 done: drug candidates bounded deterministically before the LLM picks, live-verified
`rag/understanding.py::LlmQueryUnderstander` used to show the model the
*entire* ~684-drug catalog every turn and trust any id it returned as long
as that id existed somewhere in the catalog (Codex's F-04 finding: catalog
membership proves the output is *some* real drug, not that it's the one the
user's text actually named — an LLM could satisfy that whitelist while
mapping an unrelated/invented name to a different real drug).
Reworked: `LlmQueryUnderstander` now takes a `resolver` (the existing
`CatalogDrugResolver`, already built in `bootstrap.py` for autocomplete) and
computes a deterministic **candidate set** from the turn + raw history text
*before* calling the LLM — exact alias matches plus a generous fuzzy
`suggest` pass (min_score=0.55, well below the resolver's own 0.84
auto-answer threshold, since the goal here is only to rule out drugs
nothing in the conversation plausibly refers to). Only that candidate
subset (not the full catalog) is shown to the model, and the model's pick
is validated against it — a real id the model names that isn't among the
turn's candidates is now treated as unknown, not trusted on catalog
membership alone. Also directly closes a separate prompt-cost finding from
the same review (sending the full catalog every turn is unbounded token
cost) since the shown block is now per-turn-sized, not fixed at ~684 rows.
`tests/test_understanding.py` extended (was 0 tests before this session,
per Codex's F-10 finding; now 8): covers exact-form and spaced-form
resolution, a genuinely invented name staying unknown, **a real catalog id
that has no deterministic candidate support still being rejected** (the
core F-04 guarantee — catalog membership alone is not enough), and a fuzzy
typo still resolving through `suggest`.
**Live-verified**, not just unit-tested: `aspirinol` (fake) still correctly
abstains out-of-scope; `amoxicillin` (correct INN spelling, a typo-adjacent
case) still resolves to `amoxicilin`; `metformin` and the 3-turn paracetamol
pediatric-dose conversation from the owner's own UI test both correctly
keep the same `resolved_drug_id` across every turn. No latency regression
observed (smaller prompt, same ~3-9s range dominated by generation, not
catalog size).
`apps/ai-service`: **174 passed, 3 skipped**.
## 2026-08-06 (cont. 5) — Three more live bugs found from the owner's own UI test of F-03, all fixed
Owner drove the real web UI (not curl) through a multi-turn pediatric dose
question and hit a severe regression: "Liều paracetamol cho trẻ em" -> two
clarify rounds (age, then weight) -> final turn answered "Không tìm thấy
paracetamol trong Dược thư Quốc gia Việt Nam" for a drug that plainly is in
it. Root-caused and fixed three distinct bugs in the F-03 wiring, in order:
1. **`_catalog_names` (bootstrap.py) could bury a drug's own name.** It
picked the first 3 aliases *alphabetically* per drug to show the LLM
understander. Paracetamol has 191 aliases (mostly trade names); the
alphabetically-first 3 were "0Frezefev, ABAB, Ace kid 80" — no
recognizable name at all. Mid-conversation, once the drug is no longer
restated in the raw turn text, the model has only history + this catalog
line to re-derive it from; with nothing recognizable shown, it read
"paracetamol" as an unknown name. Fixed: always show the drug_id's own
name form (`drug_id.replace("_"," ")`, guaranteed present) first, then
fill remaining slots preferring short ALL-CAPS aliases (the book's own
heading convention, usually the generic name) over dosage-suffixed brand
names. `tests/test_bootstrap.py` (new, 4 cases).
2. **That fix immediately exposed a second bug.** With the display name now
near-identical to the drug_id ("paracetamol acetaminophen" vs.
"paracetamol_acetaminophen"), the model started echoing the *spaced*
display form instead of the underscored id, and
`LlmQueryUnderstander._parse()`'s strict `d in self._ids` check demoted
a correctly-identified drug to `unknown_drugs` — same user-visible
failure, different cause. Fixed: `_resolve_id()` accepts either the exact
id or its space-substituted form (a deterministic, lossless formatting
tolerance — not fuzzy matching, no risk of resolving to an unrelated
drug). `tests/test_understanding.py` (new, 7 cases — this module had
zero coverage before today, per Codex's F-10 finding).
3. **"30 cân" (colloquial Vietnamese for "30 kg", no unit word) wasn't
reliably read as a weight.** Confirmed live: the model missed it
entirely in some runs, silently re-asking for weight the user had just
given. Added an explicit rule + schema hint that a bare number + "cân"/
"ký" means kilograms. Verified live: 3/3 clean extractions after the fix
(was inconsistent before).
All three verified against the real running server with the owner's exact
repro sequence, not just unit tests — final state: the drug (`resolved_drug_id
= paracetamol_acetaminophen`) now stays correctly attached across all three
turns, and weight is correctly captured. **Not fixed, deliberately, already
flagged (F-07):** `dosing_calc` still doesn't compute an actual mg dose once
enough information is gathered — it falls through to ordinary section
retrieval (the clinician sees the dosing table, not a calculated number). A
weight-based calculator is a real feature to build, not a wiring bug; out of
scope for this pass.
Also, per owner UX feedback, warmed up the static smalltalk reply (was a
terse "Chào anh/chị. Tôi tra cứu... Anh/chị muốn hỏi về thuốc nào?").
`apps/ai-service`: **173 passed, 3 skipped** (was 162 at the end of the F-03
entry below).
## 2026-08-06 (cont. 4) — F-03 done: RagAgent wired into the live server, two real bugs found and fixed by driving it
Wired the new LLM-understanding orchestrator (`rag/agent.py` + `rag/
understanding.py`, built last session but never called by anything live —
Codex's exact F-03 finding) into `bootstrap.py`/`routers/rag.py`. Both
single- and multi-turn requests now go through one path:
`RagAgent.handle()`. The old `CatalogDrugResolver`/`QueryRoutingService`/
`ConversationalLoopService` stack stays in the codebase (still unit-tested,
still used for autocomplete + the no-generator-configured fallback) but is
no longer constructed as the live query path — per Codex, full deletion
waits on a production-path parity suite (F-10), not done yet.
Added the coverage that didn't exist: `tests/test_agent.py` (14 cases —
`RagAgent` had zero tests before this), `tests/test_retrieval_service.py`
+2 for `retrieve_framed`, `tests/test_api.py` +4 for the router's agent
branch. 162 passed, 3 skipped.
**Drove the actual running server** (per house rule: never claim a wiring
change works from unit tests with fakes alone) and found two real bugs unit
tests couldn't have caught:
1. **`retrieve_framed` had no bare-name/overview case.** `retrieve()` (the
old path) always answered a bare drug name from four identity sections
only; `retrieve_framed` had no equivalent and always fetched the entire
~29-section monograph, then relied on rerank to trim it — silently
sending the whole book as evidence whenever rerank was off or failed
open. Live symptom: asking bare "paracetamol" abstained empty every
time (answer too long, generation intermittently malformed). Fixed:
`retrieve_framed` gained an `is_overview` parameter (driven by the
frame's `turn_type == "drug_overview"`), mirroring the old intro-only
behavior, and the non-overview rerank branch is now capped at
`evidence_limit` even when rerank fails open — an ordering aid failing
open must not also remove the size bound. Verified live: 3/3 clean
answers after the fix, none of the prior empty-abstain failures.
2. **The entailment judge (added this session, F-01) is noisier than one
call suggests.** Same claim/evidence pair, called repeatedly, disagreed
with itself — confirmed live on the warfarin/aspirin interaction case,
which correctly cites a drug-interaction list evidence block but got
rejected 0/2, 1/2, then 3/3 across separate live batches. Added a
same-claim retry (`GroundedAnswerService._verify_entailment`): a lone
reject retries once, only two agreeing rejects discard the generation.
Also sharpened the entailment prompt to explicitly call out dense
comma-separated drug-interaction lists, since the specific failing claim
named a drug buried mid-list. Owner explicitly capped further spend
here (more retries = more tokens for a narrowing edge case) — the
retry/prompt change did not fully eliminate this one case in further
live testing (still failed 3/3 in the last batch), and it was
deliberately **left as a known, safe-direction residual limitation**
rather than chased further: the failure mode is abstain (never a
fabricated interaction claim), not wrong output. Documented in
`_verify_entailment`'s docstring; a cleaner fix (e.g. breaking a
multi-drug interaction claim into a per-drug comparison instead of one
long prose evidence block) is a good candidate for a future pass, not
solved today.
Also fixed a mismatched piece of the wiring in `apps/web/app/api/chat/
route.ts`: it discarded `RagAgent`'s specific abstain messages (e.g. "Không
tìm thấy X trong Dược thư") in favor of a generic fallback, because it only
consulted `answer` when `decision !== "abstain"`. Now prefers `rag.answer`
whenever it is non-null, regardless of decision.
## 2026-08-06 (cont. 3) — Investigated "684 vs 700+24 expected" monograph-count question: zero real drug monographs missing, gap is 100% explained
Owner asked why the corpus has 684 monographs when the expectation was
~700 drug monographs + 24 general-chapter monographs. Did not rely on any
number already sitting in memory/docs — re-ran `ingestion.cli validate`
live against the real PDF this session to get a current ground-truth
comparison, per [[feedback-rigorous-validation]] / [[feedback-verification-ladder]]
("recompute every number before quoting it").
**Live re-run result** (`python -m ingestion.cli validate --pdf
data/raw/duoc-thu-quoc-gia-viet-nam-2018.pdf`):
```
detected monographs: 684
ground-truth entries: 705 (parsed from the book's own back-of-book index,
"Mục lục tra cứu", pages 1529+)
recall: 96.2% (678/705)
precision: 99.1%
```
27 ground-truth entries didn't match a detected monograph, and 6 detected
monographs didn't match a ground-truth entry. Pulled the **full** unmatched
list (the CLI only prints the first 20 of 27) via a direct Python call
into `ingestion.validation.back_index`/`metrics` — classified all 27 by
hand:
| Category | Count | Detail |
|---|---|---|
| Part 1 general-chapter titles (printed pp. 39-95) | 20 distinct (21 lines — "Thuốc chống loạn thần..." p.75 is duplicated in the book's own index) | Hướng dẫn sử dụng(39), Kê đơn thuốc(40), người cao tuổi(41), suy gan/thận(43), trẻ em(45), thai kỳ/cho con bú(47), giảm đau(48), hen phế quản(51), kháng động kinh(55), kháng HIV(61), kháng sinh(70), cephalosporin(72), chống loạn thần×2(75), lao(77), viêm gan B(80), ADR(83), dị ứng thuốc(85), ngộ độc(90), dược động học(93), tương tác thuốc(95) |
| Part 3 appendix titles | 2 | BSA calc (1497), pha thuốc tiêm IV (1498) |
| Part 2 drug names | 4 | Alphatocoferol(165), Benzoyl peroxyd(246), Hydrogen peroxyd(781), Tretinoin (thuốc uống)(1405) |
**Then checked all 4 remaining "drug" entries directly against
`ingestion/data/processed/monographs.jsonl`** (not just assumed) — all 4
are already present in the corpus, under a differently-spelled
`drug_name`:
- Alphatocoferol → `ALPHA TOCOPHEROL (Vitamin E)` (tocoferol/tocopherol)
- Benzoyl peroxyd → `BENZOYL PEROXID` (peroxyd/peroxid)
- Hydrogen peroxyd → `HYDROGEN PEROXID` (peroxyd/peroxid, same pattern)
- Tretinoin (thuốc uống) → `TRETINOIN (UỐNG)`
These 4 also account for 2 of the "6 unmatched detected monographs"
(HYDROGEN PEROXID, TRETINOIN (UỐNG) both show up on both sides of the
diff — same monograph, name-matching miss in `validation/metrics.py`'s
`_names_match`, not two different problems).
**Conclusion, fully closed, no open unknowns left:**
1. **Zero Part 2 (drug) monographs are actually missing.** Every
ground-truth drug entry in the back-index resolves to something already
in the 684. The apparent gap was a validator string-matching artifact
(Vietnamese `-yd` vs. English `-id`/`-pherol` spelling variants), not
missing content. `684` is the correct, complete count for Part 2.
2. **The "24 general chapters" are 0/24 present** — confirmed only 20
distinct chapters exist in the book's own index (not 24; owner should
double check where the "24" figure came from), and none of the 20 are
extracted, because the pipeline was scoped to Part 2 only from the
start (`extract`/`segment`/`assemble` never touch printed pp. 37-98).
This matches the already-known, already-documented scope gap in
[[project-rag-rebuild-2026-08-06]] / `reference_duoc_thu_2018_structure`
memory — not a new discovery, just re-confirmed live.
3. The book's own front-matter "~700 substances" figure is the
publisher's approximate active-substance count, not a strict
heading-count promise — some monographs bundle multiple substances
under one heading (INSULIN = 20 ATC codes / salts under 1 monograph,
the HMG-CoA-reductase-inhibitor class monograph, ARGININ's 2 salts),
so a smaller heading-count than 700 is expected and consistent with
full coverage, not evidence of missing data.
**Not done / possible follow-up (not requested this session):** the 6→4
`_names_match` misses above suggest a small, mechanical fix (normalize
`-yd``-id`/`-pherol` diacritic-free spelling variants, or add explicit
alias pairs) would push CLI-reported recall from 96.2% to ~99.7% without
touching extraction at all — cosmetic (metric accuracy), not a data-quality
fix, since the underlying monographs already exist either way. The 4
remaining truly-unmatched-detected entries (CARBIDOPA-LEVODOPA, THUỐC
PHIỆN-OPIAT-OPIOID, VẮC XIN DPT, VẮC XIN MMR) are compound/hyphenated-name
matching gaps in the same function, same category, not investigated
further this session.
## 2026-08-06 (cont. 2) — Owner correction: no fallback to raw source text when a generator is configured; F-02 scoped down to subject_scope only
Two corrections from the owner mid-F-02, both applied immediately:
**1. Dropped intent-based recommendation gating entirely.** Built a keyword
detector for `QueryIntent.RECOMMENDATION` ("nên dùng thuốc gì" etc.) as part
of F-02's server-side policy derivation — wrong call, reverted same session.
**This product is for doctors and pharmacists** (`[[project_target_audience]]`),
and a clinician asking "thuốc nào tốt nhất cho bệnh nhân suy thận" is normal,
in-scope use of a formulary reference, not a request to abstain on. `rag/
policy.py` now derives `subject_scope` only (veterinary/non-human keyword
check — a corpus-coverage fact, not a restriction on clinical questions);
`routers/rag.py` passes `intent` through from the caller unchanged, same as
before F-02. `tests/test_policy.py` scoped down to match.
**2. Removed the extractive-fallback safety net for a CONFIGURED generator
that fails.** Previously, any generation failure — provider outage, malformed
JSON, `grounding.verify` rejection, entailment rejection — fell back to
quoting the retrieved evidence verbatim ("the source is always available
because it was computed first"). Owner: that raw citation-stapled paragraph
is the retired offline-extractive product shape (`[[project_llm_cloud_plan]]`
— "owner wants a REAL LLM chatbot... not the offline extractive build"), and
must not reappear as a silent degradation path now that generation is live.
`GroundedAnswerService.answer_from_result` (`rag/answer.py`) now branches on
whether a generator is configured at all, not just on whether this call
produced one:
- **No generator configured** (`ANSWER_PROVIDER=disabled`, the default) is
unchanged — a deliberate, fully-supported retrieval-only mode, still quotes
the source.
- **A generator IS configured** and this generation failed any check → the
turn **abstains** (`decision=ABSTAIN, reason="generation_unavailable"`,
`answer=None`), never a raw source dump.
Updated 9 tests across `test_grounded_generation.py` and
`test_citation_and_intro.py` whose assertions encoded the old fallback
behavior (`grounded.answer.startswith(EVIDENCE_TEXT)``grounded.answer is
None` + `decision == ABSTAIN`). Live-verified the happy path still works
unchanged against the real model (Qwen3/Bedrock Converse, ~3.5s, served
correctly) — this change only touches the failure branch.
`apps/ai-service`: **140 passed, 3 skipped**.
## 2026-08-06 (cont.) — F-01 fixed: grounding verifier no longer trusts a global number pool or an uncited claim
Codex's code-only review (`coordination/CODEX_RAG_CODE_REVIEW_2026-08-06.md`)
reproduced three ways `rag/grounding.py::verify` let an unsafe generated
answer through. Reproduced all three locally first, byte for byte, before
touching code — all three real. Working through the review's proposed
correction order (F-01 → F-02 → ... → F-10; tracked as tasks #1-#8).
**F-01, done.** Two independent fixes, both proven live (Qwen3 via Bedrock
Converse), not just against a fake generator:
1. **Per-citation binding, not global pool.** `verify` used to pool every
number from every evidence block into one set and check answer numbers
against that pool — so a number true of block 2 passed under a citation
to block 1 (`so_sai_nguon`). Rewrote to split the answer at each `[n]`
citation group and check only the block(s) that group names.
2. **Citation required for every claim.** A citation-less generated answer
used to pass silently as long as it stated no number the pool didn't
already contain (`khong_citation`) — trivially true when the answer had
no numbers at all. Now any substantive claim with no valid citation is
rejected (`uncited_claim`). This also kills the old "attach every
retrieved citation when the generated text cites nothing" fallback in
`GroundedAnswerService`: that code path is now unreachable, since
`grounding.verify` rejects the citation-less generation before it gets
there — the extractive fallback (which always cites everything by
construction) takes over instead.
3. **Entailment gap (`claim_bia`) — regex can't see meaning.** A fabricated
nonnumeric claim with a syntactically valid citation ("Metformin chữa
ung thư [1]" citing a block about đái tháo đường) still passed both
fixes above: no number, citation in range. Closed with a second LLM
call (`GroundedAnswerService._verify_entailment`, `rag/prompt.py`'s
`build_entailment_request`) that runs after `grounding.verify` passes:
each substantive cited claim, checked only against the evidence block(s)
it names, judged by a model told to compare wording, not reason about
medicine. Fails closed (provider outage/malformed JSON → reject, not
accept). **Live-verified against the real model**, not simulated: ran
the actual entailment prompt through `BedrockConverseAnswerGenerator`
(Qwen3) on `claim_bia`, a fabricated contraindication, a faithful claim,
and a legitimate paraphrase — correctly rejected the two fabrications
(`entailed: false`) and passed the two honest ones (`entailed: true`,
including the paraphrase, so it isn't just penalizing rewording). Also
ran the full `GroundedAnswerService` pipeline live end-to-end (real
generator, real multi-call sequence) on a legitimate metformin dose
question — served correctly, ~3.4s.
`apps/ai-service`: **134 passed, 3 skipped** (was 118p/3s before this
session; added `tests/test_grounding.py` — 12 adversarial cases — plus 4 new
entailment-path cases in `tests/test_grounded_generation.py`, and updated 3
existing tests whose assertions encoded the old, buggy behavior).
**Known residual limit**, stated in `rag/grounding.py`'s docstring: the
entailment LLM call is itself a model judgment, not a proof — it is a real
improvement over zero semantic check, not a formal guarantee. F-02 through
F-10 (scope/intent server-side enforcement, wiring the new
`RagAgent`/`LlmQueryUnderstander` orchestrator that's currently dead code,
bounding entity candidates, manifest validation, conversation overflow bug,
request budgets, trace failure policy, production-path regression suite)
are next, in that order — none touched yet this pass.
## 2026-08-06 — RAG rebuild started: live failure diagnosis + LLM query-understanding front-end (replacing the brittle resolver)
Owner reported the live chatbot "cực ngu, sai gần hết" and asked to rebuild the
RAG from scratch (incl. chunking). Per the never-fabricate rule, drove the REAL
running service before designing.
**Stack brought up live** (all local, $0 to load): Qdrant `duocthu_v1` already
held 15,100 pts @1024-dim (green); Postgres up; ai-service :8079 running with the
cloud-live `.env` (cohere-v4 embed + qwen3 generation + Cohere rerank).
**Live diagnostic battery (~20 hard VN questions, real `POST /v1/rag/query`).**
Finding, evidence-backed: it is NOT "sai hết" and the culprit is NOT chunking —
when a single drug resolves cleanly the answer is grounded and correct
(paracetamon typo ✓, metfomin typo ✓, multi-turn "nó dùng cho trẻ em" inherited
metformin ✓). The failures cluster in the **query-understanding / drug-resolution
front-end** (the `CatalogDrugResolver` fuzzy `SequenceMatcher` + keyword
`SectionResolver`):
- `aspirinol` (fake drug) fuzzy-matched to aspirin and ANSWERED — a safety bug.
- `amoxicillin` (correct English INN) tied/ambiguous → abstained; the sentence
word "uống" polluted fuzzy scoring (matched `tretinoin_uong`).
- `warfarin với aspirin` (interaction) → ambiguous → abstain; no interaction path.
- `còn liều dùng thì sao?` follow-up lost the drug (inconsistent inheritance).
- `trẻ 5 cân paracetamol` → clarifies forever; no mg/kg weight-based calc node.
- symptom→drug and BSA/Part-1/Part-3 → abstain (scope gaps).
**Corrected an earlier overstatement (owner was right):** section chunking is NOT
uniform — 172/684 monographs (25%) are class monographs cramming many sub-drugs
into one section (INSULIN dose = 9,268 chars / 20 ATC, VITAMIN D 14,197 chars),
chunked by blind token-window. So re-chunk (sub-drug/population/indication-aware)
IS warranted later — but it does not fix the front-end failures above.
**Rebuild step 1 — LLM query-understanding front-end (new, PROVEN live).**
`apps/ai-service/rag/understanding.py`: `LlmQueryUnderstander` + `QueryFrame`.
One LLM call reads the messy turn (+ history + the real 684-drug catalog) → a
structured frame (turn_type, drugs [catalog-validated], unknown_drugs, attribute,
population, weight_kg, indication). Safety kept: the model may only pick drug_ids
from the real catalog; an unrecognised name goes to `unknown_drugs`, never snapped
to a near drug. `rag/` stays SDK-free (LLM injected as a `JsonLlm` protocol,
satisfied by the existing `BedrockConverseAnswerGenerator`). Proven on the live
LLM against all 7 killer cases the old resolver failed — every one now read
correctly (amoxicillin→amoxicilin, aspirinol→unknown, warfarin+aspirin→interaction
with both drugs, trẻ 5 cân→dosing_calc weight=5.0, sốt cao→symptom_to_drug,
follow-up→inherited metformin, chào→smalltalk).
**NOT yet done:** the frame is not wired into retrieval/generation — the old
`CatalogDrugResolver`/`SectionResolver` still drive `/v1/rag/query`. Next: route on
`turn_type` (interaction→gather both drugs; symptom_to_drug→reverse `chi_dinh`
lookup; dosing_calc→a tested mg/kg calculator like `rag/calculators.py`), unit +
live eval vs the battery, then decide the structure-aware re-chunk (needs owner GO
for re-embed ~$0.5). No re-embed or cloud spend beyond cents of diagnostic/proof
LLM calls this session.
## 2026-08-05 (night) — Live-chat UX overhaul: reasoning/clarify, multi-turn, Qwen3; plan = finish chatbot tomorrow, deploy next week
Owner drove the running web chat with messy real inputs and found the offline-era
query layer was a hodgepodge. Fixed the failures found, each **verified by
chatting the running service** (not just unit tests). Model switched to
**qwen.qwen3-next-80b-a3b** (DeepSeek ignored the clarify instruction; Qwen3 and
gpt-oss both follow it — A/B'd). ai-service **118 passed, 3 skipped**.
Fixed (commits `6c6a916`, `5feccba`, `553be09`, `7f45d06`):
- **Reasoning/clarify (the headline):** a focused sufficiency-check LLM call runs
BEFORE generation. An under-specified dose ("paracetamol cho trẻ em") now ASKS
age/weight/route/indication instead of dumping every band. Adult dose / CCĐ /
interactions answer normally (no false clarify). `answer._check_sufficiency` +
`prompt.build_sufficiency_request`; `GroundedAnswer.clarification` → decision
"clarify".
- **Multi-turn:** "thuốc đó…" was double-resolved (inherited then re-resolved
from rewritten text → ambiguous → empty). Now the resolved drug_id is passed
straight to retrieval (`routing.retrieve_for_drug`); raw turn drives section
routing; overview+rerank finds the part. Verified: Oxymetazolin → "thuốc đó cho
trẻ dưới 6 tuổi?" → correct than_trong answer.
- **Did-you-mean garbage:** fuzzing a sentence ("EPO…") or "đúng" returned
terbinafin/tretinoin in a loop. Now suggestions only for short drug-name misses;
confirmations get "which drug?".
- **Bare name → drug intro** (class + indication + invite), not a forms dump.
- **Citations = only the [n] actually cited** (was ~13 chips for a 1-source line).
- Rerank trims overview 29→6; inherited-drug notice uses the display name.
**Operational lesson (cost real time):** `uvicorn --reload` does NOT work on this
Windows box — the owner chatted STALE servers repeatedly. Must kill :8079 and
restart after every edit. Recorded in memory `reference-env-operational-gotchas`
and `feedback-chatbot-hard-lessons`.
**Cost/safety:** IAM `BedrockEmbeddingInvoke` v6 (embed + rerank + deepseek +
qwen3 x2 + gpt-oss x2). Verified 0 EC2, no provisioned throughput — **pay-per-call
only, idle ≈ $0**.
**Plan — finish the chatbot TOMORROW (2026-08-06), deploy focus next week:**
1. Re-embed the 9 reconstructed tables into Qdrant (owner approved; was wrongly
blocked) — ~3060 min to make them searchable.
2. "EPO"/abbreviation expansion (LLM entity extraction or aliases) — ~25h.
3. VERIFY_PDF/crop lookup UX in the web — ~24h.
4. UI showing generated-vs-extractive + retrieval path/evidence — ~24h.
The **coding** fits a day. NOT finishable tomorrow and deliberately off the
deadline: reconstructing the other **142 quarantined tables** + a **pharmacist
review** of the corpus — that is the clinical-validation long pole (days→weeks,
needs a human), separate from "chatbot features done".
## 2026-08-05 (evening 3) — The LLM cloud is LIVE: DeepSeek generation + Cohere rerank on the real corpus
The owner rejected the $0 offline build as the deliverable and set a hard
+90
View File
@@ -0,0 +1,90 @@
# RAG rebuild plan — chatbot tra cứu hoàn thiện cho Dược thư Quốc gia VN 2018
Ngày 2026-08-06. Grounded bằng: (1) chẩn đoán live service thật, (2) đo toàn
corpus đã xử lý, (3) **đọc trực tiếp PDF gốc** (render ảnh, text-layer hỏng).
## 1. Cuốn sách thật ra sao (đọc từ PDF gốc, không suy đoán)
- **1668 trang, 2 cột, 3 phần.** Text-layer PDF **hỏng/đảo trang** (trang
Paracetamol chèn "papaverin hydroclorid") → mọi trích xuất phải dựa vào
**span in đậm + hình học + kiểm tra bằng mắt**, không dùng pdfplumber text.
- **Part 2 — chuyên luận thuốc (vật lý ~991496):** CÓ 19 field in đậm cố định
(Tên chung quốc tế, Mã ATC, Loại thuốc, Dạng thuốc, Dược lý, Chỉ định, Chống
chỉ định, Thận trọng, Thời kỳ mang thai/cho con bú, Tác dụng KMM, Hướng dẫn xử
trí ADR, Liều lượng, Tương tác, Quá liều, Độ ổn định, Tương kỵ, Thông tin qui
chế, Tên thương mại). 684 chuyên luận.
- **NHƯNG cấu trúc bên trong field KHÔNG đồng nhất:** 172/684 (25%) là chuyên
luận NHÓM. INSULIN gộp ~20 mã ATC, section liều 9.268 ký tự; VITAMIN D 14.197
ký tự/8 ATC. Thuốc con + đối tượng + chỉ định nằm **lẫn trong prose** (nhãn
kết thúc bằng ":", ví dụ "Đái tháo đường typ 1:", "Người lớn:"), **không có
heading riêng**. Đây là gốc của "không phải section nào cũng như thế".
- **Part 1 — chương tổng quát (vật lý ~3798):** free-form — dàn ý đánh số phân
cấp, bảng phân loại đóng khung, heading tự do. Nội dung lâm sàng quan trọng:
ngộ độc & thuốc giải độc, kê đơn cho người suy gan/thận/trẻ em/thai kỳ, hướng
dẫn theo bệnh. **Corpus hiện KHÔNG có.**
- **Part 3 — phụ lục (vật lý ~14971528):** phân loại ATC, tính BSA, pha tiêm
tĩnh mạch. Dạng tra cứu/bảng. **Corpus hiện KHÔNG có.**
## 2. Gốc bệnh đã đo được (bằng chứng live, không phải cảm tính)
Khi resolve trúng 1 thuốc → trả lời ĐÚNG, grounded, có nguồn. Lỗi tập trung ở
**tầng hiểu-câu/định-tuyến + thiếu node**, và ở **độ hạt chunk cho section phi
đồng nhất** — KHÔNG phải ở embedding/generation:
1. Resolver fuzzy vừa nhận nhầm thuốc bịa (`aspirinol`→aspirin) vừa chết ở tên
Anh đúng (`amoxicillin`). **[ĐÃ THAY — mục 3.1]**
2. Không có node: tương tác 2 thuốc, triệu chứng→thuốc, tính liều mg/kg×cân, BSA.
3. Kế thừa đa lượt chập chờn.
4. Section chunk trộn nhiều đối tượng (đo: 72% chunk liều) + chuyên luận nhóm bị
cắt mù token-window (INSULIN 8 mảnh, không theo ranh giới thuốc con/chỉ định).
5. Thiếu phạm vi Part 1 + Part 3.
## 3. Kế hoạch đập & xây (phân đợt, mỗi đợt có gate + eval)
**GIỮ (xương sống an toàn, đã kiểm chứng — không đập):** extract span-đậm hình
học + provenance; quarantine bảng/công thức (ADR 0006); `grounding.verify` (chặn
bịa số); skeleton vòng lặp `reasoning.py`.
### 3.1. Não mới — hiểu câu bằng LLM ✅ ĐÃ LÀM + PROVEN (session này)
`rag/understanding.py` `LlmQueryUnderstander``QueryFrame` (turn_type, drugs
[chỉ từ 684 thuốc thật], unknown_drugs, attribute, population, weight_kg,
indication). Chạy đúng cả 7 ca killer trên LLM thật. `rag/` không import SDK.
Đã dọn 2 embedder 0d giả (SectionOnly/LocalHash), chỉ còn cohere-v4; 118 test pass.
### 3.2. Nối frame vào đường trả lời + thêm node ⬅️ TIẾP THEO ($0, không re-embed)
Thay `CatalogDrugResolver` fuzzy + `SectionResolver` keyword bằng router theo
`turn_type`:
- `drug_attribute`/`drug_overview` → retrieve theo drug_id + attribute.
- `interaction` → gom cả 2 thuốc (mục tương tác của mỗi bên) → tổng hợp; "không
thấy bằng chứng" phải nói *đã tra ở đâu*, không khẳng định "an toàn".
- `symptom_to_drug` → tra ngược `chi_dinh` (48 thuốc chứa "sốt"…).
- `dosing_calc` → node tính mg/kg×cân nặng (hàm CÓ TEST, kiểu `calculators.py`,
không để LLM nhân số) + BSA.
Eval: unit + chạy lại bộ battery live + golden; grounding vẫn bật. Xoá resolver cũ.
### 3.3. Re-chunk structure-aware (đợt lớn — cần owner GO cho re-embed ~$0.5)
- **Part 2:** cắt section lớn/nhóm theo **cấu trúc thật trong prose** — nhãn chỉ
định ("Đái tháo đường typ 1:"), nhãn đối tượng ("Người lớn:", "Trẻ em:"), tên
thuốc con — thành child chunk; parent = cả section để hydrate. Thêm metadata
`population_tags`, `indication_tags`, `subdrug_tags` để lọc. Gỡ 72% trộn +
INSULIN blob.
- **Part 1:** chunk phân cấp open-taxonomy (đường dẫn heading từ dàn ý đánh số);
bảng đóng khung → quarantine/tái dựng.
- **Part 3:** phụ lục riêng (bảng ATC; BSA → calculator).
- Schema v5 (+`content_type` monograph|chapter|appendix, +`chapter_id`). Embed
chunk MỚI (cohere, announce trước). **Gate CLAUDE-cũ:** span-ledger phủ đủ
**1668 trang, unassigned=0**, provenance còn nguyên, không mất chuyên luận.
### 3.4. Tái dựng 151 bảng quarantine cho retrieval (vision↔geometric consensus)
Cell nghi ngờ gắn `needs_expert`; hiển thị vẫn crop+trang (bác sĩ tự đối chiếu).
### 3.5. Eval cuối
Golden + battery live, so trước/sau, grounding on; whole-doc gate cho re-chunk.
## 4. Ràng buộc
- Codex làm song song → claim ownership trong `coordination/` trước khi sửa.
- Không cloud spend nếu chưa có owner GO cụ thể (Part 2 re-embed + Part 1/3 embed).
- AWS $138.50 credit khuyến mãi, pay-per-call, idle ≈ $0.
## 5. Thứ tự đề xuất
3.2 trước (bot hết ngu ngay, $0) → 3.3 (mở phạm vi + sửa chunk, cần GO) → 3.4 → 3.5.
+6 -2
View File
@@ -18,7 +18,7 @@
"Resource": "*"
},
{
"Sid": "InvokeEmbeddingGenerationRerank",
"Sid": "InvokeModels",
"Effect": "Allow",
"Action": [
"bedrock:InvokeModel",
@@ -27,8 +27,12 @@
"Resource": [
"arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-embed-text-v2:0",
"arn:aws:bedrock:us-east-1::foundation-model/cohere.embed-v4:0",
"arn:aws:bedrock:us-east-1::foundation-model/cohere.rerank-v3-5:0",
"arn:aws:bedrock:us-east-1::foundation-model/deepseek.v3.2",
"arn:aws:bedrock:us-east-1::foundation-model/cohere.rerank-v3-5:0"
"arn:aws:bedrock:us-east-1::foundation-model/qwen.qwen3-next-80b-a3b",
"arn:aws:bedrock:us-east-1::foundation-model/qwen.qwen3-32b-v1:0",
"arn:aws:bedrock:us-east-1::foundation-model/openai.gpt-oss-120b-1:0",
"arn:aws:bedrock:us-east-1::foundation-model/openai.gpt-oss-20b-1:0"
]
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

@@ -0,0 +1,247 @@
"""Offline embedding benchmark aligned with deterministic drug resolution.
The production RAG contract never lets vector similarity choose a drug. This
benchmark therefore resolves the expected drug through the verified alias
catalog, filters candidates to that drug, and measures whether an embedding
retrieves the expected section. Policy/abstention and multi-drug cases are not
silently converted into retrieval cases.
Only BGE-M3 is accepted here. Cloud providers deliberately have no CLI switch;
using one requires a separate, explicitly approved benchmark path.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable, Mapping, Sequence
from ingestion.entities.catalog import normalize_name
from .local_bge_m3 import BgeM3Local
from .ports import EmbeddingProvider
ATTRIBUTE_TO_SECTION = {
"chong_chi_dinh": "chong_chi_dinh",
"lieu_dung": "lieu_luong_va_cach_dung",
"mang_thai": "thoi_ky_mang_thai",
"qua_lieu": "qua_lieu_va_xu_tri",
"tac_dung_phu": "tac_dung_khong_mong_muon",
"than_trong": "than_trong",
"tuong_tac": "tuong_tac_thuoc",
}
@dataclass(frozen=True)
class BenchmarkCase:
case_id: str
query: str
drug_id: str
expected_section: str
@dataclass(frozen=True)
class CaseResult:
case_id: str
drug_id: str
expected_section: str
first_relevant_rank: int | None
top_chunk_id: str
top_section: str
@dataclass(frozen=True)
class BenchmarkReport:
model_id: str
dimensions: int
case_count: int
candidate_chunk_count: int
document_latency_ms: float
query_latency_ms: float
document_requests: int
query_requests: int
hit_at_1: float
hit_at_3: float
hit_at_5: float
mrr: float
cases: list[CaseResult]
def _read_jsonl(path: Path) -> list[dict]:
with path.open(encoding="utf-8") as handle:
return [json.loads(line) for line in handle if line.strip()]
def _alias_lookup(path: Path) -> dict[str, set[str]]:
payload = json.loads(path.read_text(encoding="utf-8"))
lookup: dict[str, set[str]] = {}
for entity in payload["entities"]:
for alias in entity["aliases"]:
key = normalize_name(alias)
lookup.setdefault(key, set()).add(entity["drug_id"])
return lookup
def load_cases(golden_path: Path, entities_path: Path) -> list[BenchmarkCase]:
aliases = _alias_lookup(entities_path)
cases: list[BenchmarkCase] = []
with golden_path.open(encoding="utf-8-sig", newline="") as handle:
for row in csv.DictReader(handle):
attribute = row["thuoc_tinh_ky_vong"].strip()
drug_name = row["thuoc_ky_vong"].strip()
if not attribute or not drug_name or ";" in drug_name:
continue
section = ATTRIBUTE_TO_SECTION.get(attribute)
if section is None:
raise ValueError(
f"golden case {row['id']} has unmapped attribute {attribute!r}"
)
drug_ids = aliases.get(normalize_name(drug_name), set())
if not drug_ids:
raise ValueError(
f"golden case {row['id']} drug {drug_name!r} is not in the "
"verified entity catalog"
)
if len(drug_ids) != 1:
raise ValueError(
f"golden case {row['id']} drug {drug_name!r} is ambiguous: "
f"{sorted(drug_ids)}"
)
drug_id = next(iter(drug_ids))
cases.append(
BenchmarkCase(
case_id=row["id"],
query=row["cau_hoi"].strip(),
drug_id=drug_id,
expected_section=section,
)
)
if not cases:
raise ValueError("golden dataset has no single-drug retrieval cases")
return cases
def candidate_chunks(chunks_path: Path, cases: Sequence[BenchmarkCase]) -> list[dict]:
drug_ids = {case.drug_id for case in cases}
chunks = [
chunk
for chunk in _read_jsonl(chunks_path)
if chunk["drug_id"] in drug_ids and chunk["chunk_kind"] == "prose"
]
available = {(chunk["drug_id"], chunk["section_key"]) for chunk in chunks}
missing = [
case.case_id
for case in cases
if (case.drug_id, case.expected_section) not in available
]
if missing:
raise ValueError(f"golden cases have no canonical target chunks: {missing}")
return chunks
def _cosine(left: Sequence[float], right: Sequence[float]) -> float:
numerator = sum(a * b for a, b in zip(left, right, strict=True))
left_norm = math.sqrt(sum(value * value for value in left))
right_norm = math.sqrt(sum(value * value for value in right))
if left_norm == 0 or right_norm == 0:
raise ValueError("embedding benchmark received a zero vector")
return numerator / (left_norm * right_norm)
def run_benchmark(
provider: EmbeddingProvider,
cases: Sequence[BenchmarkCase],
chunks: Sequence[Mapping[str, object]],
) -> BenchmarkReport:
document_batch = provider.embed_documents([str(chunk["text"]) for chunk in chunks])
query_batch = provider.embed_queries([case.query for case in cases])
case_results: list[CaseResult] = []
for case, query_vector in zip(cases, query_batch.vectors, strict=True):
ranked = sorted(
(
(_cosine(query_vector.values, vector.values), chunk)
for chunk, vector in zip(chunks, document_batch.vectors, strict=True)
if chunk["drug_id"] == case.drug_id
),
key=lambda item: (-item[0], str(item[1]["chunk_id"])),
)
if not ranked:
raise ValueError(f"case {case.case_id} has no candidate chunks")
relevant_rank = next(
(
rank
for rank, (_, chunk) in enumerate(ranked, start=1)
if chunk["section_key"] == case.expected_section
),
None,
)
top = ranked[0][1]
case_results.append(
CaseResult(
case_id=case.case_id,
drug_id=case.drug_id,
expected_section=case.expected_section,
first_relevant_rank=relevant_rank,
top_chunk_id=str(top["chunk_id"]),
top_section=str(top["section_key"]),
)
)
count = len(case_results)
ranks = [result.first_relevant_rank for result in case_results]
def hit_at(limit: int) -> float:
return sum(rank is not None and rank <= limit for rank in ranks) / count
return BenchmarkReport(
model_id=provider.model_id,
dimensions=provider.dimensions,
case_count=count,
candidate_chunk_count=len(chunks),
document_latency_ms=document_batch.latency_ms,
query_latency_ms=query_batch.latency_ms,
document_requests=document_batch.request_count,
query_requests=query_batch.request_count,
hit_at_1=hit_at(1),
hit_at_3=hit_at(3),
hit_at_5=hit_at(5),
mrr=sum(0.0 if rank is None else 1.0 / rank for rank in ranks) / count,
cases=case_results,
)
def write_report(report: BenchmarkReport, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(asdict(report), ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def main(argv: Iterable[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Local BGE-M3 subset benchmark")
parser.add_argument("--golden", type=Path, required=True)
parser.add_argument("--chunks", type=Path, required=True)
parser.add_argument("--entities", type=Path, required=True)
parser.add_argument("--out", type=Path, required=True)
parser.add_argument("--batch-size", type=int, default=8)
parser.add_argument("--device", default="cpu")
args = parser.parse_args(argv)
cases = load_cases(args.golden, args.entities)
chunks = candidate_chunks(args.chunks, cases)
report = run_benchmark(
BgeM3Local(batch_size=args.batch_size, device=args.device), cases, chunks
)
write_report(report, args.out)
print(json.dumps(asdict(report), ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,157 @@
import csv
import json
from pathlib import Path
import pytest
from ingestion.embed.benchmark_local import (
BenchmarkCase,
candidate_chunks,
load_cases,
run_benchmark,
)
from ingestion.embed.local_bge_m3 import BgeM3Local
def _write_csv(path: Path, rows: list[dict]) -> None:
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
def test_load_cases_uses_verified_aliases_and_skips_policy_and_multidrug(tmp_path):
entities = tmp_path / "entities.json"
entities.write_text(
json.dumps(
{
"entities": [
{"drug_id": "paracetamol", "aliases": ["Paracetamol"]}
]
}
),
encoding="utf-8",
)
golden = tmp_path / "golden.csv"
_write_csv(
golden,
[
{
"id": "1",
"cau_hoi": "Liều Paracetamol?",
"thuoc_ky_vong": "Paracetamol",
"thuoc_tinh_ky_vong": "lieu_dung",
},
{
"id": "2",
"cau_hoi": "Uống gì?",
"thuoc_ky_vong": "",
"thuoc_tinh_ky_vong": "",
},
{
"id": "3",
"cau_hoi": "So sánh",
"thuoc_ky_vong": "Paracetamol; Ibuprofen",
"thuoc_tinh_ky_vong": "lieu_dung",
},
],
)
assert load_cases(golden, entities) == [
BenchmarkCase(
case_id="1",
query="Liều Paracetamol?",
drug_id="paracetamol",
expected_section="lieu_luong_va_cach_dung",
)
]
def test_candidate_chunks_fails_when_ground_truth_is_missing(tmp_path):
path = tmp_path / "chunks.jsonl"
path.write_text(
json.dumps(
{
"drug_id": "drug",
"section_key": "chi_dinh",
"chunk_kind": "prose",
"text": "text",
}
)
+ "\n",
encoding="utf-8",
)
cases = [BenchmarkCase("1", "dose", "drug", "lieu_luong_va_cach_dung")]
with pytest.raises(ValueError, match="no canonical target"):
candidate_chunks(path, cases)
def test_load_cases_rejects_an_alias_ambiguous_in_the_verified_catalog(tmp_path):
entities = tmp_path / "entities.json"
entities.write_text(
json.dumps(
{
"entities": [
{"drug_id": "drug_a", "aliases": ["Shared"]},
{"drug_id": "drug_b", "aliases": ["Shared"]},
]
}
),
encoding="utf-8",
)
golden = tmp_path / "golden.csv"
_write_csv(
golden,
[
{
"id": "1",
"cau_hoi": "Liều Shared?",
"thuoc_ky_vong": "Shared",
"thuoc_tinh_ky_vong": "lieu_dung",
}
],
)
with pytest.raises(ValueError, match="is ambiguous"):
load_cases(golden, entities)
def test_benchmark_filters_by_drug_and_reports_first_relevant_rank():
vectors = {
"query": [1.0, 0.0],
"wrong section": [0.9, 0.1],
"correct section": [0.8, 0.2],
"other drug": [1.0, 0.0],
}
provider = BgeM3Local(encoder=lambda texts: [vectors[text] for text in texts])
provider._check_dimensions = lambda _values: None
cases = [BenchmarkCase("1", "query", "drug", "target")]
chunks = [
{
"chunk_id": "wrong",
"drug_id": "drug",
"section_key": "other",
"text": "wrong section",
},
{
"chunk_id": "right",
"drug_id": "drug",
"section_key": "target",
"text": "correct section",
},
{
"chunk_id": "leak",
"drug_id": "other",
"section_key": "target",
"text": "other drug",
},
]
report = run_benchmark(provider, cases, chunks)
assert report.case_count == 1
assert report.cases[0].first_relevant_rank == 2
assert report.hit_at_1 == 0.0
assert report.hit_at_3 == 1.0
assert report.mrr == 0.5
@@ -0,0 +1,17 @@
export interface SuggestResponse {
suggestions: string[];
}
export async function getDrugSuggestions(prefix: string): Promise<string[]> {
const trimmed = prefix.trim();
if (!trimmed) return [];
try {
const response = await fetch(`/api/suggest?q=${encodeURIComponent(trimmed)}`);
if (!response.ok) return [];
const data: SuggestResponse = await response.json();
return data.suggestions || [];
} catch {
return [];
}
}
+2
View File
@@ -1 +1,3 @@
export * from "./sendChatMessage";
export * from "./getDrugSuggestions";
+16
View File
@@ -2,6 +2,8 @@ export interface Citation {
drugName: string;
sectionType: string;
sourcePageRange: [number, number];
snippet?: string;
reason?: string;
}
export interface ChatMessage {
@@ -10,9 +12,23 @@ export interface ChatMessage {
content: string;
citations?: Citation[];
disclaimer?: string;
traceId?: string;
decision?: string;
reason?: string;
grounded?: boolean;
resolvedDrugId?: string;
createdAt: string;
}
export interface SendMessageRequest {
content: string;
conversationId?: string;
subjectScope?: "human" | "veterinary";
userRole?: "doctor" | "pharmacist" | "general";
}
export interface SendMessageResponse {
message: ChatMessage;
sessionId?: string;
}
+12
View File
@@ -0,0 +1,12 @@
export interface ChatSession {
id: string;
title: string;
createdAt: string;
updatedAt: string;
messageCount: number;
lastDrugQueried?: string;
}
export interface SessionHistoryResponse {
sessions: ChatSession[];
}
+2
View File
@@ -1 +1,3 @@
export * from "./dto/chat";
export * from "./dto/session";
+1
View File
@@ -11,6 +11,7 @@
"@radix-ui/react-slot": "^1.1.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"framer-motion": "^13.0.0",
"lucide-react": "^0.400.0",
"tailwind-merge": "^2.4.0"
},
+243 -12
View File
@@ -1,22 +1,253 @@
import type { ChatMessage } from "@duoc-thu/shared-types";
"use client";
import React, { useState } from "react";
import type { ChatMessage, Citation } from "@duoc-thu/shared-types";
import {
ShieldCheck,
FileCheck2,
Copy,
Check,
AlertTriangle,
Pill,
Sparkles,
Info,
RotateCcw,
ExternalLink,
BookOpen,
} from "lucide-react";
import { cn } from "./lib/utils";
export interface ChatBubbleProps {
interface ChatBubbleProps {
message: ChatMessage;
onCitationClick?: (citation: Citation, index: number) => void;
activeCitationIndex?: number | null;
onRetry?: () => void;
className?: string;
}
export function ChatBubble({ message }: ChatBubbleProps) {
export function ChatBubble({
message,
onCitationClick,
activeCitationIndex,
onRetry,
className,
}: ChatBubbleProps) {
const [copied, setCopied] = useState(false);
const isUser = message.role === "user";
const handleCopy = () => {
navigator.clipboard.writeText(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
if (isUser) {
return (
<div
className={cn(
"max-w-lg rounded-2xl px-4 py-3 text-[1.02rem] leading-relaxed shadow-sm",
isUser
? "ml-auto rounded-br-sm bg-primary text-primary-foreground"
: "mr-auto rounded-bl-sm bg-muted text-foreground"
)}
>
<p className="m-0 whitespace-pre-wrap">{message.content}</p>
<div className={cn("flex w-full justify-end my-3", className)}>
<div className="max-w-2xl rounded-2xl bg-accent-primary text-txt-inverse px-4 py-3 shadow-sm text-sm font-medium leading-relaxed">
{message.content}
</div>
</div>
);
}
// Helper to parse citations [1], [2] in markdown content
const renderStructuredContent = (content: string, citations?: Citation[]) => {
// Split content by citations like [1], [2], etc.
const parts = content.split(/(\[\d+\])/g);
return parts.map((part, i) => {
const match = part.match(/^\[(\d+)\]$/);
if (match) {
const citationIndex = parseInt(match[1], 10);
const citationObj = citations && citations[citationIndex - 1];
const isActive = activeCitationIndex === citationIndex;
return (
<button
key={`cite-${i}`}
id={`citation-marker-${citationIndex}`}
onClick={() => {
if (citationObj) {
onCitationClick?.(citationObj, citationIndex);
}
}}
title={citationObj ? `${citationObj.drugName} (${citationObj.sectionType})` : `Trích dẫn [${citationIndex}]`}
className={cn(
"inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 mx-0.5 rounded-full text-[0.68rem] font-extrabold tracking-tight transition-all align-baseline cursor-pointer select-none",
isActive
? "bg-accent-primary text-txt-inverse scale-110 shadow-md ring-2 ring-accent-glow glass-beam-glow"
: "bg-accent-soft text-accent-primary hover:bg-accent-primary hover:text-txt-inverse border border-border-subtle"
)}
>
[{citationIndex}]
</button>
);
}
// Format markdown-like text lines
const lines = part.split("\n");
return (
<React.Fragment key={`text-${i}`}>
{lines.map((line, lineIdx) => {
if (!line.trim()) return <br key={lineIdx} />;
// Heading 2 or 3
if (line.startsWith("### ") || line.startsWith("## ")) {
return (
<h3 key={lineIdx} className="text-base font-extrabold text-txt-primary mt-3 mb-1.5 flex items-center gap-2">
<span className="w-1.5 h-4 rounded-full bg-accent-primary inline-block" />
{line.replace(/^#+\s*/, "")}
</h3>
);
}
// Bullet points
if (line.trim().startsWith("- ") || line.trim().startsWith("* ")) {
return (
<li key={lineIdx} className="ml-4 list-disc text-txt-secondary mb-1">
{formatBoldText(line.trim().replace(/^[-*]\s*/, ""))}
</li>
);
}
// Warning block / Note
if (line.includes("Chống chỉ định") || line.includes("Cảnh báo") || line.includes("Thận trọng")) {
return (
<div key={lineIdx} className="my-2 rounded-xl border border-status-warning/40 bg-status-warning-bg/60 p-3 text-xs leading-relaxed text-txt-primary flex items-start gap-2.5">
<AlertTriangle className="h-4 w-4 text-status-warning shrink-0 mt-0.5" />
<div>{formatBoldText(line)}</div>
</div>
);
}
return (
<p key={lineIdx} className="mb-2 text-txt-primary text-sm leading-relaxed">
{formatBoldText(line)}
</p>
);
})}
</React.Fragment>
);
});
};
const formatBoldText = (text: string) => {
const boldParts = text.split(/(\*\*.*?\*\*)/g);
return boldParts.map((bPart, bIdx) => {
if (bPart.startsWith("**") && bPart.endsWith("**")) {
return (
<strong key={bIdx} className="font-bold text-txt-primary">
{bPart.slice(2, -2)}
</strong>
);
}
return bPart;
});
};
return (
<article
className={cn(
"my-4 w-full rounded-2xl border transition-all shadow-sm glass-content-card",
message.grounded !== false
? "border-border-subtle bg-surface"
: "border-status-warning/30 bg-status-warning-bg/20",
className
)}
>
{/* Intelligence Document Header */}
<header className="flex flex-wrap items-center justify-between gap-2 border-b border-border-subtle bg-surface-elevated px-4 py-2.5 rounded-t-2xl">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-xl bg-accent-soft text-accent-primary">
<Pill className="h-4 w-4" />
</div>
<div>
<h3 className="m-0 text-xs font-bold tracking-tight text-txt-primary flex items-center gap-1.5">
<span>Báo Cáo Tra Cứu Chuyên Luận Dược Thư</span>
{message.resolvedDrugId && (
<span className="rounded-md bg-accent-soft px-1.5 py-0.5 text-[0.68rem] font-bold text-accent-primary uppercase">
{message.resolvedDrugId}
</span>
)}
</h3>
</div>
</div>
<div className="flex items-center gap-2">
{message.grounded !== false ? (
<span className="inline-flex items-center gap-1 rounded-full border border-status-success/30 bg-status-success-bg px-2.5 py-0.5 text-[0.65rem] font-extrabold text-status-success">
<ShieldCheck className="h-3 w-3" />
ENTAILED & GROUNDED
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full border border-status-warning/30 bg-status-warning-bg px-2.5 py-0.5 text-[0.65rem] font-extrabold text-status-warning">
<Info className="h-3 w-3" />
THÔNG TIN TRA CỨU MỞ RỘNG
</span>
)}
<time className="text-[0.68rem] text-txt-muted hidden sm:inline">
{new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</time>
</div>
</header>
{/* Document Body */}
<div className="p-4 sm:p-5 medical-document-body">
{renderStructuredContent(message.content, message.citations)}
</div>
{/* Disclaimer Section inside document */}
{message.disclaimer && (
<div className="mx-4 mb-3 rounded-xl border border-border-subtle bg-surface-elevated/50 p-2.5 text-[0.72rem] text-txt-muted flex items-start gap-2">
<Info className="h-3.5 w-3.5 text-accent-primary shrink-0 mt-0.5" />
<span>{message.disclaimer}</span>
</div>
)}
{/* Document Footer & Actions */}
<footer className="flex flex-wrap items-center justify-between gap-3 border-t border-border-subtle bg-surface-elevated/40 px-4 py-2.5 rounded-b-2xl text-xs text-txt-muted">
<div className="flex items-center gap-2">
{message.citations && message.citations.length > 0 && (
<div className="flex items-center gap-1.5">
<BookOpen className="h-3.5 w-3.5 text-accent-primary" />
<span className="font-semibold text-txt-secondary text-[0.72rem]">
{message.citations.length} Nguồn trích dẫn chính thức
</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
{onRetry && (
<button
onClick={onRetry}
className="flex items-center gap-1 rounded-lg px-2.5 py-1 text-txt-muted hover:bg-surface-hover hover:text-txt-primary transition-colors text-xs font-medium"
>
<RotateCcw className="h-3.5 w-3.5" />
<span>Thử lại</span>
</button>
)}
<button
onClick={handleCopy}
className="flex items-center gap-1 rounded-lg px-2.5 py-1 text-txt-muted hover:bg-surface-hover hover:text-txt-primary transition-colors text-xs font-medium"
>
{copied ? (
<>
<Check className="h-3.5 w-3.5 text-status-success" />
<span className="text-status-success font-bold">Đã chép</span>
</>
) : (
<>
<Copy className="h-3.5 w-3.5" />
<span>Sao chép</span>
</>
)}
</button>
</div>
</footer>
</article>
);
}
+143
View File
@@ -0,0 +1,143 @@
"use client";
import React, { useEffect, useState } from "react";
import { useTheme } from "./ThemeContext";
interface CitationBeamOverlayProps {
activeCitationIndex: number | null;
}
interface Coords {
x1: number;
y1: number;
x2: number;
y2: number;
}
export function CitationBeamOverlay({ activeCitationIndex }: CitationBeamOverlayProps) {
const { resolvedTheme } = useTheme();
const [coords, setCoords] = useState<Coords | null>(null);
useEffect(() => {
if (!activeCitationIndex) {
setCoords(null);
return;
}
const updateCoords = () => {
const markerEl = document.getElementById(`citation-marker-${activeCitationIndex}`);
const cardEl = document.getElementById(`citation-card-${activeCitationIndex}`);
if (!markerEl || !cardEl) {
setCoords(null);
return;
}
const markerRect = markerEl.getBoundingClientRect();
const cardRect = cardEl.getBoundingClientRect();
// Ensure both elements are visible on screen
if (markerRect.width === 0 || cardRect.width === 0) {
setCoords(null);
return;
}
setCoords({
x1: markerRect.left + markerRect.width / 2,
y1: markerRect.top + markerRect.height / 2,
x2: cardRect.left,
y2: cardRect.top + cardRect.height / 2,
});
};
updateCoords();
const handleScrollOrResize = () => updateCoords();
window.addEventListener("resize", handleScrollOrResize);
window.addEventListener("scroll", handleScrollOrResize, true);
return () => {
window.removeEventListener("resize", handleScrollOrResize);
window.removeEventListener("scroll", handleScrollOrResize, true);
};
}, [activeCitationIndex]);
if (!activeCitationIndex || !coords) return null;
// Compute smooth bezier curve control points
const dx = Math.abs(coords.x2 - coords.x1);
const cx1 = coords.x1 + dx * 0.4;
const cy1 = coords.y1;
const cx2 = coords.x2 - dx * 0.4;
const cy2 = coords.y2;
const pathD = `M ${coords.x1} ${coords.y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${coords.x2} ${coords.y2}`;
const isGlass = resolvedTheme === "glass";
return (
<svg
className="pointer-events-none fixed inset-0 z-50 h-full w-full overflow-visible"
aria-hidden="true"
>
<defs>
{/* Luminous Specular Gradient */}
<linearGradient id="beamGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="var(--accent-primary)" stopOpacity="0.8" />
<stop offset="50%" stopColor="#38BDF8" stopOpacity="1" />
<stop offset="100%" stopColor="var(--accent-primary)" stopOpacity="0.8" />
</linearGradient>
<filter id="beamGlow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation={isGlass ? "6" : "3"} result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
{/* Background Outer Glow Line */}
<path
d={pathD}
fill="none"
stroke="var(--accent-glow)"
strokeWidth={isGlass ? "8" : "4"}
strokeLinecap="round"
filter="url(#beamGlow)"
className="opacity-70"
/>
{/* Main Connection Path Line */}
<path
d={pathD}
fill="none"
stroke="url(#beamGradient)"
strokeWidth={isGlass ? "3" : "2"}
strokeDasharray={isGlass ? "8 4" : "none"}
strokeLinecap="round"
className={isGlass ? "animate-pulse-beam" : "opacity-90"}
/>
{/* Start Dot at Citation Marker */}
<circle
cx={coords.x1}
cy={coords.y1}
r={isGlass ? "6" : "4"}
fill="var(--accent-primary)"
className="animate-ping opacity-75"
/>
<circle
cx={coords.x1}
cy={coords.y1}
r="4"
fill="#FFFFFF"
/>
{/* End Dot at Citation Card */}
<circle
cx={coords.x2}
cy={coords.y2}
r={isGlass ? "6" : "4"}
fill="#38BDF8"
/>
</svg>
);
}
+111 -29
View File
@@ -1,39 +1,121 @@
import { FileText } from "lucide-react";
"use client";
import React from "react";
import type { Citation } from "@duoc-thu/shared-types";
import { badgeVariants } from "./primitives/badge";
import { BookOpen, FileText, CheckCircle2, ChevronRight } from "lucide-react";
import { cn } from "./lib/utils";
export interface CitationCardProps {
interface CitationCardProps {
citation: Citation;
onClick?: () => void;
index: number;
isActive?: boolean;
onSelect?: () => void;
className?: string;
}
export function CitationCard({ citation, onClick }: CitationCardProps) {
const [fromPage, toPage] = citation.sourcePageRange;
const className = cn(
badgeVariants({ variant: "outline" }),
"mr-1.5 mt-1 border-primary/20 bg-primary/5 font-normal text-foreground",
onClick && "cursor-pointer transition-colors hover:bg-primary/10"
);
const content = (
<>
<FileText className="h-3.5 w-3.5 text-primary" aria-hidden="true" />
<span className="font-bold text-primary">{citation.drugName}</span>
<span className="text-muted-foreground">{citation.sectionType}</span>
<span className="text-muted-foreground">
tr. {fromPage}
{toPage !== fromPage ? `${toPage}` : ""}
</span>
</>
);
const SECTION_LABELS: Record<string, string> = {
chi_dinh: "Chỉ định",
chong_chi_dinh: "Chống chỉ định",
lieu_dung: "Liều lượng & Cách dùng",
tac_dung_phu: "Tác dụng không mong muốn (ADR)",
tuong_tac_thuoc: "Tương tác thuốc",
duoc_ly: "Dược lý & Cơ chế tác dụng",
than_trong: "Thận trọng khi dùng",
qua_lieu: "Quá liều & Xử trí",
bao_quan: "Bảo quản",
};
export function CitationCard({
citation,
index,
isActive = false,
onSelect,
className,
}: CitationCardProps) {
const sectionLabel = SECTION_LABELS[citation.sectionType] ?? citation.sectionType ?? "Chuyên luận";
const pageRangeText = citation.sourcePageRange
? `Trang ${citation.sourcePageRange[0]}${
citation.sourcePageRange[1] && citation.sourcePageRange[1] !== citation.sourcePageRange[0]
? `${citation.sourcePageRange[1]}`
: ""
}`
: "Dược thư 2018";
if (onClick) {
return (
<button type="button" className={className} onClick={onClick}>
{content}
</button>
<div
onClick={onSelect}
id={`citation-card-${index}`}
tabIndex={0}
role="button"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect?.();
}
}}
className={cn(
"group relative flex flex-col gap-2 rounded-2xl border p-3.5 transition-all cursor-pointer select-none",
isActive
? "bg-accent-soft/30 border-border-accent shadow-elevated glass-beam-glow ring-2 ring-accent-primary/20"
: "bg-surface border-border-subtle hover:border-border-active hover:bg-surface-elevated shadow-sm",
className
)}
>
{/* Header */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-[0.7rem] font-extrabold tracking-tight transition-colors",
isActive
? "bg-accent-primary text-txt-inverse shadow-sm"
: "bg-surface-elevated text-txt-secondary border border-border-subtle group-hover:border-border-active"
)}
>
{index}
</span>
<h4 className="m-0 truncate text-xs font-bold text-txt-primary">
{citation.drugName || "Chuyên luận Dược thư"}
</h4>
</div>
<div className="flex items-center gap-1 shrink-0">
<span className="inline-flex items-center gap-1 rounded-full border border-border-subtle bg-surface-elevated px-2 py-0.5 text-[0.65rem] font-semibold text-txt-secondary">
<BookOpen className="h-2.5 w-2.5 text-accent-primary" />
{pageRangeText}
</span>
</div>
</div>
{/* Section Badge */}
<div className="flex items-center gap-1.5">
<span className="inline-flex items-center gap-1 rounded-md bg-accent-soft px-2 py-0.5 text-[0.68rem] font-semibold text-accent-primary">
<FileText className="h-3 w-3" />
{sectionLabel}
</span>
</div>
{/* Snippet / Source Excerpt */}
{citation.snippet && (
<div className="relative rounded-xl border border-border-subtle bg-surface-elevated/70 p-2.5 text-[0.75rem] leading-relaxed text-txt-secondary italic font-sans">
<span className="not-italic text-accent-primary font-bold mr-1"></span>
{citation.snippet}
<span className="not-italic text-accent-primary font-bold ml-1"></span>
</div>
)}
{/* Reason / Entailment Note */}
{citation.reason && (
<p className="m-0 text-[0.68rem] leading-snug text-txt-muted flex items-start gap-1">
<CheckCircle2 className="h-3 w-3 text-status-success shrink-0 mt-0.5" />
<span>{citation.reason}</span>
</p>
)}
<div className="flex items-center justify-end text-[0.65rem] font-medium text-accent-primary group-hover:translate-x-0.5 transition-transform">
<span>Xem trích dẫn đy đ</span>
<ChevronRight className="h-3 w-3 ml-0.5" />
</div>
</div>
);
}
return <div className={className}>{content}</div>;
}
+47 -16
View File
@@ -1,28 +1,59 @@
import { TriangleAlert } from "lucide-react";
import { Alert, AlertDescription } from "./primitives/alert";
"use client";
import React, { useState } from "react";
import { ShieldAlert, Info, X } from "lucide-react";
import { cn } from "./lib/utils";
const DEFAULT_TEXT =
"Nội dung trả lời được tổng hợp từ Dược thư quốc gia Việt Nam và chỉ mang tính tham khảo, " +
"không thay thế chỉ định của bác sĩ hoặc dược sĩ.";
export interface DisclaimerBannerProps {
text?: string;
interface DisclaimerBannerProps {
className?: string;
collapsible?: boolean;
}
export function DisclaimerBanner({ text = DEFAULT_TEXT, className }: DisclaimerBannerProps) {
export function DisclaimerBanner({ className, collapsible = true }: DisclaimerBannerProps) {
const [collapsed, setCollapsed] = useState(false);
if (collapsed) {
return (
<Alert
variant="warning"
<div className={cn("bg-surface border-b border-border-subtle px-4 py-1 flex items-center justify-between text-xs text-txt-muted", className)}>
<div className="flex items-center gap-1.5">
<ShieldAlert className="w-3.5 h-3.5 text-status-warning shrink-0" />
<span>Thông tin trích từ Dược thư Quốc gia Việt Nam 2018 (Không thay thế chỉ đnh y khoa).</span>
</div>
<button
onClick={() => setCollapsed(false)}
className="text-accent-primary hover:underline text-[0.7rem] font-medium"
>
Hiện chi tiết
</button>
</div>
);
}
return (
<div
className={cn(
"flex items-center justify-center gap-2 rounded-none border-x-0 border-t-0 py-2.5 text-center",
"[&>svg]:static [&>svg]:left-auto [&>svg]:top-auto [&>svg~*]:pl-0",
"relative flex items-center justify-between gap-3 border-b border-border-subtle bg-surface-elevated/90 px-4 py-2 text-xs text-txt-secondary backdrop-blur-md transition-all shadow-sm",
className
)}
>
<TriangleAlert className="h-4 w-4 shrink-0" aria-hidden="true" />
<AlertDescription>{text}</AlertDescription>
</Alert>
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-status-warning-bg text-status-warning">
<ShieldAlert className="h-3.5 w-3.5" />
</div>
<p className="m-0 truncate text-[0.78rem] leading-tight">
<strong className="font-semibold text-txt-primary">Cảnh báo lâm sàng:</strong> Nội dung câu trả lời đưc truy xuất trực tiếp từ Dược thư Quốc gia Việt Nam 2018, chỉ mang tính chất tra cứu chuyên môn không thay thế chỉ đnh điều trị của bác hoặc dược lâm sàng.
</p>
</div>
{collapsible && (
<button
onClick={() => setCollapsed(true)}
aria-label="Thu gọn cảnh báo"
className="rounded-lg p-1 text-txt-muted hover:bg-surface-hover hover:text-txt-primary transition-colors shrink-0"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
import React, { createContext, useContext, useEffect, useState, useCallback } from "react";
export type ThemeMode = "auto" | "light" | "dark" | "glass";
export type ResolvedTheme = "light" | "dark" | "glass";
interface ThemeContextType {
mode: ThemeMode;
resolvedTheme: ResolvedTheme;
setMode: (mode: ThemeMode) => void;
}
const STORAGE_KEY = "dt_theme_mode";
export function getAutoTheme(date: Date = new Date()): "light" | "dark" {
const hour = date.getHours();
return hour >= 6 && hour < 18 ? "light" : "dark";
}
const ThemeContext = createContext<ThemeContextType>({
mode: "auto",
resolvedTheme: "dark",
setMode: () => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [mode, setModeState] = useState<ThemeMode>("auto");
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>("dark");
const [mounted, setMounted] = useState(false);
// Initialize theme from localStorage & system time on mount
useEffect(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY) as ThemeMode | null;
const initialMode = saved && ["auto", "light", "dark", "glass"].includes(saved) ? saved : "auto";
setModeState(initialMode);
const computed = initialMode === "auto" ? getAutoTheme() : initialMode;
setResolvedTheme(computed);
document.documentElement.setAttribute("data-theme", computed);
} catch {
const computed = getAutoTheme();
setResolvedTheme(computed);
document.documentElement.setAttribute("data-theme", computed);
}
setMounted(true);
}, []);
// Update theme data-theme attribute and interval timer for auto mode
useEffect(() => {
if (!mounted) return;
const computeResolved = (): ResolvedTheme => {
if (mode === "auto") {
return getAutoTheme();
}
return mode;
};
const currentResolved = computeResolved();
setResolvedTheme(currentResolved);
document.documentElement.setAttribute("data-theme", currentResolved);
// If auto mode, poll time boundaries every 30 seconds
if (mode === "auto") {
const interval = setInterval(() => {
const nextResolved = getAutoTheme();
if (nextResolved !== currentResolved) {
setResolvedTheme(nextResolved);
document.documentElement.setAttribute("data-theme", nextResolved);
}
}, 30000);
return () => clearInterval(interval);
}
}, [mode, mounted]);
const setMode = useCallback((newMode: ThemeMode) => {
setModeState(newMode);
try {
localStorage.setItem(STORAGE_KEY, newMode);
} catch {
// Storage unavailable
}
}, []);
return (
<ThemeContext.Provider value={{ mode, resolvedTheme, setMode }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}
export const ThemeScript = () => {
const code = `
(function() {
try {
var stored = localStorage.getItem('${STORAGE_KEY}');
var mode = (stored && ['auto', 'light', 'dark', 'glass'].indexOf(stored) !== -1) ? stored : 'auto';
var resolved = mode;
if (mode === 'auto') {
var hour = new Date().getHours();
resolved = (hour >= 6 && hour < 18) ? 'light' : 'dark';
}
document.documentElement.setAttribute('data-theme', resolved);
} catch (e) {}
})();
`;
return <script dangerouslySetInnerHTML={{ __html: code }} />;
};
+143
View File
@@ -0,0 +1,143 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import { useTheme, ThemeMode, getAutoTheme } from "./ThemeContext";
import { Sun, Moon, Sparkles, Clock, Check, ChevronDown } from "lucide-react";
import { cn } from "./lib/utils";
interface ThemeSelectorProps {
className?: string;
variant?: "compact" | "full";
}
export function ThemeSelector({ className, variant = "compact" }: ThemeSelectorProps) {
const { mode, resolvedTheme, setMode } = useTheme();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown on click outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const options: { id: ThemeMode; label: string; icon: React.ComponentType<{ className?: string }>; desc: string }[] = [
{
id: "auto",
label: "Tự động",
icon: Clock,
desc: "Chuyển Sáng/Tối theo giờ local (06:00-18:00)",
},
{
id: "light",
label: "Sáng (Daylight)",
icon: Sun,
desc: "Tối ưu đọc lâu, phong cách y tế chuẩn mực",
},
{
id: "dark",
label: "Tối (Night Lab)",
icon: Moon,
desc: "Dễ chịu ban đêm, tương phản cao, hiện đại",
},
{
id: "glass",
label: "Heavy Glass",
icon: Sparkles,
desc: "Giao diện đa tầng kính spatial, hiệu ứng khúc xạ",
},
];
const currentOption = options.find((opt) => opt.id === mode) || options[0];
const IconComponent = currentOption.icon;
if (variant === "full") {
return (
<div className={cn("grid grid-cols-2 gap-2 sm:grid-cols-4", className)}>
{options.map((opt) => {
const Icon = opt.icon;
const isActive = mode === opt.id;
return (
<button
key={opt.id}
onClick={() => setMode(opt.id)}
className={cn(
"flex flex-col items-start p-3 rounded-xl border transition-all text-left",
isActive
? "bg-accent-soft border-border-accent text-accent-primary shadow-sm"
: "bg-surface border-border-subtle text-txt-secondary hover:border-border-active hover:text-txt-primary"
)}
>
<div className="flex items-center justify-between w-full mb-1">
<Icon className="w-4 h-4" />
{isActive && <Check className="w-3.5 h-3.5" />}
</div>
<span className="text-xs font-semibold">{opt.label}</span>
<span className="text-[0.65rem] text-txt-muted line-clamp-2 mt-0.5">{opt.desc}</span>
</button>
);
})}
</div>
);
}
return (
<div className={cn("relative inline-block text-left", className)} ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
aria-label="Select theme mode"
className="flex items-center gap-2 px-3 py-1.5 rounded-full border border-border-subtle bg-surface hover:bg-surface-elevated hover:border-border-active text-txt-primary text-xs font-medium transition-all shadow-sm"
>
<IconComponent className="w-3.5 h-3.5 text-accent-primary" />
<span className="capitalize hidden sm:inline">{currentOption.label}</span>
{mode === "auto" && (
<span className="text-[0.68rem] text-txt-muted font-normal hidden md:inline">
({resolvedTheme === "light" ? "Ban ngày" : "Ban đêm"})
</span>
)}
<ChevronDown className={cn("w-3 h-3 text-txt-muted transition-transform", isOpen && "rotate-180")} />
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-64 rounded-2xl border border-border-subtle bg-surface p-1.5 shadow-elevated backdrop-blur-xl z-50 animate-scale-in">
<div className="px-2 py-1 mb-1 text-[0.68rem] font-bold tracking-wider text-txt-muted uppercase border-b border-border-subtle">
Chế đ hiển thị (Visual Modes)
</div>
{options.map((opt) => {
const Icon = opt.icon;
const isActive = mode === opt.id;
return (
<button
key={opt.id}
onClick={() => {
setMode(opt.id);
setIsOpen(false);
}}
className={cn(
"flex items-start gap-2.5 w-full p-2 rounded-xl text-left transition-colors text-xs",
isActive
? "bg-accent-soft text-accent-primary font-semibold"
: "text-txt-secondary hover:bg-surface-hover hover:text-txt-primary"
)}
>
<Icon className={cn("w-4 h-4 mt-0.5 shrink-0", isActive ? "text-accent-primary" : "text-txt-muted")} />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span>{opt.label}</span>
{isActive && <Check className="w-3.5 h-3.5 shrink-0" />}
</div>
<p className="text-[0.68rem] text-txt-muted font-normal mt-0.5 line-clamp-1">{opt.desc}</p>
</div>
</button>
);
})}
</div>
)}
</div>
);
}
+5 -2
View File
@@ -1,6 +1,9 @@
export * from "./ChatBubble";
export * from "./CitationCard";
export * from "./ThemeContext";
export * from "./ThemeSelector";
export * from "./DisclaimerBanner";
export * from "./CitationCard";
export * from "./ChatBubble";
export * from "./CitationBeamOverlay";
export * from "./primitives/button";
export * from "./primitives/card";
export * from "./primitives/input";
+38
View File
@@ -40,6 +40,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
framer-motion:
specifier: ^13.0.0
version: 13.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
lucide-react:
specifier: ^0.400.0
version: 0.400.0(react@18.3.1)
@@ -116,6 +119,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
framer-motion:
specifier: ^13.0.0
version: 13.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
lucide-react:
specifier: ^0.400.0
version: 0.400.0(react@18.3.1)
@@ -1032,6 +1038,17 @@ packages:
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
framer-motion@13.0.0:
resolution: {integrity: sha512-nQGZXlsiigN48nzvE7AL1GLekql+etcphp8v+PQ0X04oxO20yVmV9rU9XQ25c136RdeIYoaWlKT9oAwvJza3mg==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@@ -1390,6 +1407,12 @@ packages:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
motion-dom@13.0.0:
resolution: {integrity: sha512-Xk+SJas70uMAUIApg+m3lZDShxI3LBFHq7mFGbBKoRXc2PVPDyAKmzN64Bbzt4CZdP/CItTiJxWtn4TA0v53Ng==}
motion-utils@13.0.0:
resolution: {integrity: sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -2980,6 +3003,15 @@ snapshots:
fraction.js@5.3.4: {}
framer-motion@13.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
motion-dom: 13.0.0
motion-utils: 13.0.0
tslib: 2.8.1
optionalDependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
fs.realpath@1.0.0: {}
fsevents@2.3.3:
@@ -3345,6 +3377,12 @@ snapshots:
minipass@7.1.3: {}
motion-dom@13.0.0:
dependencies:
motion-utils: 13.0.0
motion-utils@13.0.0: {}
ms@2.1.3: {}
mz@2.7.0: