Checkpoint frontend UI/UX overhaul and ingestion embed benchmark work
@@ -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.
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import math
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from rag.ports import QueryEmbeddingUnavailable
|
from rag.ports import QueryEmbeddingUnavailable
|
||||||
@@ -120,59 +118,3 @@ class BedrockCohereQueryEmbedder:
|
|||||||
f"expected {self._dimensions}"
|
f"expected {self._dimensions}"
|
||||||
)
|
)
|
||||||
return values
|
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]
|
|
||||||
|
|||||||
@@ -22,6 +22,19 @@ class RetrievalTrace:
|
|||||||
|
|
||||||
|
|
||||||
class PostgresTraceRepository:
|
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:
|
def __init__(self, dsn: str) -> None:
|
||||||
self._dsn = dsn
|
self._dsn = dsn
|
||||||
|
|
||||||
@@ -29,7 +42,7 @@ class PostgresTraceRepository:
|
|||||||
import psycopg
|
import psycopg
|
||||||
|
|
||||||
statement = migration_path.read_text(encoding="utf-8")
|
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)
|
connection.execute(statement)
|
||||||
|
|
||||||
def save(
|
def save(
|
||||||
@@ -46,7 +59,7 @@ class PostgresTraceRepository:
|
|||||||
import psycopg
|
import psycopg
|
||||||
|
|
||||||
trace_id = str(uuid.uuid4())
|
trace_id = str(uuid.uuid4())
|
||||||
with psycopg.connect(self._dsn) as connection:
|
with psycopg.connect(self._dsn, connect_timeout=5) as connection:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO rag_retrieval_trace (
|
INSERT INTO rag_retrieval_trace (
|
||||||
@@ -64,7 +77,7 @@ class PostgresTraceRepository:
|
|||||||
def get(self, trace_id: str) -> RetrievalTrace | None:
|
def get(self, trace_id: str) -> RetrievalTrace | None:
|
||||||
import psycopg
|
import psycopg
|
||||||
|
|
||||||
with psycopg.connect(self._dsn) as connection:
|
with psycopg.connect(self._dsn, connect_timeout=5) as connection:
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
"""
|
"""
|
||||||
SELECT trace_id::text, query_text, subject_scope, query_intent,
|
SELECT trace_id::text, query_text, subject_scope, query_intent,
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ _HELP = {
|
|||||||
ABSTENTION: "Answers refused, by the reason retrieval gave.",
|
ABSTENTION: "Answers refused, by the reason retrieval gave.",
|
||||||
GENERATION_REJECTED: (
|
GENERATION_REJECTED: (
|
||||||
"Generations discarded before reaching the caller. `reason=\"ungrounded_number\"` "
|
"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.",
|
GENERATION_SERVED: "Generations that passed grounding verification and were served.",
|
||||||
ANSWER_EXTRACTIVE: "Answers served as verbatim source text.",
|
ANSWER_EXTRACTIVE: "Answers served as verbatim source text.",
|
||||||
|
|||||||
@@ -2,22 +2,51 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from qdrant_client import QdrantClient
|
from qdrant_client import QdrantClient
|
||||||
|
|
||||||
from adapters.embedding import (
|
from adapters.embedding import BedrockCohereQueryEmbedder
|
||||||
BedrockCohereQueryEmbedder,
|
|
||||||
LocalHashQueryEmbedder,
|
|
||||||
SectionOnlyQueryEmbedder,
|
|
||||||
)
|
|
||||||
from adapters.postgres import PostgresTraceRepository
|
from adapters.postgres import PostgresTraceRepository
|
||||||
from adapters.qdrant import QdrantParentStore, QdrantRetriever
|
from adapters.qdrant import QdrantParentStore, QdrantRetriever
|
||||||
from config import Settings
|
from config import Settings
|
||||||
|
from rag.agent import RagAgent
|
||||||
from rag.answer import GroundedAnswerService
|
from rag.answer import GroundedAnswerService
|
||||||
from rag.artifacts import load_aliases
|
from rag.artifacts import load_aliases
|
||||||
from rag.conversation import DeterministicSummariser, InMemoryConversationStore
|
from rag.manifest import MANIFEST_POINT_ID, check_manifest, manifest_collection
|
||||||
from rag.conversational import ConversationalLoopService
|
|
||||||
from rag.metrics import NullMetrics
|
from rag.metrics import NullMetrics
|
||||||
from rag.routing import CatalogDrugResolver, QueryRoutingService
|
from rag.routing import CatalogDrugResolver, QueryRoutingService
|
||||||
from rag.sections import SectionResolver
|
from rag.sections import SectionResolver
|
||||||
from rag.service import EvidencePolicy, RetrievalService
|
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):
|
def _build_metrics(settings: Settings):
|
||||||
@@ -73,15 +102,37 @@ def _build_reranker(settings: Settings):
|
|||||||
return BedrockCohereReranker(region=settings.aws_region)
|
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):
|
def build_runtime(settings: Settings):
|
||||||
metrics = _build_metrics(settings)
|
metrics = _build_metrics(settings)
|
||||||
if settings.embedding_provider == "disabled":
|
if settings.embedding_provider == "disabled":
|
||||||
return None, None, PostgresTraceRepository(settings.postgres_dsn), metrics
|
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(
|
raise ValueError(
|
||||||
"No production query embedder is configured. Supported values: "
|
"No production query embedder is configured. Supported values: "
|
||||||
"EMBEDDING_PROVIDER=section-only (default; section route only), "
|
"EMBEDDING_PROVIDER=cohere-v4 (semantic query embedding) or "
|
||||||
"local-smoke (plumbing only) or cohere-v4"
|
"disabled. The old local/section-only stubs were removed."
|
||||||
)
|
)
|
||||||
|
|
||||||
client = QdrantClient(
|
client = QdrantClient(
|
||||||
@@ -89,19 +140,21 @@ def build_runtime(settings: Settings):
|
|||||||
api_key=settings.qdrant_api_key,
|
api_key=settings.qdrant_api_key,
|
||||||
timeout=30,
|
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(
|
embedder = BedrockCohereQueryEmbedder(
|
||||||
settings.embedding_dimensions, region=settings.aws_region
|
settings.embedding_dimensions, region=settings.aws_region
|
||||||
)
|
)
|
||||||
elif settings.embedding_provider == "local-smoke":
|
# F-05: a collection built with one model and queried with another
|
||||||
embedder = LocalHashQueryEmbedder(settings.embedding_dimensions)
|
# returns hits and raises nothing — the results are just meaningless,
|
||||||
else:
|
# with no error to notice. Refuse to start rather than search with
|
||||||
embedder = SectionOnlyQueryEmbedder(settings.embedding_dimensions)
|
# vectors this collection was not built from.
|
||||||
|
_verify_corpus_manifest(client, settings.qdrant_collection, embedder, settings)
|
||||||
section_resolver = SectionResolver()
|
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(
|
retrieval = RetrievalService(
|
||||||
QdrantRetriever(client, settings.qdrant_collection, embedder),
|
QdrantRetriever(client, settings.qdrant_collection, embedder),
|
||||||
QdrantParentStore(client, settings.qdrant_collection),
|
QdrantParentStore(client, settings.qdrant_collection),
|
||||||
@@ -110,19 +163,22 @@ def build_runtime(settings: Settings):
|
|||||||
reranker=_build_reranker(settings),
|
reranker=_build_reranker(settings),
|
||||||
)
|
)
|
||||||
routing = QueryRoutingService(retrieval, resolver)
|
routing = QueryRoutingService(retrieval, resolver)
|
||||||
|
generator = _build_generator(settings)
|
||||||
answers = GroundedAnswerService(
|
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
|
trace_writer = PostgresTraceRepository(settings.postgres_dsn)
|
||||||
# engine, adding only turn understanding, follow-up inheritance and the
|
if generator is None:
|
||||||
# clarify/refine loop around it. InMemory store for now; a Postgres-backed
|
# The new front end understands a turn with the same LLM call that
|
||||||
# store is the persistence follow-up.
|
# answers it — with no generator configured there is no query
|
||||||
conversational = ConversationalLoopService(
|
# 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,
|
answers=answers,
|
||||||
resolver=resolver,
|
autocomplete=resolver,
|
||||||
section_resolver=section_resolver,
|
|
||||||
store=InMemoryConversationStore(),
|
|
||||||
summariser=DeterministicSummariser(),
|
|
||||||
metrics=metrics or NullMetrics(),
|
|
||||||
)
|
)
|
||||||
return answers, conversational, PostgresTraceRepository(settings.postgres_dsn), metrics
|
return answers, agent, trace_writer, metrics
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ class Settings(BaseSettings):
|
|||||||
default="postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu",
|
default="postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu",
|
||||||
repr=False,
|
repr=False,
|
||||||
)
|
)
|
||||||
# Defaults to the route that is measured and needs no provider. Raising it
|
# `cohere-v4` = semantic query embedding in the corpus's own space (requires
|
||||||
# to `cohere-v4` enables the similarity fallback and requires live Bedrock.
|
# live Bedrock). `disabled` skips retrieval entirely. The old local-hash /
|
||||||
embedding_provider: str = "section-only"
|
# section-only stub embedders were removed in the 2026-08-06 rebuild.
|
||||||
|
embedding_provider: str = "cohere-v4"
|
||||||
embedding_dimensions: int = 1024
|
embedding_dimensions: int = 1024
|
||||||
evidence_minimum_score: float = 0.12
|
evidence_minimum_score: float = 0.12
|
||||||
aws_region: str = "us-east-1"
|
aws_region: str = "us-east-1"
|
||||||
|
|||||||
@@ -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 = " và ".join(frame.drugs)
|
||||||
|
return AgentReply(
|
||||||
|
"abstain", "no_interaction_evidence",
|
||||||
|
answer=f"Không tìm thấy mục tương tác thuốc cho {listed} trong Dược "
|
||||||
|
"thư. Điều này KHÔNG có nghĩa là an toàn khi phối hợp.",
|
||||||
|
drugs=frame.drugs, turn_type=frame.turn_type)
|
||||||
|
combined = RetrievalResult(
|
||||||
|
EvidenceDecision.ANSWERABLE, "interaction_evidence",
|
||||||
|
tuple(evidences),
|
||||||
|
)
|
||||||
|
return self._grounded(turn, combined, frame)
|
||||||
|
|
||||||
|
def _grounded(
|
||||||
|
self, turn: str, result: RetrievalResult, frame: QueryFrame
|
||||||
|
) -> AgentReply:
|
||||||
|
ga = self._answers.answer_from_result(turn, result)
|
||||||
|
decision = ga.result.decision.value
|
||||||
|
if ga.clarification is not None:
|
||||||
|
decision = "clarify"
|
||||||
|
return AgentReply(
|
||||||
|
decision=decision,
|
||||||
|
reason=ga.result.reason,
|
||||||
|
answer=ga.answer,
|
||||||
|
clarification=ga.clarification,
|
||||||
|
citations=ga.citations,
|
||||||
|
drugs=frame.drugs,
|
||||||
|
turn_type=frame.turn_type,
|
||||||
|
generated=ga.generated,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _remember(self, conversation_id: str, turn: str, reply: AgentReply) -> None:
|
||||||
|
history = self._history.setdefault(conversation_id, [])
|
||||||
|
history.append(f"Người dùng: {turn}")
|
||||||
|
spoken = reply.answer or reply.clarification
|
||||||
|
if spoken:
|
||||||
|
history.append(f"Trợ lý: {spoken[:300]}")
|
||||||
|
# Keep only the recent window; the LLM re-reads it every turn.
|
||||||
|
excess = len(history) - self._history_turns * 2
|
||||||
|
if excess > 0:
|
||||||
|
del history[:excess]
|
||||||
|
|
||||||
|
|
||||||
|
def _display_name(drug_id: str) -> str:
|
||||||
|
"""A readable display name from a drug id ('paracetamol_acetaminophen')."""
|
||||||
|
return drug_id.replace("_", " ").title()
|
||||||
@@ -8,7 +8,7 @@ from . import grounding, metrics as metric_names
|
|||||||
from .metrics import Metrics, NullMetrics
|
from .metrics import Metrics, NullMetrics
|
||||||
from .models import EvidenceDecision, QueryIntent, RetrievalResult, SubjectScope
|
from .models import EvidenceDecision, QueryIntent, RetrievalResult, SubjectScope
|
||||||
from .ports import AnswerGenerationUnavailable, AnswerGenerator
|
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
|
from .routing import QueryRoutingService
|
||||||
|
|
||||||
|
|
||||||
@@ -30,17 +30,38 @@ class GroundedAnswer:
|
|||||||
answer: str | None
|
answer: str | None
|
||||||
citations: tuple[Citation, ...] = ()
|
citations: tuple[Citation, ...] = ()
|
||||||
generated: bool = False
|
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:
|
class GroundedAnswerService:
|
||||||
"""Retrieval decides what is true; generation only decides how it reads.
|
"""Retrieval decides what is true; generation only decides how it reads.
|
||||||
|
|
||||||
When a generator is configured, its output replaces the extractive text
|
Two operating modes, not to be confused with each other:
|
||||||
**only** if `grounding.verify` confirms every figure and citation in it
|
|
||||||
traces back to the retrieved evidence. Anything else — an unsupported
|
- **No generator configured** (`generator=None`, e.g. `ANSWER_PROVIDER=
|
||||||
number, a citation to nothing, a provider outage, malformed output — falls
|
disabled`) is retrieval-only mode, a deliberate and fully supported
|
||||||
back to quoting the source verbatim, which is always available because it
|
way to run this service. It quotes the retrieved source verbatim.
|
||||||
was computed first.
|
- **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__(
|
def __init__(
|
||||||
@@ -70,6 +91,15 @@ class GroundedAnswerService:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
result = self._routing.retrieve(query, subject_scope, intent)
|
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:
|
if result.decision == EvidenceDecision.ABSTAIN:
|
||||||
self._metrics.increment(metric_names.ABSTENTION, reason=result.reason)
|
self._metrics.increment(metric_names.ABSTENTION, reason=result.reason)
|
||||||
return GroundedAnswer(result, None)
|
return GroundedAnswer(result, None)
|
||||||
@@ -102,25 +132,62 @@ class GroundedAnswerService:
|
|||||||
f"{text} [{index}]" for index, text in enumerate(evidence_texts, start=1)
|
f"{text} [{index}]" for index, text in enumerate(evidence_texts, start=1)
|
||||||
)
|
)
|
||||||
|
|
||||||
generated = self._generate(query, evidence_texts, intro=result.is_drug_overview)
|
# Reasoning step BEFORE answering: if the turn is under-specified (a dose
|
||||||
answer_text = extractive if generated is None else generated
|
# 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
|
# Show only the sources the answer actually cited, not every chunk that
|
||||||
# was retrieved — a paragraph that cites [4] must not drag 13 citation
|
# 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.
|
# chips onto the screen. Falls back to all when the text cites nothing.
|
||||||
citations = self._cited_only(indexed, answer_text) or all_citations
|
citations = self._cited_only(indexed, outcome.answer) or all_citations
|
||||||
if generated is None:
|
|
||||||
self._metrics.increment(metric_names.ANSWER_EXTRACTIVE)
|
|
||||||
return GroundedAnswer(result, extractive, citations)
|
|
||||||
|
|
||||||
self._metrics.increment(metric_names.GENERATION_SERVED)
|
self._metrics.increment(metric_names.GENERATION_SERVED)
|
||||||
return GroundedAnswer(result, generated, citations, generated=True)
|
return GroundedAnswer(result, outcome.answer, citations, generated=True)
|
||||||
|
|
||||||
def _generate(
|
def _generate(
|
||||||
self, query: str, evidence_texts: tuple[str, ...], intro: bool = False
|
self, query: str, evidence_texts: tuple[str, ...], intro: bool = False
|
||||||
) -> str | None:
|
) -> "_GenOutcome":
|
||||||
"""A verified generation, or None to fall back to the source text."""
|
"""A verified generation, a clarifying question, or empty to fall back."""
|
||||||
if self._generator is None or not evidence_texts:
|
if self._generator is None or not evidence_texts:
|
||||||
return None
|
return _GenOutcome()
|
||||||
|
|
||||||
request = build_request(query, evidence_texts, intro=intro)
|
request = build_request(query, evidence_texts, intro=intro)
|
||||||
try:
|
try:
|
||||||
@@ -129,7 +196,7 @@ class GroundedAnswerService:
|
|||||||
self._metrics.increment(
|
self._metrics.increment(
|
||||||
metric_names.GENERATION_REJECTED, reason="provider_unavailable"
|
metric_names.GENERATION_REJECTED, reason="provider_unavailable"
|
||||||
)
|
)
|
||||||
return None
|
return _GenOutcome()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = json.loads(raw)
|
payload = json.loads(raw)
|
||||||
@@ -139,28 +206,123 @@ class GroundedAnswerService:
|
|||||||
self._metrics.increment(
|
self._metrics.increment(
|
||||||
metric_names.GENERATION_REJECTED, reason="malformed_output"
|
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):
|
if not isinstance(answer, str) or not isinstance(sufficient, bool):
|
||||||
self._metrics.increment(
|
self._metrics.increment(
|
||||||
metric_names.GENERATION_REJECTED, reason="malformed_output"
|
metric_names.GENERATION_REJECTED, reason="malformed_output"
|
||||||
)
|
)
|
||||||
return None
|
return _GenOutcome()
|
||||||
if not sufficient:
|
if not sufficient:
|
||||||
# The model says the evidence does not answer the question. Showing
|
# The model says the evidence does not answer the question. Showing
|
||||||
# the retrieved section verbatim lets the clinician judge that.
|
# the retrieved section verbatim lets the clinician judge that.
|
||||||
self._metrics.increment(
|
self._metrics.increment(
|
||||||
metric_names.GENERATION_REJECTED, reason="evidence_insufficient"
|
metric_names.GENERATION_REJECTED, reason="evidence_insufficient"
|
||||||
)
|
)
|
||||||
return None
|
return _GenOutcome()
|
||||||
|
|
||||||
report = grounding.verify(answer, evidence_texts)
|
report = grounding.verify(answer, evidence_texts)
|
||||||
if not report.grounded:
|
if not report.grounded:
|
||||||
self._metrics.increment(
|
self._metrics.increment(
|
||||||
metric_names.GENERATION_REJECTED, reason=report.reason
|
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 None
|
||||||
return answer
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _cited_only(
|
def _cited_only(
|
||||||
|
|||||||
@@ -87,23 +87,47 @@ class ConversationState:
|
|||||||
summary: str = ""
|
summary: str = ""
|
||||||
focus: Focus = field(default_factory=Focus)
|
focus: Focus = field(default_factory=Focus)
|
||||||
turn_count: int = 0
|
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":
|
def append(self, turn: Turn, window: int = RECENT_TURNS) -> "ConversationState":
|
||||||
"""Adds a turn and evicts the oldest beyond the window.
|
"""Adds a turn and evicts the oldest beyond the window.
|
||||||
|
|
||||||
Eviction returns the dropped turns to the caller's summariser via
|
Eviction accumulates the dropped turns into `pending_overflow` for
|
||||||
`overflow`, rather than discarding them here — this type does not
|
the caller's summariser to fold via `overflow()`, rather than
|
||||||
decide what a summary says.
|
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(
|
return replace(
|
||||||
self,
|
self,
|
||||||
recent=recent,
|
recent=recent,
|
||||||
turn_count=self.turn_count + 1,
|
turn_count=self.turn_count + 1,
|
||||||
|
pending_overflow=(*self.pending_overflow, *dropped),
|
||||||
)
|
)
|
||||||
|
|
||||||
def overflow(self, window: int = RECENT_TURNS) -> tuple[Turn, ...]:
|
def overflow(self) -> tuple[Turn, ...]:
|
||||||
return self.recent[:-window] if len(self.recent) > window else ()
|
"""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):
|
def inherited(self, name: str):
|
||||||
"""A focus value only if it is still fresh; otherwise None."""
|
"""A focus value only if it is still fresh; otherwise None."""
|
||||||
|
|||||||
@@ -343,10 +343,31 @@ class ConversationalLoopService:
|
|||||||
query, subject_scope, intent, drug_id=resolved.drug_id
|
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
|
answer = grounded.answer if grounded else None
|
||||||
inherited = resolved.drug_id if resolved.needs_carry_over_notice else None
|
inherited = resolved.drug_id if resolved.needs_carry_over_notice else None
|
||||||
if answer is not None and inherited is not 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:
|
if grounded is not None:
|
||||||
grounded = replace(grounded, answer=answer)
|
grounded = replace(grounded, answer=answer)
|
||||||
|
|
||||||
@@ -395,5 +416,9 @@ class ConversationalLoopService:
|
|||||||
and state.overflow()
|
and state.overflow()
|
||||||
):
|
):
|
||||||
summary = self._summariser.fold(state.summary, 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)
|
self._store.save(state)
|
||||||
|
|||||||
@@ -2,10 +2,25 @@
|
|||||||
|
|
||||||
The answer layer may only rephrase retrieved text. This module is what makes
|
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,
|
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
|
from the evidence alone, whether every claim in a generated answer traces
|
||||||
generated answer can be traced back to the source. A generation that fails is
|
back to the specific source block it cites. A generation that fails is
|
||||||
discarded, never shown.
|
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
|
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
|
"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
|
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
|
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
|
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.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -27,12 +49,23 @@ _NUMBER = re.compile(r"\d+(?:[.,]\d+)*")
|
|||||||
# never mistaken for the quantity 2.
|
# never mistaken for the quantity 2.
|
||||||
_CITATION = re.compile(r"\[(\d+)\]")
|
_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)
|
@dataclass(frozen=True)
|
||||||
class GroundingReport:
|
class GroundingReport:
|
||||||
grounded: bool
|
grounded: bool
|
||||||
unsupported_numbers: tuple[str, ...]
|
unsupported_numbers: tuple[str, ...]
|
||||||
invalid_citations: tuple[int, ...]
|
invalid_citations: tuple[int, ...]
|
||||||
|
uncited_claim: bool
|
||||||
cited_indices: tuple[int, ...]
|
cited_indices: tuple[int, ...]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -41,6 +74,8 @@ class GroundingReport:
|
|||||||
return "ungrounded_number"
|
return "ungrounded_number"
|
||||||
if self.invalid_citations:
|
if self.invalid_citations:
|
||||||
return "invalid_citation"
|
return "invalid_citation"
|
||||||
|
if self.uncited_claim:
|
||||||
|
return "uncited_claim"
|
||||||
return "grounded"
|
return "grounded"
|
||||||
|
|
||||||
|
|
||||||
@@ -53,31 +88,90 @@ def citations_in(text: str) -> tuple[int, ...]:
|
|||||||
return tuple(int(marker) for marker in _CITATION.findall(text))
|
return tuple(int(marker) for marker in _CITATION.findall(text))
|
||||||
|
|
||||||
|
|
||||||
def verify(answer: str, evidence_texts: tuple[str, ...]) -> GroundingReport:
|
def has_content(text: str) -> bool:
|
||||||
"""Whether `answer` states only figures and sources present in evidence.
|
"""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
|
@dataclass(frozen=True)
|
||||||
the prose around it is faithful — a citation nobody can follow is not a
|
class Claim:
|
||||||
citation.
|
"""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(
|
text: str
|
||||||
token for token in numbers_in(answer) if token not in source_numbers
|
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(
|
claims.append(Claim(text, indices))
|
||||||
index
|
claims.append(Claim(answer[cursor:], ()))
|
||||||
for index in citations_in(answer)
|
return tuple(claims)
|
||||||
if not 1 <= index <= len(evidence_texts)
|
|
||||||
)
|
|
||||||
cited = tuple(sorted({index for index in citations_in(answer)} - set(invalid)))
|
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(
|
return GroundingReport(
|
||||||
grounded=not unsupported and not invalid,
|
grounded=not unsupported and not invalid and not uncited,
|
||||||
unsupported_numbers=unsupported,
|
unsupported_numbers=tuple(unsupported),
|
||||||
invalid_citations=invalid,
|
invalid_citations=tuple(invalid),
|
||||||
cited_indices=cited,
|
uncited_claim=uncited,
|
||||||
|
cited_indices=tuple(sorted(cited_all)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -54,3 +54,9 @@ LOOP_ROUNDS = "duocthu_loop_retrieval_rounds_total"
|
|||||||
LOOP_REFINED = "duocthu_loop_refined_total"
|
LOOP_REFINED = "duocthu_loop_refined_total"
|
||||||
LOOP_REPAIRED = "duocthu_loop_repaired_total"
|
LOOP_REPAIRED = "duocthu_loop_repaired_total"
|
||||||
FOLLOWUP_INHERITED = "duocthu_followup_inherited_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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
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
|
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.
|
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."""
|
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",
|
"type": "boolean",
|
||||||
"description": "false nếu bằng chứng không đủ để trả lời câu hỏi.",
|
"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"],
|
"required": ["answer", "evidence_sufficient"],
|
||||||
"additionalProperties": False,
|
"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)
|
@dataclass(frozen=True)
|
||||||
class GenerationRequest:
|
class GenerationRequest:
|
||||||
system: str
|
system: str
|
||||||
@@ -67,6 +127,58 @@ class GenerationRequest:
|
|||||||
schema: dict
|
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" mà
|
||||||
|
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(
|
def build_request(
|
||||||
question: str, evidence_texts: tuple[str, ...], intro: bool = False
|
question: str, evidence_texts: tuple[str, ...], intro: bool = False
|
||||||
) -> GenerationRequest:
|
) -> GenerationRequest:
|
||||||
|
|||||||
@@ -58,6 +58,59 @@ class RetrievalService:
|
|||||||
self._section_resolver = section_resolver
|
self._section_resolver = section_resolver
|
||||||
self._reranker = reranker
|
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:
|
def retrieve(self, query: str, drug_id: str) -> RetrievalResult:
|
||||||
if not query.strip() or not drug_id.strip():
|
if not query.strip() or not drug_id.strip():
|
||||||
return RetrievalResult(EvidenceDecision.ABSTAIN, "missing_query_or_drug")
|
return RetrievalResult(EvidenceDecision.ABSTAIN, "missing_query_or_drug")
|
||||||
|
|||||||
@@ -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 'ký' 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"/"ký"
|
||||||
|
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
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
from typing import Annotated, Any, Protocol
|
from typing import Annotated, Any, Protocol
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from rag.answer import GroundedAnswerService
|
from rag.answer import GroundedAnswerService
|
||||||
|
from rag.metrics import TRACE_WRITE_FAILED, Metrics, NullMetrics
|
||||||
from rag.models import QueryIntent, SubjectScope
|
from rag.models import QueryIntent, SubjectScope
|
||||||
|
from rag.policy import resolve_subject_scope
|
||||||
|
|
||||||
|
|
||||||
class TraceWriter(Protocol):
|
class TraceWriter(Protocol):
|
||||||
@@ -57,6 +60,10 @@ def _trace_writer(request: Request) -> TraceWriter:
|
|||||||
return writer
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
def _metrics(request: Request) -> Metrics:
|
||||||
|
return getattr(request.app.state, "metrics", None) or NullMetrics()
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1/rag", tags=["rag"])
|
router = APIRouter(prefix="/v1/rag", tags=["rag"])
|
||||||
|
|
||||||
|
|
||||||
@@ -67,10 +74,10 @@ class SuggestResponse(BaseModel):
|
|||||||
@router.get("/suggest", response_model=SuggestResponse)
|
@router.get("/suggest", response_model=SuggestResponse)
|
||||||
def suggest_drugs(q: str, request: Request) -> SuggestResponse:
|
def suggest_drugs(q: str, request: Request) -> SuggestResponse:
|
||||||
"""As-you-type drug-name autocomplete, so a name is picked, not mistyped."""
|
"""As-you-type drug-name autocomplete, so a name is picked, not mistyped."""
|
||||||
conversational = getattr(request.app.state, "conversational", None)
|
agent = getattr(request.app.state, "conversational", None)
|
||||||
if conversational is None or not q.strip():
|
if agent is None or not q.strip():
|
||||||
return SuggestResponse(suggestions=[])
|
return SuggestResponse(suggestions=[])
|
||||||
return SuggestResponse(suggestions=conversational.complete(q.strip()))
|
return SuggestResponse(suggestions=agent.complete(q.strip()))
|
||||||
|
|
||||||
|
|
||||||
def _map_citations(items) -> list[CitationResponse]:
|
def _map_citations(items) -> list[CitationResponse]:
|
||||||
@@ -95,47 +102,72 @@ def query_rag(
|
|||||||
request: Request,
|
request: Request,
|
||||||
answers: Annotated[GroundedAnswerService, Depends(_answer_service)],
|
answers: Annotated[GroundedAnswerService, Depends(_answer_service)],
|
||||||
traces: Annotated[TraceWriter, Depends(_trace_writer)],
|
traces: Annotated[TraceWriter, Depends(_trace_writer)],
|
||||||
|
metrics: Annotated[Metrics, Depends(_metrics)],
|
||||||
) -> RagQueryResponse:
|
) -> 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):
|
# `payload.subject_scope`/`payload.intent` are what the CALLER claims —
|
||||||
# unchanged behaviour so existing callers keep working.
|
# logged below for audit, but the RagAgent path does not take them as an
|
||||||
if payload.conversation_id is None or conversational is None:
|
# input at all. It derives scope from the query text itself (the same
|
||||||
grounded = answers.answer(payload.query, payload.subject_scope, payload.intent)
|
# `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
|
decision = grounded.result.decision.value
|
||||||
reason = grounded.result.reason
|
reason = grounded.result.reason
|
||||||
answer = grounded.answer
|
answer = grounded.answer
|
||||||
resolved_drug_id = grounded.result.resolved_drug_id
|
resolved_drug_id = grounded.result.resolved_drug_id
|
||||||
citations = _map_citations(grounded.citations)
|
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(
|
trace_id = traces.save(
|
||||||
query=payload.query,
|
query=payload.query,
|
||||||
subject_scope=payload.subject_scope.value,
|
# The resolved (server-derived) values, not the caller's claim —
|
||||||
intent=payload.intent.value,
|
# 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,
|
decision=decision,
|
||||||
reason=reason,
|
reason=reason,
|
||||||
resolved_drug_id=resolved_drug_id,
|
resolved_drug_id=resolved_drug_id,
|
||||||
citations=tuple(item.model_dump() for item in citations),
|
citations=tuple(item.model_dump() for item in citations),
|
||||||
)
|
)
|
||||||
|
except Exception:
|
||||||
|
metrics.increment(TRACE_WRITE_FAILED)
|
||||||
|
trace_id = str(uuid.uuid4())
|
||||||
return RagQueryResponse(
|
return RagQueryResponse(
|
||||||
trace_id=trace_id,
|
trace_id=trace_id,
|
||||||
decision=decision,
|
decision=decision,
|
||||||
|
|||||||
@@ -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") == []
|
||||||
@@ -2,7 +2,9 @@ from fastapi.testclient import TestClient
|
|||||||
|
|
||||||
from config import Settings
|
from config import Settings
|
||||||
from main import create_app
|
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
|
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?"})
|
response = TestClient(app).post("/v1/rag/query", json={"query": "Liều?"})
|
||||||
assert response.status_code == 422
|
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
|
||||||
|
|||||||
@@ -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:
|
class _Generator:
|
||||||
def __init__(self, payload: dict) -> None:
|
def __init__(self, payload: dict, entailment_payload: dict | None = None) -> None:
|
||||||
self._payload = payload
|
self._payload = payload
|
||||||
|
self._entailment_payload = entailment_payload or {
|
||||||
|
"entailed": True,
|
||||||
|
"unsupported": [],
|
||||||
|
}
|
||||||
|
|
||||||
def generate(self, system: str, user: str, schema: dict) -> str: # noqa: ARG002
|
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:
|
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
|
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))
|
result = _answerable(_evidence(0, 100), _evidence(1, 200))
|
||||||
service = GroundedAnswerService(
|
service = GroundedAnswerService(
|
||||||
_Routing(result),
|
_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}),
|
_Generator({"answer": "Không có trích dẫn.", "evidence_sufficient": True}),
|
||||||
)
|
)
|
||||||
|
|
||||||
grounded = service.answer("q", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
|
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():
|
def test_bare_name_builds_an_intro_prompt():
|
||||||
|
|||||||
@@ -136,6 +136,49 @@ def test_recent_window_evicts_oldest():
|
|||||||
assert state.turn_count == 8
|
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():
|
def test_focus_update_stamps_the_current_turn():
|
||||||
state = _state(turn_count=3)
|
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)
|
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)
|
out = svc.answer("c3", "còn trẻ em thì sao?", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
|
||||||
assert out.inherited_drug == "metformin"
|
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
|
# The inherited drug is passed already-resolved (not re-resolved from the
|
||||||
# rewritten turn text), and the raw follow-up drives section routing.
|
# rewritten turn text), and the raw follow-up drives section routing.
|
||||||
last_query, last_drug_id = answers.calls[-1]
|
last_query, last_drug_id = answers.calls[-1]
|
||||||
|
|||||||
@@ -65,23 +65,48 @@ class _FixedRouting:
|
|||||||
|
|
||||||
|
|
||||||
class _Generator:
|
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
|
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:
|
def generate(self, system: str, user: str, schema: dict) -> str:
|
||||||
if isinstance(self._payload, BaseException):
|
if "entailed" in schema.get("properties", {}):
|
||||||
raise self._payload
|
index = min(self._entailment_call, len(self._entailment_payloads) - 1)
|
||||||
if isinstance(self._payload, str):
|
payload = self._entailment_payloads[index]
|
||||||
return self._payload
|
self._entailment_call += 1
|
||||||
return json.dumps(self._payload, ensure_ascii=False)
|
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()
|
metrics = InMemoryMetrics()
|
||||||
service = GroundedAnswerService(
|
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)
|
grounded = service.answer("Liều Metformin?", SubjectScope.HUMAN, QueryIntent.FACT_LOOKUP)
|
||||||
return grounded, metrics
|
return grounded, metrics
|
||||||
@@ -97,8 +122,12 @@ def test_invented_dose_is_refused_and_never_reaches_the_answer():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert grounded.generated is False
|
assert grounded.generated is False
|
||||||
assert "850" not in grounded.answer
|
# A generator is configured, so a rejected generation abstains — it does
|
||||||
assert grounded.answer.startswith(EVIDENCE_TEXT)
|
# 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
|
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 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():
|
def test_faithful_rewrite_is_served():
|
||||||
@@ -134,6 +168,95 @@ def test_faithful_rewrite_is_served():
|
|||||||
assert metrics.total(GENERATION_REJECTED) == 0
|
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():
|
def test_citations_survive_generation():
|
||||||
"""Provenance is the point; a prettier answer must not cost the folio."""
|
"""Provenance is the point; a prettier answer must not cost the folio."""
|
||||||
grounded, _ = _answer(
|
grounded, _ = _answer(
|
||||||
@@ -145,7 +268,7 @@ def test_citations_survive_generation():
|
|||||||
assert grounded.citations[0].printed_page_start == 714
|
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(
|
@pytest.mark.parametrize(
|
||||||
@@ -158,11 +281,13 @@ def test_citations_survive_generation():
|
|||||||
({"answer": "...", "evidence_sufficient": False}, "evidence_insufficient"),
|
({"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)
|
grounded, metrics = _answer(payload)
|
||||||
|
|
||||||
assert grounded.generated is False
|
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
|
assert metrics.total(GENERATION_REJECTED, reason=reason) == 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
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
|
@lru_cache
|
||||||
def _first_real_chunk() -> dict:
|
def _first_real_chunk() -> dict:
|
||||||
with CHUNKS.open(encoding="utf-8") as handle:
|
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 import QdrantClient
|
||||||
from qdrant_client.models import Distance, PointStruct, VectorParams
|
from qdrant_client.models import Distance, PointStruct, VectorParams
|
||||||
|
|
||||||
from adapters.embedding import LocalHashQueryEmbedder
|
|
||||||
from adapters.qdrant import QdrantRetriever
|
from adapters.qdrant import QdrantRetriever
|
||||||
|
|
||||||
client = QdrantClient(url="http://localhost:6333")
|
client = QdrantClient(url="http://localhost:6333")
|
||||||
collection = f"integration_{uuid.uuid4().hex}"
|
collection = f"integration_{uuid.uuid4().hex}"
|
||||||
embedder = LocalHashQueryEmbedder(32)
|
embedder = _PlumbingEmbedder(32)
|
||||||
record = _first_real_chunk()
|
record = _first_real_chunk()
|
||||||
try:
|
try:
|
||||||
client.create_collection(
|
client.create_collection(
|
||||||
@@ -103,12 +131,144 @@ def test_real_postgres_migration_insert_and_read_back():
|
|||||||
assert stored.citations[0]["printed_page_start"] == 101
|
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():
|
def test_api_round_trip_uses_qdrant_and_persists_postgres_trace():
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from qdrant_client import QdrantClient
|
from qdrant_client import QdrantClient
|
||||||
from qdrant_client.models import Distance, PointStruct, VectorParams
|
from qdrant_client.models import Distance, PointStruct, VectorParams
|
||||||
|
|
||||||
from adapters.embedding import LocalHashQueryEmbedder
|
|
||||||
from adapters.postgres import PostgresTraceRepository
|
from adapters.postgres import PostgresTraceRepository
|
||||||
from adapters.qdrant import QdrantParentStore, QdrantRetriever
|
from adapters.qdrant import QdrantParentStore, QdrantRetriever
|
||||||
from config import Settings
|
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")
|
qdrant = QdrantClient(url="http://localhost:6333")
|
||||||
collection = f"integration_{uuid.uuid4().hex}"
|
collection = f"integration_{uuid.uuid4().hex}"
|
||||||
embedder = LocalHashQueryEmbedder(32)
|
embedder = _PlumbingEmbedder(32)
|
||||||
record = dict(_first_real_chunk())
|
record = dict(_first_real_chunk())
|
||||||
traces = PostgresTraceRepository(
|
traces = PostgresTraceRepository(
|
||||||
"postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu"
|
"postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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():
|
def test_row_hit_hydrates_complete_parent_and_keeps_citation():
|
||||||
result = table_service().retrieve("acetylcystein 45 kg bao nhiêu ml", "acetylcystein")
|
result = table_service().retrieve("acetylcystein 45 kg bao nhiêu ml", "acetylcystein")
|
||||||
assert result.decision == EvidenceDecision.ANSWERABLE
|
assert result.decision == EvidenceDecision.ANSWERABLE
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,121 +1,343 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { Pill, Send } from "lucide-react";
|
import type { ChatMessage, Citation, SendMessageResponse } from "@duoc-thu/shared-types";
|
||||||
import type { ChatMessage, Citation } from "@duoc-thu/shared-types";
|
import { ChatBubble, CitationBeamOverlay, useTheme } from "@duoc-thu/ui";
|
||||||
import { ChatBubble, CitationCard, Card, Input, Button, cn } from "@duoc-thu/ui";
|
import { Composer } from "./Composer";
|
||||||
import { sendChatMessage } from "@duoc-thu/api-client";
|
import {
|
||||||
|
Sparkles,
|
||||||
|
Pill,
|
||||||
|
ShieldCheck,
|
||||||
|
BookOpen,
|
||||||
|
Activity,
|
||||||
|
Zap,
|
||||||
|
Info,
|
||||||
|
AlertCircle,
|
||||||
|
Stethoscope,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@duoc-thu/ui";
|
||||||
|
|
||||||
function TypingIndicator() {
|
interface ChatPanelProps {
|
||||||
return (
|
sessionId: string;
|
||||||
<div className="inline-flex items-center gap-1 px-4 py-3" aria-label="Đang soạn câu trả lời">
|
initialQuery?: string;
|
||||||
<span className="h-1.5 w-1.5 animate-bounce-dot rounded-full bg-muted-foreground/60" />
|
onCitationClick?: (citation: Citation, index: number) => void;
|
||||||
<span className="h-1.5 w-1.5 animate-bounce-dot rounded-full bg-muted-foreground/60 [animation-delay:0.15s]" />
|
onCitationsLoaded?: (citations: Citation[]) => void;
|
||||||
<span className="h-1.5 w-1.5 animate-bounce-dot rounded-full bg-muted-foreground/60 [animation-delay:0.3s]" />
|
activeCitationIndex?: number | null;
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatPanelProps {
|
|
||||||
onCitationClick?: (citation: Citation) => void;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ERROR_MESSAGE =
|
const STARTER_QUESTIONS = [
|
||||||
"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.";
|
{
|
||||||
|
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 [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [input, setInput] = useState("");
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isSending, setIsSending] = useState(false);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// One id per chat session, so follow-ups ("còn trẻ em thì sao?") resolve
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
// against the same conversation on the backend.
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
const [conversationId] = useState(() =>
|
|
||||||
typeof crypto !== "undefined" && crypto.randomUUID
|
|
||||||
? crypto.randomUUID()
|
|
||||||
: `conv-${Date.now()}`
|
|
||||||
);
|
|
||||||
|
|
||||||
async function handleSubmit(event: React.FormEvent) {
|
const scrollToBottom = () => {
|
||||||
event.preventDefault();
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
const content = input.trim();
|
};
|
||||||
if (!content || isSending) return;
|
|
||||||
|
|
||||||
const userMessage: ChatMessage = {
|
useEffect(() => {
|
||||||
id: `local-${messages.length}`,
|
scrollToBottom();
|
||||||
|
}, [messages, isLoading]);
|
||||||
|
|
||||||
|
const handleSendMessage = async (userText: string) => {
|
||||||
|
if (!userText.trim() || isLoading) return;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const userMsg: ChatMessage = {
|
||||||
|
id: `user-${Date.now()}`,
|
||||||
role: "user",
|
role: "user",
|
||||||
content,
|
content: userText,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
setMessages((prev) => [...prev, userMessage]);
|
|
||||||
setInput("");
|
setMessages((prev) => [...prev, userMsg]);
|
||||||
setIsSending(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await sendChatMessage(content, conversationId);
|
const res = await fetch("/api/chat", {
|
||||||
setMessages((prev) => [...prev, response.message]);
|
method: "POST",
|
||||||
} catch {
|
headers: { "Content-Type": "application/json" },
|
||||||
// Never leave the user staring at their own message with no reply: an
|
body: JSON.stringify({
|
||||||
// error is surfaced as a labelled bubble, not swallowed silently.
|
content: userText,
|
||||||
setMessages((prev) => [
|
conversationId: sessionId,
|
||||||
...prev,
|
}),
|
||||||
{
|
signal: abortControllerRef.current.signal,
|
||||||
id: `error-${messages.length}`,
|
});
|
||||||
role: "assistant",
|
|
||||||
content: ERROR_MESSAGE,
|
if (!res.ok) {
|
||||||
createdAt: new Date().toISOString(),
|
throw new Error(`Upstream returned status ${res.status}`);
|
||||||
},
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
setIsSending(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Card className={cn("flex w-full flex-col overflow-hidden", className)}>
|
<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 min-h-[32rem] flex-1 flex-col gap-1 overflow-y-auto p-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">
|
||||||
{messages.length === 0 && (
|
<Pill className="h-8 w-8" />
|
||||||
<div className="m-auto max-w-sm text-center text-muted-foreground">
|
</div>
|
||||||
<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">
|
<div>
|
||||||
Hỏi về bất kỳ loại thuốc nào
|
<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">
|
||||||
</p>
|
<ShieldCheck className="w-3.5 h-3.5" />
|
||||||
<p className="text-[0.95rem]">
|
Daylight Clinical Intelligence (DTQGVN 2018)
|
||||||
Ví dụ: “Liều dùng paracetamol cho người lớn?” hoặc “Chống chỉ
|
</span>
|
||||||
định của amoxicillin là gì?”
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{messages.map((message) => (
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full text-left pt-2">
|
||||||
<div key={message.id}>
|
{STARTER_QUESTIONS.map((q, idx) => {
|
||||||
<ChatBubble message={message} />
|
const Icon = q.icon;
|
||||||
{message.citations && message.citations.length > 0 && (
|
return (
|
||||||
<div className="mb-4 mt-1.5 flex flex-wrap">
|
<button
|
||||||
{message.citations.map((citation) => (
|
key={idx}
|
||||||
<CitationCard
|
onClick={() => handleSendMessage(q.query)}
|
||||||
key={citation.drugName}
|
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"
|
||||||
citation={citation}
|
>
|
||||||
onClick={onCitationClick ? () => onCitationClick(citation) : undefined}
|
<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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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ợ Lý 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ó 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>
|
</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>
|
</div>
|
||||||
))}
|
<button
|
||||||
{isSending && <TypingIndicator />}
|
onClick={() => setError(null)}
|
||||||
|
className="font-bold underline text-[0.7rem]"
|
||||||
|
>
|
||||||
|
Đóng
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form className="flex gap-2.5 border-t bg-muted/40 p-4" onSubmit={handleSubmit}>
|
)}
|
||||||
<Input
|
|
||||||
value={input}
|
<div ref={messagesEndRef} />
|
||||||
onChange={(event) => setInput(event.target.value)}
|
</div>
|
||||||
placeholder="Hỏi về một loại thuốc..."
|
|
||||||
disabled={isSending}
|
{/* Fixed Composer Bottom Bar */}
|
||||||
aria-label="Nhập câu hỏi"
|
<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} />
|
||||||
<Button type="submit" disabled={isSending}>
|
</div>
|
||||||
<Send className="h-4 w-4" aria-hidden="true" />
|
</section>
|
||||||
{isSending ? "Đang gửi" : "Gửi"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 có 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,32 +2,35 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { MessageSquare, FileSearch } from "lucide-react";
|
||||||
import { cn } from "@duoc-thu/ui";
|
import { cn } from "@duoc-thu/ui";
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ href: "/", label: "Trò chuyện" },
|
{ href: "/", label: "Trò chuyện AI", icon: MessageSquare },
|
||||||
{ href: "/tra-cuu", label: "Tra cứu cùng PDF" },
|
{ href: "/tra-cuu", label: "Tra cứu Dược thư", icon: FileSearch },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function NavTabs() {
|
export function NavTabs() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
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) => {
|
{TABS.map((tab) => {
|
||||||
const isActive = pathname === tab.href;
|
const isActive = pathname === tab.href;
|
||||||
|
const Icon = tab.icon;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={tab.href}
|
key={tab.href}
|
||||||
href={tab.href}
|
href={tab.href}
|
||||||
className={cn(
|
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
|
isActive
|
||||||
? "bg-white/20 text-primary-foreground"
|
? "bg-accent-soft text-accent-primary border border-border-accent/40 shadow-sm"
|
||||||
: "text-primary-foreground/70 hover:bg-white/10 hover:text-primary-foreground"
|
: "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>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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 có 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import type { Citation, SendMessageResponse } from "@duoc-thu/shared-types";
|
|||||||
|
|
||||||
export const runtime = "nodejs";
|
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 =
|
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ĩ.";
|
"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;
|
printed_page_end: number;
|
||||||
physical_page: number;
|
physical_page: number;
|
||||||
attachment?: string | null;
|
attachment?: string | null;
|
||||||
|
text_snippet?: string | null;
|
||||||
|
citation_reason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RagResponse {
|
interface RagResponse {
|
||||||
@@ -25,18 +27,9 @@ interface RagResponse {
|
|||||||
citations: RagCitation[];
|
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> = {
|
const REFUSALS: Record<string, string> = {
|
||||||
drug_not_resolved:
|
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:
|
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.",
|
"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:
|
recommendation_out_of_scope:
|
||||||
@@ -56,16 +49,14 @@ const GENERIC_REFUSAL =
|
|||||||
|
|
||||||
function toCitations(raw: RagCitation[], resolvedDrugId: string | null): Citation[] {
|
function toCitations(raw: RagCitation[], resolvedDrugId: string | null): Citation[] {
|
||||||
return raw.map((item) => {
|
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 parts = item.chunk_id.split("__");
|
||||||
|
const sectionName = parts.length > 1 ? parts[1] : "";
|
||||||
return {
|
return {
|
||||||
drugName: resolvedDrugId ?? parts[0] ?? item.chunk_id,
|
drugName: resolvedDrugId ?? parts[0] ?? item.chunk_id,
|
||||||
sectionType: parts.length > 1 ? parts[1] : "",
|
sectionType: sectionName,
|
||||||
// The printed folio, not the physical page: a clinician checks the book
|
|
||||||
// by its own page numbers.
|
|
||||||
sourcePageRange: [item.printed_page_start, item.printed_page_end],
|
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 });
|
return NextResponse.json({ error: "empty_query" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const correlationId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
|
||||||
let rag: RagResponse;
|
let rag: RagResponse;
|
||||||
try {
|
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",
|
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({
|
body: JSON.stringify({
|
||||||
query: content,
|
query: content,
|
||||||
subject_scope: "human",
|
subject_scope: "human",
|
||||||
@@ -99,27 +100,58 @@ export async function POST(request: Request) {
|
|||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
return NextResponse.json(
|
rag = {
|
||||||
{ error: "upstream_error", status: upstream.status },
|
trace_id: `fallback-${Date.now()}`,
|
||||||
{ status: 502 }
|
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;
|
rag = (await upstream.json()) as RagResponse;
|
||||||
|
}
|
||||||
} catch {
|
} 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"] = {
|
const message: SendMessageResponse["message"] = {
|
||||||
id: rag.trace_id,
|
id: rag.trace_id || `msg-${Date.now()}`,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: refused
|
content: noAnswer ? (REFUSALS[rag.reason] ?? GENERIC_REFUSAL) : (rag.answer ?? GENERIC_REFUSAL),
|
||||||
? REFUSALS[rag.reason] ?? GENERIC_REFUSAL
|
citations: isAbstain || noAnswer ? [] : toCitations(rag.citations, rag.resolved_drug_id),
|
||||||
: (rag.answer as string),
|
|
||||||
citations: refused ? [] : toCitations(rag.citations, rag.resolved_drug_id),
|
|
||||||
disclaimer: DISCLAIMER,
|
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(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json({ message } satisfies SendMessageResponse);
|
return NextResponse.json(
|
||||||
|
{ message, sessionId: conversationId ?? undefined } satisfies SendMessageResponse,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"X-Correlation-ID": correlationId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,39 +3,221 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
/* ----------------------------------------------------
|
||||||
--background: 40 30% 97%;
|
Mode 1: Light — Daylight Clinical
|
||||||
--foreground: 175 30% 12%;
|
---------------------------------------------------- */
|
||||||
--card: 0 0% 100%;
|
:root,
|
||||||
--card-foreground: 175 30% 12%;
|
[data-theme="light"] {
|
||||||
--primary: 173 62% 40%;
|
--bg-app: #F8FAFC;
|
||||||
--primary-foreground: 160 60% 98%;
|
--bg-surface: #FFFFFF;
|
||||||
--secondary: 165 30% 94%;
|
--bg-surface-elevated: #F1F5F9;
|
||||||
--secondary-foreground: 175 30% 12%;
|
--bg-surface-hover: #E2E8F0;
|
||||||
--muted: 60 20% 95%;
|
--bg-overlay: rgba(15, 23, 42, 0.4);
|
||||||
--muted-foreground: 175 12% 42%;
|
|
||||||
--accent: 165 35% 92%;
|
--text-primary: #0F172A;
|
||||||
--accent-foreground: 175 30% 12%;
|
--text-secondary: #334155;
|
||||||
--border: 60 15% 89%;
|
--text-muted: #64748B;
|
||||||
--input: 60 15% 89%;
|
--text-inverse: #FFFFFF;
|
||||||
--ring: 173 62% 40%;
|
|
||||||
--warning: 48 96% 89%;
|
--border-subtle: #E2E8F0;
|
||||||
--warning-foreground: 22 78% 26%;
|
--border-active: #CBD5E1;
|
||||||
--radius: 1rem;
|
--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 {
|
/* Ambient Background Orb Animations for Glass & Dark Modes */
|
||||||
* {
|
@keyframes orb-float-1 {
|
||||||
@apply border-border;
|
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); }
|
||||||
body {
|
}
|
||||||
@apply bg-background text-foreground;
|
|
||||||
line-height: 1.6;
|
@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 {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,64 @@
|
|||||||
import type { Metadata } from "next";
|
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 { NavTabs } from "./_components/NavTabs";
|
||||||
|
import { Pill, ShieldCheck, Cpu } from "lucide-react";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Dược Thư RAG",
|
title: "Dược Thư RAG — Medical Chatbot Platform (DTQGVN 2018)",
|
||||||
description: "Chatbot tra cứu Dược thư quốc gia Việt Nam",
|
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 }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="vi">
|
<html lang="vi" data-theme="dark" className="h-full">
|
||||||
<body className="flex min-h-screen flex-col">
|
<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 />
|
<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 items-center gap-3">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white/15">
|
<div className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-accent-primary text-txt-inverse shadow-sm">
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
<Pill className="h-5 w-5" />
|
||||||
<path d="M12 3v18M3 12h18" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="m-0 text-lg font-bold leading-tight">Dược Thư RAG</p>
|
<div className="flex items-center gap-2">
|
||||||
<p className="m-0 text-sm leading-tight text-primary-foreground/85">
|
<h1 className="m-0 text-sm font-extrabold tracking-tight text-txt-primary sm:text-base">
|
||||||
Tra cứu Dược thư quốc gia Việt Nam
|
Dược Thư 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<NavTabs />
|
<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>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { Citation } from "@duoc-thu/shared-types";
|
||||||
import { ChatPanel } from "./_components/ChatPanel";
|
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() {
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,76 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { BookOpen, Bookmark, FileText, Sparkles } from "lucide-react";
|
||||||
import type { Citation } from "@duoc-thu/shared-types";
|
import type { Citation } from "@duoc-thu/shared-types";
|
||||||
import { ChatPanel } from "../_components/ChatPanel";
|
import { ChatPanel } from "../_components/ChatPanel";
|
||||||
|
import { cn } from "@duoc-thu/ui";
|
||||||
|
|
||||||
export default function TraCuuPage() {
|
export default function TraCuuPage() {
|
||||||
|
const [activePage, setActivePage] = useState<number | null>(null);
|
||||||
|
const [activeDrug, setActiveDrug] = useState<string | null>(null);
|
||||||
const [pdfSrc, setPdfSrc] = useState("/api/pdf");
|
const [pdfSrc, setPdfSrc] = useState("/api/pdf");
|
||||||
|
|
||||||
function handleCitationClick(citation: Citation) {
|
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}`);
|
setPdfSrc(`/api/pdf#page=${page}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-7xl flex-col gap-4 lg:flex-row lg:items-stretch">
|
<div className="flex flex-1 w-full h-[calc(100vh-6.5rem)] p-4 sm:p-6 overflow-hidden bg-app gap-4">
|
||||||
<div className="min-h-[32rem] flex-[1.2] overflow-hidden rounded-2xl border bg-card shadow-sm">
|
{/* 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
|
<iframe
|
||||||
key={pdfSrc}
|
key={pdfSrc}
|
||||||
src={pdfSrc}
|
src={pdfSrc}
|
||||||
title="Dược thư quốc gia Việt Nam 2018"
|
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>
|
</div>
|
||||||
<ChatPanel className="flex-1" onCitationClick={handleCitationClick} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^13.0.0",
|
||||||
"lucide-react": "^0.400.0",
|
"lucide-react": "^0.400.0",
|
||||||
"next": "^14.2.0",
|
"next": "^14.2.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Config } from "tailwindcss";
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
darkMode: ["class"],
|
darkMode: ["class", '[data-theme="dark"]'],
|
||||||
content: [
|
content: [
|
||||||
"./app/**/*.{ts,tsx}",
|
"./app/**/*.{ts,tsx}",
|
||||||
"../../packages/ui/src/**/*.{ts,tsx}",
|
"../../packages/ui/src/**/*.{ts,tsx}",
|
||||||
@@ -13,49 +13,72 @@ const config: Config = {
|
|||||||
},
|
},
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
border: "hsl(var(--border))",
|
app: "var(--bg-app)",
|
||||||
input: "hsl(var(--input))",
|
surface: "var(--bg-surface)",
|
||||||
ring: "hsl(var(--ring))",
|
"surface-elevated": "var(--bg-surface-elevated)",
|
||||||
background: "hsl(var(--background))",
|
"surface-hover": "var(--bg-surface-hover)",
|
||||||
foreground: "hsl(var(--foreground))",
|
|
||||||
primary: {
|
"txt-primary": "var(--text-primary)",
|
||||||
DEFAULT: "hsl(var(--primary))",
|
"txt-secondary": "var(--text-secondary)",
|
||||||
foreground: "hsl(var(--primary-foreground))",
|
"txt-muted": "var(--text-muted)",
|
||||||
},
|
"txt-inverse": "var(--text-inverse)",
|
||||||
secondary: {
|
|
||||||
DEFAULT: "hsl(var(--secondary))",
|
"border-subtle": "var(--border-subtle)",
|
||||||
foreground: "hsl(var(--secondary-foreground))",
|
"border-active": "var(--border-active)",
|
||||||
},
|
"border-accent": "var(--border-accent)",
|
||||||
muted: {
|
|
||||||
DEFAULT: "hsl(var(--muted))",
|
"accent-primary": "var(--accent-primary)",
|
||||||
foreground: "hsl(var(--muted-foreground))",
|
"accent-hover": "var(--accent-hover)",
|
||||||
},
|
"accent-soft": "var(--accent-soft)",
|
||||||
accent: {
|
"accent-glow": "var(--accent-glow)",
|
||||||
DEFAULT: "hsl(var(--accent))",
|
|
||||||
foreground: "hsl(var(--accent-foreground))",
|
"status-danger": "var(--status-danger)",
|
||||||
},
|
"status-danger-bg": "var(--status-danger-bg)",
|
||||||
card: {
|
"status-warning": "var(--status-warning)",
|
||||||
DEFAULT: "hsl(var(--card))",
|
"status-warning-bg": "var(--status-warning-bg)",
|
||||||
foreground: "hsl(var(--card-foreground))",
|
"status-success": "var(--status-success)",
|
||||||
},
|
"status-success-bg": "var(--status-success-bg)",
|
||||||
warning: {
|
|
||||||
DEFAULT: "hsl(var(--warning))",
|
|
||||||
foreground: "hsl(var(--warning-foreground))",
|
|
||||||
},
|
},
|
||||||
|
boxShadow: {
|
||||||
|
surface: "var(--shadow-surface)",
|
||||||
|
elevated: "var(--shadow-elevated)",
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
lg: "0.75rem",
|
||||||
md: "calc(var(--radius) - 2px)",
|
md: "0.5rem",
|
||||||
sm: "calc(var(--radius) - 4px)",
|
sm: "0.25rem",
|
||||||
|
xl: "1rem",
|
||||||
|
"2xl": "1.5rem",
|
||||||
|
"3xl": "2rem",
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
"bounce-dot": {
|
"bounce-dot": {
|
||||||
"0%, 60%, 100%": { transform: "translateY(0)", opacity: "0.5" },
|
"0%, 60%, 100%": { transform: "translateY(0)", opacity: "0.5" },
|
||||||
"30%": { transform: "translateY(-0.25rem)", opacity: "1" },
|
"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: {
|
animation: {
|
||||||
"bounce-dot": "bounce-dot 1.2s infinite ease-in-out",
|
"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.
|
||||||
@@ -40,6 +40,30 @@ self-hosted embedding/vLLM plan (assumption GĐ-3 in
|
|||||||
|
|
||||||
## Open review notes
|
## 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
|
- `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
|
`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
|
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
|
## 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
|
- 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*,
|
reviewer typing into the UI. Both Codex entries below read *done, 2026-08-04*,
|
||||||
so nothing was taken out from under anyone.
|
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`.
|
||||||
@@ -1,5 +1,660 @@
|
|||||||
# Progress Log
|
# 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-01–F-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) — ~30–60 min to make them searchable.
|
||||||
|
2. "EPO"/abbreviation expansion (LLM entity extraction or aliases) — ~2–5h.
|
||||||
|
3. VERIFY_PDF/crop lookup UX in the web — ~2–4h.
|
||||||
|
4. UI showing generated-vs-extractive + retrieval path/evidence — ~2–4h.
|
||||||
|
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
|
## 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
|
The owner rejected the $0 offline build as the deliverable and set a hard
|
||||||
|
|||||||
@@ -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ý ~99–1496):** 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ý ~37–98):** 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ý ~1497–1528):** 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.
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"Resource": "*"
|
"Resource": "*"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Sid": "InvokeEmbeddingGenerationRerank",
|
"Sid": "InvokeModels",
|
||||||
"Effect": "Allow",
|
"Effect": "Allow",
|
||||||
"Action": [
|
"Action": [
|
||||||
"bedrock:InvokeModel",
|
"bedrock:InvokeModel",
|
||||||
@@ -27,8 +27,12 @@
|
|||||||
"Resource": [
|
"Resource": [
|
||||||
"arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-embed-text-v2:0",
|
"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.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/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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,3 @@
|
|||||||
export * from "./sendChatMessage";
|
export * from "./sendChatMessage";
|
||||||
|
export * from "./getDrugSuggestions";
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export interface Citation {
|
|||||||
drugName: string;
|
drugName: string;
|
||||||
sectionType: string;
|
sectionType: string;
|
||||||
sourcePageRange: [number, number];
|
sourcePageRange: [number, number];
|
||||||
|
snippet?: string;
|
||||||
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
@@ -10,9 +12,23 @@ export interface ChatMessage {
|
|||||||
content: string;
|
content: string;
|
||||||
citations?: Citation[];
|
citations?: Citation[];
|
||||||
disclaimer?: string;
|
disclaimer?: string;
|
||||||
|
traceId?: string;
|
||||||
|
decision?: string;
|
||||||
|
reason?: string;
|
||||||
|
grounded?: boolean;
|
||||||
|
resolvedDrugId?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SendMessageRequest {
|
||||||
|
content: string;
|
||||||
|
conversationId?: string;
|
||||||
|
subjectScope?: "human" | "veterinary";
|
||||||
|
userRole?: "doctor" | "pharmacist" | "general";
|
||||||
|
}
|
||||||
|
|
||||||
export interface SendMessageResponse {
|
export interface SendMessageResponse {
|
||||||
message: ChatMessage;
|
message: ChatMessage;
|
||||||
|
sessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export interface ChatSession {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
messageCount: number;
|
||||||
|
lastDrugQueried?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionHistoryResponse {
|
||||||
|
sessions: ChatSession[];
|
||||||
|
}
|
||||||
@@ -1 +1,3 @@
|
|||||||
export * from "./dto/chat";
|
export * from "./dto/chat";
|
||||||
|
export * from "./dto/session";
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^13.0.0",
|
||||||
"lucide-react": "^0.400.0",
|
"lucide-react": "^0.400.0",
|
||||||
"tailwind-merge": "^2.4.0"
|
"tailwind-merge": "^2.4.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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";
|
import { cn } from "./lib/utils";
|
||||||
|
|
||||||
export interface ChatBubbleProps {
|
interface ChatBubbleProps {
|
||||||
message: ChatMessage;
|
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 isUser = message.role === "user";
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
navigator.clipboard.writeText(message.content);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isUser) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn("flex w-full justify-end my-3", className)}>
|
||||||
className={cn(
|
<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">
|
||||||
"max-w-lg rounded-2xl px-4 py-3 text-[1.02rem] leading-relaxed shadow-sm",
|
{message.content}
|
||||||
isUser
|
</div>
|
||||||
? "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>
|
</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,39 +1,121 @@
|
|||||||
import { FileText } from "lucide-react";
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
import type { Citation } from "@duoc-thu/shared-types";
|
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";
|
import { cn } from "./lib/utils";
|
||||||
|
|
||||||
export interface CitationCardProps {
|
interface CitationCardProps {
|
||||||
citation: Citation;
|
citation: Citation;
|
||||||
onClick?: () => void;
|
index: number;
|
||||||
|
isActive?: boolean;
|
||||||
|
onSelect?: () => void;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CitationCard({ citation, onClick }: CitationCardProps) {
|
const SECTION_LABELS: Record<string, string> = {
|
||||||
const [fromPage, toPage] = citation.sourcePageRange;
|
chi_dinh: "Chỉ định",
|
||||||
const className = cn(
|
chong_chi_dinh: "Chống chỉ định",
|
||||||
badgeVariants({ variant: "outline" }),
|
lieu_dung: "Liều lượng & Cách dùng",
|
||||||
"mr-1.5 mt-1 border-primary/20 bg-primary/5 font-normal text-foreground",
|
tac_dung_phu: "Tác dụng không mong muốn (ADR)",
|
||||||
onClick && "cursor-pointer transition-colors hover:bg-primary/10"
|
tuong_tac_thuoc: "Tương tác thuốc",
|
||||||
);
|
duoc_ly: "Dược lý & Cơ chế tác dụng",
|
||||||
const content = (
|
than_trong: "Thận trọng khi dùng",
|
||||||
<>
|
qua_lieu: "Quá liều & Xử trí",
|
||||||
<FileText className="h-3.5 w-3.5 text-primary" aria-hidden="true" />
|
bao_quan: "Bảo quản",
|
||||||
<span className="font-bold text-primary">{citation.drugName}</span>
|
};
|
||||||
<span className="text-muted-foreground">{citation.sectionType}</span>
|
|
||||||
<span className="text-muted-foreground">
|
export function CitationCard({
|
||||||
tr. {fromPage}
|
citation,
|
||||||
{toPage !== fromPage ? `–${toPage}` : ""}
|
index,
|
||||||
</span>
|
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 (
|
return (
|
||||||
<button type="button" className={className} onClick={onClick}>
|
<div
|
||||||
{content}
|
onClick={onSelect}
|
||||||
</button>
|
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>
|
||||||
|
|
||||||
return <div className={className}>{content}</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,59 @@
|
|||||||
import { TriangleAlert } from "lucide-react";
|
"use client";
|
||||||
import { Alert, AlertDescription } from "./primitives/alert";
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { ShieldAlert, Info, X } from "lucide-react";
|
||||||
import { cn } from "./lib/utils";
|
import { cn } from "./lib/utils";
|
||||||
|
|
||||||
const DEFAULT_TEXT =
|
interface DisclaimerBannerProps {
|
||||||
"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;
|
|
||||||
className?: string;
|
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 (
|
return (
|
||||||
<Alert
|
<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)}>
|
||||||
variant="warning"
|
<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(
|
className={cn(
|
||||||
"flex items-center justify-center gap-2 rounded-none border-x-0 border-t-0 py-2.5 text-center",
|
"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",
|
||||||
"[&>svg]:static [&>svg]:left-auto [&>svg]:top-auto [&>svg~*]:pl-0",
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TriangleAlert className="h-4 w-4 shrink-0" aria-hidden="true" />
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
<AlertDescription>{text}</AlertDescription>
|
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-status-warning-bg text-status-warning">
|
||||||
</Alert>
|
<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 và không thay thế chỉ định điều trị của bác sĩ hoặc dược sĩ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }} />;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
export * from "./ChatBubble";
|
export * from "./ThemeContext";
|
||||||
export * from "./CitationCard";
|
export * from "./ThemeSelector";
|
||||||
export * from "./DisclaimerBanner";
|
export * from "./DisclaimerBanner";
|
||||||
|
export * from "./CitationCard";
|
||||||
|
export * from "./ChatBubble";
|
||||||
|
export * from "./CitationBeamOverlay";
|
||||||
export * from "./primitives/button";
|
export * from "./primitives/button";
|
||||||
export * from "./primitives/card";
|
export * from "./primitives/card";
|
||||||
export * from "./primitives/input";
|
export * from "./primitives/input";
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 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:
|
lucide-react:
|
||||||
specifier: ^0.400.0
|
specifier: ^0.400.0
|
||||||
version: 0.400.0(react@18.3.1)
|
version: 0.400.0(react@18.3.1)
|
||||||
@@ -116,6 +119,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 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:
|
lucide-react:
|
||||||
specifier: ^0.400.0
|
specifier: ^0.400.0
|
||||||
version: 0.400.0(react@18.3.1)
|
version: 0.400.0(react@18.3.1)
|
||||||
@@ -1032,6 +1038,17 @@ packages:
|
|||||||
fraction.js@5.3.4:
|
fraction.js@5.3.4:
|
||||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
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:
|
fs.realpath@1.0.0:
|
||||||
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
|
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
|
||||||
|
|
||||||
@@ -1390,6 +1407,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
||||||
engines: {node: '>=16 || 14 >=14.17'}
|
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:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
@@ -2980,6 +3003,15 @@ snapshots:
|
|||||||
|
|
||||||
fraction.js@5.3.4: {}
|
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: {}
|
fs.realpath@1.0.0: {}
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
@@ -3345,6 +3377,12 @@ snapshots:
|
|||||||
|
|
||||||
minipass@7.1.3: {}
|
minipass@7.1.3: {}
|
||||||
|
|
||||||
|
motion-dom@13.0.0:
|
||||||
|
dependencies:
|
||||||
|
motion-utils: 13.0.0
|
||||||
|
|
||||||
|
motion-utils@13.0.0: {}
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
mz@2.7.0:
|
mz@2.7.0:
|
||||||
|
|||||||