Add Langfuse as a self-hosted eval and trace viewer
This commit is contained in:
@@ -22,6 +22,11 @@ class RetrievalTrace:
|
||||
otel_trace_id: str | None = None
|
||||
conversation_id: str | None = None
|
||||
created_at: datetime | None = None
|
||||
# The full assistant turn (answer text, blocks, plan, ...) — see
|
||||
# `PostgresTraceRepository.save`'s `response_payload` param. None for
|
||||
# rows persisted before that column existed, or when persistence raced
|
||||
# a legacy caller that never passed it.
|
||||
response_payload: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class FeedbackTraceNotFound(LookupError):
|
||||
@@ -65,6 +70,7 @@ class PostgresTraceRepository:
|
||||
correlation_id: str | None = None,
|
||||
otel_trace_id: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
response_payload: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
import psycopg
|
||||
|
||||
@@ -75,13 +81,17 @@ class PostgresTraceRepository:
|
||||
INSERT INTO rag_retrieval_trace (
|
||||
trace_id, query_text, subject_scope, query_intent,
|
||||
decision, reason, resolved_drug_id, citations,
|
||||
correlation_id, otel_trace_id, conversation_id
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s)
|
||||
correlation_id, otel_trace_id, conversation_id,
|
||||
response_payload
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s::jsonb)
|
||||
""",
|
||||
(
|
||||
trace_id, query, subject_scope, intent, decision, reason,
|
||||
resolved_drug_id, json.dumps(citations, ensure_ascii=False),
|
||||
correlation_id, otel_trace_id, conversation_id,
|
||||
json.dumps(response_payload, ensure_ascii=False)
|
||||
if response_payload is not None
|
||||
else None,
|
||||
),
|
||||
)
|
||||
return trace_id
|
||||
@@ -94,7 +104,8 @@ class PostgresTraceRepository:
|
||||
"""
|
||||
SELECT trace_id::text, query_text, subject_scope, query_intent,
|
||||
decision, reason, resolved_drug_id, citations,
|
||||
correlation_id, otel_trace_id, conversation_id, created_at
|
||||
correlation_id, otel_trace_id, conversation_id, created_at,
|
||||
response_payload
|
||||
FROM rag_retrieval_trace WHERE trace_id = %s
|
||||
""",
|
||||
(trace_id,),
|
||||
@@ -105,7 +116,7 @@ class PostgresTraceRepository:
|
||||
trace_id=row[0], query=row[1], subject_scope=row[2], intent=row[3],
|
||||
decision=row[4], reason=row[5], resolved_drug_id=row[6],
|
||||
citations=tuple(row[7]), correlation_id=row[8], otel_trace_id=row[9],
|
||||
conversation_id=row[10], created_at=row[11],
|
||||
conversation_id=row[10], created_at=row[11], response_payload=row[12],
|
||||
)
|
||||
|
||||
def list_by_conversation(
|
||||
@@ -115,10 +126,11 @@ class PostgresTraceRepository:
|
||||
cứu), most recent first. Scoped to `conversation_id` on purpose:
|
||||
this system has no auth anywhere (`apps/api-gateway`/`auth-service`
|
||||
are unbuilt — see README), so an unscoped listing would mix every
|
||||
browser's/user's queries together. Citations/answer text are NOT
|
||||
persisted here (only decision/reason/resolved_drug_id) — a history
|
||||
entry is for re-running the same query, not replaying its old
|
||||
answer verbatim.
|
||||
browser's/user's queries together. Each row's full replayable
|
||||
answer, when one was persisted, rides along in `response_payload` —
|
||||
see `routers/rag.py`'s `/transcript` endpoint, which is what turns
|
||||
this into an actual resumable conversation rather than just a
|
||||
re-run-the-query shortcut.
|
||||
"""
|
||||
import psycopg
|
||||
|
||||
@@ -127,7 +139,8 @@ class PostgresTraceRepository:
|
||||
"""
|
||||
SELECT trace_id::text, query_text, subject_scope, query_intent,
|
||||
decision, reason, resolved_drug_id, citations,
|
||||
correlation_id, otel_trace_id, conversation_id, created_at
|
||||
correlation_id, otel_trace_id, conversation_id, created_at,
|
||||
response_payload
|
||||
FROM rag_retrieval_trace
|
||||
WHERE conversation_id = %s
|
||||
ORDER BY created_at DESC
|
||||
@@ -140,7 +153,7 @@ class PostgresTraceRepository:
|
||||
trace_id=row[0], query=row[1], subject_scope=row[2], intent=row[3],
|
||||
decision=row[4], reason=row[5], resolved_drug_id=row[6],
|
||||
citations=tuple(row[7]), correlation_id=row[8], otel_trace_id=row[9],
|
||||
conversation_id=row[10], created_at=row[11],
|
||||
conversation_id=row[10], created_at=row[11], response_payload=row[12],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# Ragas scoring — setup and pins
|
||||
|
||||
`scripts/run_all_evals.py` checks invariants (decision, citations, drug
|
||||
provenance). `scripts/score_evals_ragas.py` is the other half: it scores the
|
||||
*quality* of answers already recorded by that run, so a change to retrieval or
|
||||
prompting cannot quietly degrade answers while every invariant still passes.
|
||||
|
||||
## Why a separate virtualenv
|
||||
|
||||
Ragas pulls the LangChain stack, which conflicts with this service's pinned
|
||||
dependencies. Installing it into the service environment on 2026-08-18 broke
|
||||
`botocore` and `langchain-core` system-wide. Keep it isolated.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
python -m venv /path/to/ragas_env
|
||||
/path/to/ragas_env/bin/python -m pip install --upgrade pip
|
||||
/path/to/ragas_env/bin/python -m pip install ragas langchain-aws boto3
|
||||
```
|
||||
|
||||
That alone does **not** work. `pip` resolves `langchain-openai` /
|
||||
`langchain-community` to versions that need a newer `langchain-core` than
|
||||
ragas accepts, and the import dies in `ragas/embeddings/base.py` on
|
||||
`from langchain_openai.embeddings import OpenAIEmbeddings`. Pin all three:
|
||||
|
||||
```bash
|
||||
/path/to/ragas_env/bin/python -m pip install \
|
||||
"langchain-core<0.4,>=0.3.85" \
|
||||
"langchain-community<0.4,>=0.3" \
|
||||
"langchain-openai<0.4,>=0.3" \
|
||||
"langchain-aws<1.0"
|
||||
```
|
||||
|
||||
Verify before running anything real:
|
||||
|
||||
```bash
|
||||
/path/to/ragas_env/bin/python -c \
|
||||
"import ragas, langchain_aws; from ragas.llms import llm_factory; print(ragas.__version__)"
|
||||
```
|
||||
|
||||
Known-good: ragas **0.4.3**, boto3 1.43.x.
|
||||
|
||||
## Models
|
||||
|
||||
Judged by Bedrock in `us-east-1`, using the same credentials as the rest of
|
||||
this repo (instance role or `AWS_*` env). Pay per call: roughly three LLM
|
||||
calls per case per metric, so a 90-case run is a few hundred calls.
|
||||
|
||||
| role | model | why |
|
||||
|---|---|---|
|
||||
| judge | `qwen.qwen3-next-80b-a3b` | same model production answers with |
|
||||
| embeddings | `cohere.embed-multilingual-v3` | **not** cohere-v4 |
|
||||
|
||||
cohere-v4 is what production embeds the corpus with, but `langchain-aws`
|
||||
cannot parse v4's response envelope — `BedrockEmbeddings.embed_query` raises a
|
||||
bare `KeyError(0)`. It does not matter here: this embedder only measures how
|
||||
close an answer sits to its question and never touches the index, so it has no
|
||||
need to match the retrieval model. Multilingual does matter — the corpus and
|
||||
the questions are Vietnamese.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# 1. record responses (hits the deployment)
|
||||
python3 scripts/run_all_evals.py \
|
||||
--base-url https://realvuxbaro.me --output-dir /tmp/evals
|
||||
|
||||
# 2. score them (offline, re-runnable, no production traffic)
|
||||
/path/to/ragas_env/bin/python scripts/score_evals_ragas.py \
|
||||
--input /tmp/evals/production60.jsonl \
|
||||
--output /tmp/evals/production60.ragas.jsonl
|
||||
```
|
||||
|
||||
## Reading the numbers
|
||||
|
||||
Only `answerable` turns are scored. An abstain or a clarify has no claims to be
|
||||
faithful to, and averaging them in would move the mean for no reason.
|
||||
|
||||
- **faithfulness** — every claim traceable to the cited evidence. This is the
|
||||
hallucination check and the one that must stay at 1.0. Anything below means
|
||||
the answer asserted something its own citations do not support.
|
||||
- **context_precision** — how much of what was retrieved was actually useful.
|
||||
Low values are a *retrieval* signal, not a safety one: the answer can be
|
||||
perfectly faithful while most of the retrieved chunks were noise.
|
||||
- **answer_relevancy** — whether the answer addresses the question. Catches a
|
||||
well-grounded answer to a different question. Expect below 1.0 on clinical
|
||||
answers that legitimately add safety context the question did not ask for.
|
||||
|
||||
## The trap that already caught us once
|
||||
|
||||
Contexts must carry the drug name. A citation's `evidence_text` is raw section
|
||||
prose that often never names its own drug ("Tăng huyết áp (dùng đơn trị
|
||||
liệu...)"). The service knows the drug from a separate field; a judge handed
|
||||
the bare text does not.
|
||||
|
||||
Scored that way on 2026-08-18, a multi-drug answer came out at **faithfulness
|
||||
0.251** — every claim marked unsupported because no context could be
|
||||
attributed to any drug. The identical run scored **1.000** once `[drug_name]`
|
||||
was prefixed. That was a defect in the measurement, not in the service, and it
|
||||
would have been reported as a model regression. `_contexts_for` in the scoring
|
||||
script exists solely to prevent it; do not simplify it away.
|
||||
|
||||
## The judge confuses lookalike drug names — verify before believing a low score
|
||||
|
||||
Run of 2026-08-19, case **G14** ("Viêm phổi mắc phải ở cộng đồng dùng thuốc
|
||||
gì?"), scored **faithfulness 0.43**. It is not a hallucination. The answer
|
||||
reproduces the GEMIFLOXACIN indication line from printed page 720 almost
|
||||
verbatim, pathogen for pathogen.
|
||||
|
||||
Dumping the per-claim verdicts shows the judge contradicting itself:
|
||||
|
||||
> "Mycoplasma pneumoniae chỉ được liệt kê trong chỉ định của GEMIFLOXACIN cho
|
||||
> viêm phổi cộng đồng, nhưng không phải với GEMIFLOXACIN — mà là với
|
||||
> GEMIFLOXACIN trong phần đầu context"
|
||||
|
||||
> "...chỉ được liệt kê trong chỉ định của GEMIFLOXACIN, không phải
|
||||
> GEMIFLOXACIN"
|
||||
|
||||
The same name sits on both sides of the contradiction. The judge is mixing up
|
||||
**GEMIFLOXACIN** and **GATIFLOXACIN**, which differ by two letters, and marks
|
||||
correct claims unsupported on that basis.
|
||||
|
||||
This is a formulary full of near-identical stems — -floxacin, -azolam,
|
||||
-tidine, -pril, -sartan — so expect it wherever one answer cites two drugs
|
||||
from the same class.
|
||||
|
||||
**Treat faithfulness below 1.0 as a question, not a verdict.** Before reporting
|
||||
one as a regression, dump the claim-level verdicts and read them against the
|
||||
printed page:
|
||||
|
||||
```python
|
||||
stmts = (await metric._create_statements(sample.to_dict(), None)).statements
|
||||
verdicts = await metric._create_verdicts(sample.to_dict(), stmts, None)
|
||||
for v in verdicts.statements:
|
||||
print(v.verdict, v.statement, v.reason)
|
||||
```
|
||||
|
||||
Both low scores this project has investigated turned out to be measurement
|
||||
faults, not service faults: the missing drug names on 2026-08-18, and this on
|
||||
2026-08-19. That record is the reason for the rule above.
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Full answer content for one trace row (answer text, blocks, citations,
|
||||
-- quick replies, plan, candidate assessments, disclaimer) — everything a
|
||||
-- client needs to redraw the assistant turn without re-running the query.
|
||||
-- Nullable/additive: existing rows simply have no replayable answer, and
|
||||
-- `/v1/rag/transcript` degrades to showing just the user turn for those.
|
||||
ALTER TABLE rag_retrieval_trace
|
||||
ADD COLUMN IF NOT EXISTS response_payload jsonb;
|
||||
@@ -232,6 +232,87 @@ def list_history(
|
||||
)
|
||||
|
||||
|
||||
class TranscriptMessage(BaseModel):
|
||||
role: Literal["user", "assistant"]
|
||||
trace_id: str
|
||||
content: str | None = None
|
||||
decision: str | None = None
|
||||
reason: str | None = None
|
||||
resolved_drug_id: str | None = None
|
||||
citations: list[dict[str, Any]] = []
|
||||
generated: bool = False
|
||||
quick_replies: list[str] = []
|
||||
blocks: list[dict[str, Any]] = []
|
||||
answer_mode: str | None = None
|
||||
answer_plan: dict[str, Any] | None = None
|
||||
candidate_assessments: list[dict[str, Any]] = []
|
||||
disclaimer: str | None = None
|
||||
created_at: str
|
||||
|
||||
|
||||
class TranscriptResponse(BaseModel):
|
||||
messages: list[TranscriptMessage]
|
||||
|
||||
|
||||
@router.get("/transcript", response_model=TranscriptResponse)
|
||||
def get_transcript(
|
||||
conversation_id: Annotated[str, Query(max_length=128)],
|
||||
traces: Annotated[TraceWriter, Depends(_trace_writer)],
|
||||
) -> TranscriptResponse:
|
||||
"""The actual conversation for one session — both the question AND the
|
||||
answer for each turn, oldest first, so a resumed session can be redrawn
|
||||
and continued rather than starting blank (the gap `/history` deliberately
|
||||
leaves open, see its docstring). Same no-auth scoping as `/history`: an
|
||||
empty/missing `conversation_id` returns nothing.
|
||||
|
||||
A row saved before `response_payload` existed (or where persistence
|
||||
raced a DB outage) has no replayable answer — its turn contributes only
|
||||
the user message, not a silently-wrong assistant one.
|
||||
"""
|
||||
trimmed = conversation_id.strip()
|
||||
if not trimmed:
|
||||
return TranscriptResponse(messages=[])
|
||||
try:
|
||||
rows = traces.list_by_conversation(trimmed, limit=_HISTORY_LIMIT)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=503, detail="trace_store_unavailable") from exc
|
||||
|
||||
messages: list[TranscriptMessage] = []
|
||||
for row in reversed(rows): # list_by_conversation is newest-first
|
||||
created_at = row.created_at.isoformat() if row.created_at else ""
|
||||
messages.append(
|
||||
TranscriptMessage(
|
||||
role="user",
|
||||
trace_id=row.trace_id,
|
||||
content=row.query,
|
||||
created_at=created_at,
|
||||
)
|
||||
)
|
||||
payload = row.response_payload
|
||||
if payload is None:
|
||||
continue
|
||||
messages.append(
|
||||
TranscriptMessage(
|
||||
role="assistant",
|
||||
trace_id=row.trace_id,
|
||||
content=payload.get("answer"),
|
||||
decision=row.decision,
|
||||
reason=row.reason,
|
||||
resolved_drug_id=payload.get("resolved_drug_id"),
|
||||
citations=payload.get("citations") or [],
|
||||
generated=bool(payload.get("generated", False)),
|
||||
quick_replies=payload.get("quick_replies") or [],
|
||||
blocks=payload.get("blocks") or [],
|
||||
answer_mode=payload.get("answer_mode"),
|
||||
answer_plan=payload.get("answer_plan"),
|
||||
candidate_assessments=payload.get("candidate_assessments") or [],
|
||||
disclaimer=payload.get("disclaimer"),
|
||||
created_at=created_at,
|
||||
)
|
||||
)
|
||||
return TranscriptResponse(messages=messages)
|
||||
|
||||
|
||||
class SuggestResponse(BaseModel):
|
||||
suggestions: list[str]
|
||||
|
||||
@@ -488,6 +569,22 @@ def query_rag(
|
||||
# /v1/rag/trace/{id}` (if it existed) could later look up.
|
||||
correlation_id = current_correlation_id()
|
||||
otel_trace_id = current_trace_id()
|
||||
# The full assistant turn, persisted alongside the trace so a later
|
||||
# `/transcript` read can redraw this exact answer without re-running the
|
||||
# query — the shape mirrors `RagQueryResponse` minus the ids (trace_id is
|
||||
# the row's own primary key; correlation/otel ids are separate columns).
|
||||
response_payload = {
|
||||
"answer": answer,
|
||||
"resolved_drug_id": resolved_drug_id,
|
||||
"citations": [item.model_dump() for item in citations],
|
||||
"generated": generated,
|
||||
"quick_replies": quick_replies,
|
||||
"blocks": [item.model_dump() for item in blocks],
|
||||
"answer_mode": answer_mode,
|
||||
"answer_plan": answer_plan.model_dump() if answer_plan else None,
|
||||
"candidate_assessments": [item.model_dump() for item in candidate_assessments],
|
||||
"disclaimer": DISCLAIMER,
|
||||
}
|
||||
try:
|
||||
with stage("persistence"):
|
||||
trace_id = traces.save(
|
||||
@@ -504,6 +601,7 @@ def query_rag(
|
||||
correlation_id=correlation_id,
|
||||
otel_trace_id=otel_trace_id,
|
||||
conversation_id=payload.conversation_id,
|
||||
response_payload=response_payload,
|
||||
)
|
||||
except Exception:
|
||||
metrics.increment(TRACE_WRITE_FAILED)
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Score recorded eval responses with Ragas, using Bedrock as the judge.
|
||||
|
||||
`run_all_evals.py` answers "did the service break a rule" -- decision, citation
|
||||
presence, drug provenance. It cannot answer "was the answer any good", so a
|
||||
change to retrieval or prompting can degrade quality while every invariant
|
||||
still passes. This fills that gap.
|
||||
|
||||
Three metrics, chosen because the datasets carry no reference answers and any
|
||||
metric needing one (context recall, answer correctness) would be unmeasurable:
|
||||
|
||||
faithfulness -- is every claim in the answer supported by the cited
|
||||
evidence? The hallucination check.
|
||||
context_precision -- were the retrieved chunks actually relevant, or did
|
||||
useful evidence arrive buried in noise? A retrieval
|
||||
check, which faithfulness alone cannot see: an answer
|
||||
can be perfectly faithful to one good chunk that
|
||||
arrived alongside nine useless ones.
|
||||
answer_relevancy -- does the answer address the question asked? Catches a
|
||||
grounded, well-cited answer to a different question.
|
||||
|
||||
CONTEXT MUST CARRY THE DRUG NAME. Each citation's `evidence_text` is the raw
|
||||
section prose, which frequently never repeats the drug it belongs to ("Tăng
|
||||
huyết áp (dùng đơn trị liệu...)"). The service knows the drug from a separate
|
||||
field; a judge handed the bare text does not. Scoring a multi-drug answer that
|
||||
way on 2026-08-18 produced faithfulness 0.251 -- every claim marked
|
||||
unsupported because no context could be attributed to any drug -- and the same
|
||||
run scored 1.000 once `[drug_name]` was prefixed. That was a defect in the
|
||||
measurement, not the service, and it is exactly the kind of error that gets
|
||||
reported as a model regression. Hence `_contexts_for`.
|
||||
|
||||
Only `answerable` turns are scored: an abstain or a clarify has no claims to
|
||||
be faithful to, and scoring them would drag the mean around with values that
|
||||
mean nothing.
|
||||
|
||||
Runs against recorded output, so it never re-queries production and can be
|
||||
re-run offline as often as needed.
|
||||
|
||||
Requires ragas + langchain-aws, which conflict with this service's own pinned
|
||||
dependencies -- install them in a separate virtualenv and run this with that
|
||||
interpreter. See evals/README-ragas.md.
|
||||
|
||||
Usage:
|
||||
<ragas-venv>/python scripts/score_evals_ragas.py \\
|
||||
--input /tmp/evals/production60.jsonl \\
|
||||
--output /tmp/evals/production60.ragas.jsonl
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
||||
|
||||
ANSWER_MODEL = "qwen.qwen3-next-80b-a3b"
|
||||
# NOT cohere-v4, which production uses for retrieval: langchain-aws cannot
|
||||
# parse v4's response envelope and raises a bare KeyError(0). v3-multilingual
|
||||
# is the right substitute anyway -- this embedder only measures how close the
|
||||
# answer sits to the question, never touching the indexed corpus, so it does
|
||||
# not need to match the retrieval model. Multilingual matters more here, the
|
||||
# corpus and questions both being Vietnamese.
|
||||
EMBED_MODEL = "cohere.embed-multilingual-v3"
|
||||
REGION = "us-east-1"
|
||||
|
||||
|
||||
def _contexts_for(citations: list[dict[str, Any]]) -> list[str]:
|
||||
"""Label every context with its drug -- see the module docstring."""
|
||||
contexts = []
|
||||
for citation in citations:
|
||||
text = (citation.get("evidence_text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
name = citation.get("drug_name") or citation.get("drug_id") or ""
|
||||
contexts.append(f"[{name}] {text}" if name else text)
|
||||
return contexts
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--input", type=Path, required=True, help="run_all_evals.py output")
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--limit", type=int)
|
||||
args = parser.parse_args()
|
||||
|
||||
from langchain_aws import BedrockEmbeddings, ChatBedrockConverse
|
||||
from ragas import SingleTurnSample
|
||||
from ragas.embeddings import LangchainEmbeddingsWrapper
|
||||
from ragas.llms import LangchainLLMWrapper
|
||||
from ragas.metrics import (
|
||||
Faithfulness,
|
||||
LLMContextPrecisionWithoutReference,
|
||||
ResponseRelevancy,
|
||||
)
|
||||
|
||||
judge = LangchainLLMWrapper(
|
||||
ChatBedrockConverse(model=ANSWER_MODEL, region_name=REGION, temperature=0)
|
||||
)
|
||||
embedder = LangchainEmbeddingsWrapper(
|
||||
BedrockEmbeddings(model_id=EMBED_MODEL, region_name=REGION)
|
||||
)
|
||||
metrics = {
|
||||
"faithfulness": Faithfulness(llm=judge),
|
||||
"context_precision": LLMContextPrecisionWithoutReference(llm=judge),
|
||||
"answer_relevancy": ResponseRelevancy(llm=judge, embeddings=embedder),
|
||||
}
|
||||
|
||||
rows = [
|
||||
json.loads(line)
|
||||
for line in args.input.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
if args.limit:
|
||||
rows = rows[: args.limit]
|
||||
|
||||
scored: list[dict[str, Any]] = []
|
||||
skipped = 0
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with args.output.open("w", encoding="utf-8") as handle:
|
||||
for index, row in enumerate(rows, start=1):
|
||||
case, response = row.get("case", {}), row.get("response") or {}
|
||||
case_id = case.get("id", f"row{index}")
|
||||
decision = response.get("decision")
|
||||
answer = (response.get("answer") or "").strip()
|
||||
contexts = _contexts_for(response.get("citations") or [])
|
||||
|
||||
if decision != "answerable" or not answer or not contexts:
|
||||
skipped += 1
|
||||
print(f"[{index:02d}/{len(rows)}] {case_id} SKIP ({decision})", flush=True)
|
||||
continue
|
||||
|
||||
sample = SingleTurnSample(
|
||||
user_input=case.get("query", ""),
|
||||
response=answer,
|
||||
retrieved_contexts=contexts,
|
||||
)
|
||||
result: dict[str, Any] = {"id": case_id, "query": case.get("query", "")}
|
||||
for name, metric in metrics.items():
|
||||
try:
|
||||
result[name] = float(await metric.single_turn_ascore(sample))
|
||||
except Exception as exc: # noqa: BLE001 - recorded, not hidden
|
||||
result[name] = None
|
||||
result[f"{name}_error"] = repr(exc)[:200]
|
||||
scored.append(result)
|
||||
handle.write(json.dumps(result, ensure_ascii=False) + "\n")
|
||||
handle.flush()
|
||||
print(
|
||||
f"[{index:02d}/{len(rows)}] {case_id} "
|
||||
+ " ".join(
|
||||
f"{n}={result[n]:.2f}" if result[n] is not None else f"{n}=ERR"
|
||||
for n in metrics
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print(f"\n=== {args.input.name}: scored {len(scored)}, skipped {skipped} ===")
|
||||
for name in metrics:
|
||||
values = [r[name] for r in scored if r.get(name) is not None]
|
||||
if values:
|
||||
worst = min(values)
|
||||
print(
|
||||
f" {name:<18} mean={sum(values) / len(values):.3f} "
|
||||
f"min={worst:.3f} n={len(values)}"
|
||||
)
|
||||
else:
|
||||
print(f" {name:<18} no successful scores")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
@@ -162,6 +162,66 @@ def test_history_rejects_an_oversized_conversation_id_before_querying_storage():
|
||||
assert traces.calls == []
|
||||
|
||||
|
||||
def test_transcript_returns_user_and_assistant_turns_oldest_first():
|
||||
when = datetime(2026, 8, 14, 10, 0, tzinfo=timezone.utc)
|
||||
traces = FakeHistoryTraceWriter({
|
||||
# list_by_conversation contract is newest-first, same as /history.
|
||||
"case-1": [
|
||||
RetrievalTrace(
|
||||
trace_id="t2", query="Chống chỉ định metformin?",
|
||||
subject_scope="human", intent="fact_lookup",
|
||||
decision="answerable", reason="grounded_evidence_available",
|
||||
resolved_drug_id="metformin", citations=(),
|
||||
conversation_id="case-1", created_at=when,
|
||||
response_payload={
|
||||
"answer": "Suy thận nặng.",
|
||||
"resolved_drug_id": "metformin",
|
||||
"citations": [{"chunk_id": "metformin::cci::0"}],
|
||||
"generated": True,
|
||||
"quick_replies": [],
|
||||
"blocks": [],
|
||||
"answer_mode": "concise",
|
||||
"answer_plan": None,
|
||||
"candidate_assessments": [],
|
||||
"disclaimer": "disclaimer text",
|
||||
},
|
||||
),
|
||||
RetrievalTrace(
|
||||
trace_id="t1", query="Chỉ định metformin?",
|
||||
subject_scope="human", intent="fact_lookup",
|
||||
decision="answerable", reason="grounded_evidence_available",
|
||||
resolved_drug_id="metformin", citations=(),
|
||||
conversation_id="case-1", created_at=when,
|
||||
response_payload=None,
|
||||
),
|
||||
],
|
||||
})
|
||||
app = create_app(settings=Settings(), trace_writer=traces)
|
||||
|
||||
response = TestClient(app).get("/v1/rag/transcript", params={"conversation_id": "case-1"})
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
# t1 (oldest) has no persisted answer -> user turn only. t2 has one -> both.
|
||||
roles = [(m["trace_id"], m["role"]) for m in body["messages"]]
|
||||
assert roles == [("t1", "user"), ("t2", "user"), ("t2", "assistant")]
|
||||
assistant = body["messages"][2]
|
||||
assert assistant["content"] == "Suy thận nặng."
|
||||
assert assistant["citations"] == [{"chunk_id": "metformin::cci::0"}]
|
||||
assert assistant["generated"] is True
|
||||
|
||||
|
||||
def test_transcript_with_empty_conversation_id_returns_no_messages():
|
||||
traces = FakeHistoryTraceWriter({})
|
||||
app = create_app(settings=Settings(), trace_writer=traces)
|
||||
|
||||
response = TestClient(app).get("/v1/rag/transcript", params={"conversation_id": " "})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"messages": []}
|
||||
assert traces.calls == []
|
||||
|
||||
|
||||
def test_health_and_fail_closed_rag_response_are_traced():
|
||||
traces = MemoryTraceWriter()
|
||||
app = create_app(
|
||||
@@ -324,6 +384,35 @@ def test_query_routes_through_the_agent_when_one_is_configured():
|
||||
assert agent.calls == [("Liều metformin?", "c1", "ai")]
|
||||
|
||||
|
||||
def test_query_persists_the_full_answer_for_later_transcript_replay():
|
||||
"""The `/transcript` endpoint can only redraw a conversation if this
|
||||
survives to storage — a plain trace row (decision/reason only) is not
|
||||
enough to show what the AI actually said."""
|
||||
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,
|
||||
))
|
||||
traces = MemoryTraceWriter()
|
||||
app = create_app(
|
||||
settings=Settings(),
|
||||
answer_service=GroundedAnswerService(FixedRouting()),
|
||||
conversational=agent,
|
||||
trace_writer=traces,
|
||||
)
|
||||
TestClient(app).post("/v1/rag/query", json={
|
||||
"query": "Liều metformin?", "subject_scope": "human",
|
||||
"intent": "fact_lookup", "conversation_id": "c1",
|
||||
})
|
||||
|
||||
payload = traces.rows[-1]["response_payload"]
|
||||
assert payload["answer"] == "Liều 500 mg [1]."
|
||||
assert payload["resolved_drug_id"] == "metformin"
|
||||
assert payload["generated"] is True
|
||||
assert len(payload["citations"]) == 1
|
||||
assert payload["citations"][0]["chunk_id"] == "metformin::lieu::0"
|
||||
|
||||
|
||||
def test_query_forwards_monograph_response_mode_to_agent():
|
||||
agent = FakeAgent(AgentReply(
|
||||
decision="clarify",
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { AnswerBlock, AnswerPlan, Citation, ChatMessage } from "@duoc-thu/shared-types";
|
||||
|
||||
// Shared between `/api/chat` (a live turn) and `/api/transcript` (a
|
||||
// persisted turn read back later) — both start from the same ai-service
|
||||
// response shape (`RagQueryResponse` / `response_payload`) and must render
|
||||
// identically, so the mapping lives in one place rather than two that can
|
||||
// drift.
|
||||
|
||||
export interface RagCitation {
|
||||
chunk_id: string;
|
||||
printed_page_start: number;
|
||||
printed_page_end: number;
|
||||
physical_page: number;
|
||||
block_id?: string | null;
|
||||
bbox?: [number, number, number, number] | null;
|
||||
source_crop?: string | null;
|
||||
attachment?: string | null;
|
||||
evidence_text?: string | null;
|
||||
drug_id?: string | null;
|
||||
drug_name?: string | null;
|
||||
section_key?: string | null;
|
||||
section_title?: string | null;
|
||||
source_document?: string | null;
|
||||
}
|
||||
|
||||
export interface RagAnswerRaw {
|
||||
decision: string;
|
||||
reason: string;
|
||||
answer: string | null;
|
||||
resolved_drug_id: string | null;
|
||||
citations: RagCitation[];
|
||||
generated?: boolean;
|
||||
quick_replies?: string[];
|
||||
blocks?: Array<{
|
||||
title: string;
|
||||
kind: string;
|
||||
claims: Array<{ text: string; source_ids: string[] }>;
|
||||
}>;
|
||||
answer_mode?: "concise" | "normal" | "detailed";
|
||||
disclaimer?: string | null;
|
||||
answer_plan?: {
|
||||
verbosity: "concise" | "normal" | "detailed";
|
||||
layout: string;
|
||||
reasoning_mode: string;
|
||||
show_heading: boolean;
|
||||
needs_warning: boolean;
|
||||
} | null;
|
||||
candidate_assessments?: Array<{
|
||||
drug_id: string;
|
||||
drug_name: string;
|
||||
indication_supported: boolean;
|
||||
status: string;
|
||||
indication_source_ids: string[];
|
||||
safety_source_ids: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export function toAnswerBlocks(raw: NonNullable<RagAnswerRaw["blocks"]>): AnswerBlock[] {
|
||||
return raw.map((block) => ({
|
||||
title: block.title,
|
||||
kind: block.kind,
|
||||
claims: block.claims.map((claim) => ({
|
||||
text: claim.text,
|
||||
sourceIds: claim.source_ids,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function toAnswerPlan(raw: NonNullable<RagAnswerRaw["answer_plan"]>): AnswerPlan {
|
||||
return {
|
||||
verbosity: raw.verbosity,
|
||||
layout: raw.layout,
|
||||
reasoningMode: raw.reasoning_mode,
|
||||
showHeading: raw.show_heading,
|
||||
needsWarning: raw.needs_warning,
|
||||
};
|
||||
}
|
||||
|
||||
export const REFUSALS: Record<string, string> = {
|
||||
drug_not_resolved:
|
||||
"Chưa xác định được thuốc trong câu hỏi này, nên hệ thống không đưa ra nội dung chuyên môn. Vui lòng nêu rõ tên hoạt chất cần tra cứu (ví dụ: Paracetamol, Amoxicillin...).",
|
||||
drug_resolution_ambiguous:
|
||||
"Câu hỏi có thể ứng với nhiều thuốc khác nhau. Vui lòng nêu rõ tên hoạt chất cần tra cứu.",
|
||||
drug_resolution_invalid_state:
|
||||
"Có lỗi nội bộ khi xác định thuốc trong câu hỏi này. Vui lòng thử lại.",
|
||||
recommendation_out_of_scope:
|
||||
"Đây là câu hỏi xin tư vấn hoặc quyết định điều trị. Hệ thống chỉ tra cứu Dược thư và không đưa ra khuyến cáo điều trị — vui lòng hỏi bác sĩ hoặc dược sĩ.",
|
||||
out_of_scope_non_human:
|
||||
"Dược thư Quốc gia Việt Nam áp dụng cho người. Hệ thống không tra cứu cho đối tượng khác.",
|
||||
subject_scope_unknown:
|
||||
"Chưa rõ câu hỏi áp dụng cho đối tượng nào, nên hệ thống không trả lời.",
|
||||
query_intent_unknown:
|
||||
"Chưa rõ mục đích câu hỏi (tra cứu thông tin hay xin tư vấn điều trị). Vui lòng đặt lại câu hỏi cụ thể hơn.",
|
||||
query_embedding_unavailable:
|
||||
"Chưa tra được mục tương ứng cho câu hỏi này. Vui lòng nêu rõ thuộc tính cần tra (liều dùng, chống chỉ định, tương tác thuốc…).",
|
||||
insufficient_retrieval_score:
|
||||
"Không tìm thấy nội dung đủ liên quan trong Dược thư cho câu hỏi này.",
|
||||
missing_query_or_drug:
|
||||
"Câu hỏi hoặc tên thuốc chưa đủ rõ để tra cứu. Vui lòng nêu rõ tên thuốc và nội dung cần tra.",
|
||||
missing_indication:
|
||||
"Vui lòng nêu rõ triệu chứng hoặc chỉ định cần tra thuốc (ví dụ: sốt, đau đầu).",
|
||||
no_indication_match:
|
||||
"Không tìm thấy thuốc nào trong Dược thư ghi nhận chỉ định phù hợp với triệu chứng này.",
|
||||
parent_hydration_failed:
|
||||
"Có lỗi khi tổng hợp dữ liệu nhiều thuốc trong câu hỏi này. Vui lòng thử lại.",
|
||||
missing_provenance:
|
||||
"Không xác định được nguồn trang cho nội dung này nên hệ thống không thể trích dẫn. Vui lòng thử lại.",
|
||||
missing_printed_page_provenance:
|
||||
"Không xác định được nguồn trang cho nội dung này nên hệ thống không thể trích dẫn. Vui lòng thử lại.",
|
||||
request_budget_exhausted:
|
||||
"Hệ thống mất quá nhiều thời gian xử lý câu hỏi này. Vui lòng thử lại.",
|
||||
provider_unavailable:
|
||||
"Không thể kết nối dịch vụ AI để tạo câu trả lời lúc này. Vui lòng thử lại sau ít phút.",
|
||||
malformed_output:
|
||||
"Hệ thống nhận được phản hồi không hợp lệ khi tạo câu trả lời. Vui lòng thử lại.",
|
||||
evidence_insufficient:
|
||||
"Dược thư có nội dung liên quan đến câu hỏi này, nhưng hệ thống chưa xác định đủ cơ sở để trả lời chắc chắn. Vui lòng thử lại hoặc nêu rõ hơn câu hỏi.",
|
||||
ungrounded_number:
|
||||
"Hệ thống phát hiện số liệu trong câu trả lời không khớp với nguồn nên đã huỷ để tránh sai sót. Vui lòng thử lại.",
|
||||
invalid_citation:
|
||||
"Hệ thống phát hiện trích dẫn không hợp lệ trong câu trả lời nên đã huỷ để tránh sai sót. Vui lòng thử lại.",
|
||||
uncited_claim:
|
||||
"Hệ thống phát hiện một phần câu trả lời không có trích dẫn nguồn rõ ràng nên đã huỷ để tránh sai sót. Vui lòng thử lại.",
|
||||
unsupported_claim:
|
||||
"Dược thư có nội dung liên quan đến câu hỏi này, nhưng bước đối chiếu lại chưa xác nhận được câu trả lời khớp hoàn toàn với nguồn. Vui lòng thử lại.",
|
||||
unsupported_drug:
|
||||
"Câu trả lời vừa tạo có tên thuốc ngoài tập ứng viên được Dược thư hỗ trợ nên đã bị huỷ để tránh gợi ý không có bằng chứng.",
|
||||
incomplete_answer:
|
||||
"Câu trả lời vừa tạo đã bị huỷ vì bước đối chiếu phát hiện còn bỏ sót dữ kiện liên quan trong nguồn. Vui lòng thử lại để hệ thống tạo câu trả lời đầy đủ hơn.",
|
||||
generation_unavailable:
|
||||
"Dược thư có nội dung liên quan đến câu hỏi này, nhưng hệ thống chưa tạo được câu trả lời đã kiểm chứng đầy đủ (có thể do lỗi tạm thời). Vui lòng bấm Thử lại.",
|
||||
clarify_loop_exhausted:
|
||||
"Hệ thống chưa xác định đủ thông tin sau nhiều lần hỏi lại. Vui lòng gõ lại toàn bộ câu hỏi trong một tin nhắn đầy đủ, hoặc bấm \"Tạo phiên tra cứu mới\".",
|
||||
};
|
||||
|
||||
// Only a truly unclassified reason code reaches this — every abstain path
|
||||
// the backend actually produces has a specific entry above. This must stay
|
||||
// narrow: an unmapped reason silently reading as "no data in the formulary"
|
||||
// is exactly the bug fixed 2026-08-07 (generation_unavailable was falling
|
||||
// through here).
|
||||
export const GENERIC_REFUSAL =
|
||||
"Hệ thống không thể xử lý câu hỏi này lúc này. Vui lòng thử lại.";
|
||||
|
||||
// Mirrors `DISCLAIMER` in `apps/ai-service/rag/answer.py`. ai-service is the
|
||||
// source of truth and normally supplies it; this exists so a version skew
|
||||
// between the two services cannot produce a medical message with no notice
|
||||
// attached. If the wording changes, change it there first.
|
||||
export const FALLBACK_DISCLAIMER =
|
||||
"Nội dung được trích từ Dược thư Quốc gia Việt Nam 2018, phục vụ tra cứu " +
|
||||
"chuyên môn và không thay thế chỉ định của bác sĩ hoặc dược sĩ lâm sàng.";
|
||||
|
||||
// Chunk ids are always `{drug_id}__{section_key}__{part_index}` — drug_id and
|
||||
// section_key use single underscores internally, so splitting on the double
|
||||
// underscore reliably recovers both per citation. This must be derived per
|
||||
// citation, not from the turn's single `resolved_drug_id`: a 2-drug
|
||||
// interaction answer cites both drugs, and stamping every citation with one
|
||||
// drug name would misattribute half of them.
|
||||
//
|
||||
// The backend emits one raw citation per `source_ref` of an evidence block —
|
||||
// a quarantined chunk has both a plain-text ref (where the prose sits) and
|
||||
// an attachment ref (where the table/formula actually sits, which can be a
|
||||
// different physical page than the prose that mentions it — confirmed on
|
||||
// real data, not assumed). Both refs share the same `chunk_id` and the same
|
||||
// `evidence_text`, so they're grouped into ONE card here instead of showing
|
||||
// two near-identical ones — the attachment ref's own page is kept as
|
||||
// `quarantinePhysicalPage` rather than discarded.
|
||||
export function toCitations(raw: RagCitation[]): Citation[] {
|
||||
const byChunk = new Map<string, RagCitation[]>();
|
||||
for (const item of raw) {
|
||||
const group = byChunk.get(item.chunk_id);
|
||||
if (group) group.push(item);
|
||||
else byChunk.set(item.chunk_id, [item]);
|
||||
}
|
||||
|
||||
return Array.from(byChunk.entries()).map(([chunkId, group]) => {
|
||||
const primary = group.find((g) => !g.attachment) ?? group[0];
|
||||
const attachmentRef = group.find((g) => g.attachment);
|
||||
const [drugSlug, chunkSectionKey] = chunkId.split("__");
|
||||
const sectionKey = primary.section_key ?? chunkSectionKey;
|
||||
const isQuarantined = Boolean(attachmentRef);
|
||||
return {
|
||||
chunkId,
|
||||
drugName:
|
||||
primary.drug_name ??
|
||||
(drugSlug ? drugSlug.replace(/_/g, " ").toUpperCase() : chunkId),
|
||||
sectionType: sectionKey ?? "",
|
||||
sourceDocument: primary.source_document ?? "Dược thư Quốc gia Việt Nam 2018",
|
||||
sourcePageRange: [primary.printed_page_start, primary.printed_page_end],
|
||||
physicalPage: primary.physical_page,
|
||||
snippet: primary.evidence_text ?? "",
|
||||
isQuarantined,
|
||||
quarantineNotice: attachmentRef
|
||||
? `Có bảng hoặc công thức tại trang in ${attachmentRef.printed_page_start}${
|
||||
attachmentRef.printed_page_end !== attachmentRef.printed_page_start
|
||||
? `–${attachmentRef.printed_page_end}`
|
||||
: ""
|
||||
} chưa được số hóa tự động — không suy ra số liệu từ đây, cần đối chiếu trực tiếp ảnh PDF gốc.`
|
||||
: undefined,
|
||||
quarantinePhysicalPage: attachmentRef?.physical_page,
|
||||
sourceCropUrl: attachmentRef?.source_crop ?? undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Builds the assistant `ChatMessage` for one turn, live or replayed — same
|
||||
* rules either way: an abstain/no-answer turn never shows citations, and a
|
||||
* missing `answer` text falls back to the reason-specific refusal copy. */
|
||||
export function buildAssistantMessage(
|
||||
rag: RagAnswerRaw,
|
||||
id: string,
|
||||
createdAt: string
|
||||
): ChatMessage {
|
||||
const noAnswer = rag.answer === null;
|
||||
const isAbstain = rag.decision === "abstain";
|
||||
const isGroundedAnswer =
|
||||
rag.decision === "answerable" && !noAnswer && rag.citations.length > 0;
|
||||
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
content: noAnswer ? (REFUSALS[rag.reason] ?? GENERIC_REFUSAL) : (rag.answer ?? GENERIC_REFUSAL),
|
||||
citations: isAbstain || noAnswer ? [] : toCitations(rag.citations),
|
||||
traceId: id,
|
||||
decision: rag.decision,
|
||||
reason: rag.reason,
|
||||
grounded: isGroundedAnswer,
|
||||
generated: isGroundedAnswer ? Boolean(rag.generated) : false,
|
||||
resolvedDrugId: rag.resolved_drug_id ?? undefined,
|
||||
createdAt,
|
||||
quickReplies:
|
||||
rag.decision === "clarify" && rag.quick_replies && rag.quick_replies.length > 0
|
||||
? rag.quick_replies
|
||||
: undefined,
|
||||
blocks:
|
||||
isGroundedAnswer && rag.blocks && rag.blocks.length > 0
|
||||
? toAnswerBlocks(rag.blocks)
|
||||
: undefined,
|
||||
answerMode: rag.answer_mode,
|
||||
answerPlan: rag.answer_plan ? toAnswerPlan(rag.answer_plan) : undefined,
|
||||
candidateAssessments: rag.candidate_assessments?.map((item) => ({
|
||||
drugId: item.drug_id,
|
||||
drugName: item.drug_name,
|
||||
indicationSupported: item.indication_supported,
|
||||
status: item.status,
|
||||
indicationSourceIds: item.indication_source_ids,
|
||||
safetySourceIds: item.safety_source_ids,
|
||||
})),
|
||||
disclaimer: rag.disclaimer?.trim() || FALLBACK_DISCLAIMER,
|
||||
};
|
||||
}
|
||||
+13
-258
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { AnswerBlock, AnswerPlan, Citation, SendMessageResponse } from "@duoc-thu/shared-types";
|
||||
import type { SendMessageResponse } from "@duoc-thu/shared-types";
|
||||
import { buildAssistantMessage, type RagAnswerRaw } from "../_lib/ragResponse";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -12,222 +13,10 @@ export const runtime = "nodejs";
|
||||
// directly and let AI_SERVICE_URL mean what its name says: auth only.
|
||||
const AI_SERVICE_URL = process.env.AI_SERVICE_URL ?? "http://localhost:8000";
|
||||
|
||||
interface RagCitation {
|
||||
chunk_id: string;
|
||||
printed_page_start: number;
|
||||
printed_page_end: number;
|
||||
physical_page: number;
|
||||
block_id?: string | null;
|
||||
bbox?: [number, number, number, number] | null;
|
||||
source_crop?: string | null;
|
||||
attachment?: string | null;
|
||||
evidence_text?: string | null;
|
||||
drug_id?: string | null;
|
||||
drug_name?: string | null;
|
||||
section_key?: string | null;
|
||||
section_title?: string | null;
|
||||
source_document?: string | null;
|
||||
}
|
||||
|
||||
interface RagResponse {
|
||||
interface RagResponse extends RagAnswerRaw {
|
||||
trace_id: string;
|
||||
decision: string;
|
||||
reason: string;
|
||||
answer: string | null;
|
||||
resolved_drug_id: string | null;
|
||||
citations: RagCitation[];
|
||||
generated?: boolean;
|
||||
quick_replies?: string[];
|
||||
blocks?: Array<{
|
||||
title: string;
|
||||
kind: string;
|
||||
claims: Array<{ text: string; source_ids: string[] }>;
|
||||
}>;
|
||||
answer_mode?: "concise" | "normal" | "detailed";
|
||||
/**
|
||||
* Fixed notice set by `rag/answer.py`, never written by the model. Optional
|
||||
* here only so an older ai-service build still parses; the fallback below
|
||||
* keeps the guarantee that a message always carries one.
|
||||
*/
|
||||
disclaimer?: string;
|
||||
answer_plan?: {
|
||||
verbosity: "concise" | "normal" | "detailed";
|
||||
layout: string;
|
||||
reasoning_mode: string;
|
||||
show_heading: boolean;
|
||||
needs_warning: boolean;
|
||||
} | null;
|
||||
candidate_assessments?: Array<{
|
||||
drug_id: string;
|
||||
drug_name: string;
|
||||
indication_supported: boolean;
|
||||
status: string;
|
||||
indication_source_ids: string[];
|
||||
safety_source_ids: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
function toAnswerBlocks(raw: NonNullable<RagResponse["blocks"]>): AnswerBlock[] {
|
||||
return raw.map((block) => ({
|
||||
title: block.title,
|
||||
kind: block.kind,
|
||||
claims: block.claims.map((claim) => ({
|
||||
text: claim.text,
|
||||
sourceIds: claim.source_ids,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function toAnswerPlan(raw: NonNullable<RagResponse["answer_plan"]>): AnswerPlan {
|
||||
return {
|
||||
verbosity: raw.verbosity,
|
||||
layout: raw.layout,
|
||||
reasoningMode: raw.reasoning_mode,
|
||||
showHeading: raw.show_heading,
|
||||
needsWarning: raw.needs_warning,
|
||||
};
|
||||
}
|
||||
|
||||
const REFUSALS: Record<string, string> = {
|
||||
drug_not_resolved:
|
||||
"Chưa xác định được thuốc trong câu hỏi này, nên hệ thống không đưa ra nội dung chuyên môn. Vui lòng nêu rõ tên hoạt chất cần tra cứu (ví dụ: Paracetamol, Amoxicillin...).",
|
||||
drug_resolution_ambiguous:
|
||||
"Câu hỏi có thể ứng với nhiều thuốc khác nhau. Vui lòng nêu rõ tên hoạt chất cần tra cứu.",
|
||||
drug_resolution_invalid_state:
|
||||
"Có lỗi nội bộ khi xác định thuốc trong câu hỏi này. Vui lòng thử lại.",
|
||||
recommendation_out_of_scope:
|
||||
"Đây là câu hỏi xin tư vấn hoặc quyết định điều trị. Hệ thống chỉ tra cứu Dược thư và không đưa ra khuyến cáo điều trị — vui lòng hỏi bác sĩ hoặc dược sĩ.",
|
||||
out_of_scope_non_human:
|
||||
"Dược thư Quốc gia Việt Nam áp dụng cho người. Hệ thống không tra cứu cho đối tượng khác.",
|
||||
subject_scope_unknown:
|
||||
"Chưa rõ câu hỏi áp dụng cho đối tượng nào, nên hệ thống không trả lời.",
|
||||
query_intent_unknown:
|
||||
"Chưa rõ mục đích câu hỏi (tra cứu thông tin hay xin tư vấn điều trị). Vui lòng đặt lại câu hỏi cụ thể hơn.",
|
||||
query_embedding_unavailable:
|
||||
"Chưa tra được mục tương ứng cho câu hỏi này. Vui lòng nêu rõ thuộc tính cần tra (liều dùng, chống chỉ định, tương tác thuốc…).",
|
||||
insufficient_retrieval_score:
|
||||
"Không tìm thấy nội dung đủ liên quan trong Dược thư cho câu hỏi này.",
|
||||
missing_query_or_drug:
|
||||
"Câu hỏi hoặc tên thuốc chưa đủ rõ để tra cứu. Vui lòng nêu rõ tên thuốc và nội dung cần tra.",
|
||||
missing_indication:
|
||||
"Vui lòng nêu rõ triệu chứng hoặc chỉ định cần tra thuốc (ví dụ: sốt, đau đầu).",
|
||||
no_indication_match:
|
||||
"Không tìm thấy thuốc nào trong Dược thư ghi nhận chỉ định phù hợp với triệu chứng này.",
|
||||
parent_hydration_failed:
|
||||
"Có lỗi khi tổng hợp dữ liệu nhiều thuốc trong câu hỏi này. Vui lòng thử lại.",
|
||||
missing_provenance:
|
||||
"Không xác định được nguồn trang cho nội dung này nên hệ thống không thể trích dẫn. Vui lòng thử lại.",
|
||||
missing_printed_page_provenance:
|
||||
"Không xác định được nguồn trang cho nội dung này nên hệ thống không thể trích dẫn. Vui lòng thử lại.",
|
||||
// The backend DID retrieve real evidence for every code below — none of
|
||||
// these are missing-data cases. `rag/answer.py` now propagates the
|
||||
// SPECIFIC safety check that rejected a generation instead of collapsing
|
||||
// them all into "generation_unavailable" (found live 2026-08-07: the
|
||||
// collapsed version made a real provider outage indistinguishable from
|
||||
// ordinary entailment noise, both from here and from server metrics).
|
||||
// Every one of these needs its own entry for the exact reason the
|
||||
// now-fixed `generation_unavailable` case did: an unmapped reason here
|
||||
// silently reads as "no data in the formulary", which is false.
|
||||
request_budget_exhausted:
|
||||
"Hệ thống mất quá nhiều thời gian xử lý câu hỏi này. Vui lòng thử lại.",
|
||||
provider_unavailable:
|
||||
"Không thể kết nối dịch vụ AI để tạo câu trả lời lúc này. Vui lòng thử lại sau ít phút.",
|
||||
malformed_output:
|
||||
"Hệ thống nhận được phản hồi không hợp lệ khi tạo câu trả lời. Vui lòng thử lại.",
|
||||
evidence_insufficient:
|
||||
"Dược thư có nội dung liên quan đến câu hỏi này, nhưng hệ thống chưa xác định đủ cơ sở để trả lời chắc chắn. Vui lòng thử lại hoặc nêu rõ hơn câu hỏi.",
|
||||
ungrounded_number:
|
||||
"Hệ thống phát hiện số liệu trong câu trả lời không khớp với nguồn nên đã huỷ để tránh sai sót. Vui lòng thử lại.",
|
||||
invalid_citation:
|
||||
"Hệ thống phát hiện trích dẫn không hợp lệ trong câu trả lời nên đã huỷ để tránh sai sót. Vui lòng thử lại.",
|
||||
uncited_claim:
|
||||
"Hệ thống phát hiện một phần câu trả lời không có trích dẫn nguồn rõ ràng nên đã huỷ để tránh sai sót. Vui lòng thử lại.",
|
||||
unsupported_claim:
|
||||
"Dược thư có nội dung liên quan đến câu hỏi này, nhưng bước đối chiếu lại chưa xác nhận được câu trả lời khớp hoàn toàn với nguồn. Vui lòng thử lại.",
|
||||
unsupported_drug:
|
||||
"Câu trả lời vừa tạo có tên thuốc ngoài tập ứng viên được Dược thư hỗ trợ nên đã bị huỷ để tránh gợi ý không có bằng chứng.",
|
||||
incomplete_answer:
|
||||
"Câu trả lời vừa tạo đã bị huỷ vì bước đối chiếu phát hiện còn bỏ sót dữ kiện liên quan trong nguồn. Vui lòng thử lại để hệ thống tạo câu trả lời đầy đủ hơn.",
|
||||
// Kept as the fallback `answer.py` itself falls back to when, for some
|
||||
// reason, none of the specific codes above was set.
|
||||
generation_unavailable:
|
||||
"Dược thư có nội dung liên quan đến câu hỏi này, nhưng hệ thống chưa tạo được câu trả lời đã kiểm chứng đầy đủ (có thể do lỗi tạm thời). Vui lòng bấm Thử lại.",
|
||||
// `agent.py`'s clarify-loop circuit breaker (found live 2026-08-07: the
|
||||
// understanding LLM could re-ask the same clarifying question forever,
|
||||
// reproduced 3 times independently, one case never converged after 5 real
|
||||
// turns). The backend always supplies its own `answer` text for this
|
||||
// reason, so this entry is a fallback only.
|
||||
clarify_loop_exhausted:
|
||||
"Hệ thống chưa xác định đủ thông tin sau nhiều lần hỏi lại. Vui lòng gõ lại toàn bộ câu hỏi trong một tin nhắn đầy đủ, hoặc bấm \"Tạo phiên tra cứu mới\".",
|
||||
};
|
||||
|
||||
// Only a truly unclassified reason code reaches this — every abstain path
|
||||
// the backend actually produces (see rag/routing.py, rag/service.py,
|
||||
// rag/answer.py) has a specific entry above. This must stay narrow: an
|
||||
// unmapped reason silently reading as "no data in the formulary" is exactly
|
||||
// the bug fixed 2026-08-07 (generation_unavailable was falling through here).
|
||||
const GENERIC_REFUSAL =
|
||||
"Hệ thống không thể xử lý câu hỏi này lúc này. Vui lòng thử lại.";
|
||||
|
||||
// Mirrors `DISCLAIMER` in `apps/ai-service/rag/answer.py`. ai-service is the
|
||||
// source of truth and normally supplies it; this exists so a version skew
|
||||
// between the two services cannot produce a medical message with no notice
|
||||
// attached. If the wording changes, change it there first.
|
||||
const FALLBACK_DISCLAIMER =
|
||||
"Nội dung được trích từ Dược thư Quốc gia Việt Nam 2018, phục vụ tra cứu " +
|
||||
"chuyên môn và không thay thế chỉ định của bác sĩ hoặc dược sĩ lâm sàng.";
|
||||
|
||||
// Chunk ids are always `{drug_id}__{section_key}__{part_index}` — drug_id and
|
||||
// section_key use single underscores internally, so splitting on the double
|
||||
// underscore reliably recovers both per citation. This must be derived per
|
||||
// citation, not from the turn's single `resolved_drug_id`: a 2-drug
|
||||
// interaction answer cites both drugs, and stamping every citation with one
|
||||
// drug name would misattribute half of them.
|
||||
//
|
||||
// The backend emits one raw citation per `source_ref` of an evidence block —
|
||||
// a quarantined chunk has both a plain-text ref (where the prose sits) and
|
||||
// an attachment ref (where the table/formula actually sits, which can be a
|
||||
// different physical page than the prose that mentions it — confirmed on
|
||||
// real data, not assumed). Both refs share the same `chunk_id` and the same
|
||||
// `evidence_text`, so they're grouped into ONE card here instead of showing
|
||||
// two near-identical ones — the attachment ref's own page is kept as
|
||||
// `quarantinePhysicalPage` rather than discarded.
|
||||
function toCitations(raw: RagCitation[]): Citation[] {
|
||||
const byChunk = new Map<string, RagCitation[]>();
|
||||
for (const item of raw) {
|
||||
const group = byChunk.get(item.chunk_id);
|
||||
if (group) group.push(item);
|
||||
else byChunk.set(item.chunk_id, [item]);
|
||||
}
|
||||
|
||||
return Array.from(byChunk.entries()).map(([chunkId, group]) => {
|
||||
const primary = group.find((g) => !g.attachment) ?? group[0];
|
||||
const attachmentRef = group.find((g) => g.attachment);
|
||||
const [drugSlug, chunkSectionKey] = chunkId.split("__");
|
||||
const sectionKey = primary.section_key ?? chunkSectionKey;
|
||||
const isQuarantined = Boolean(attachmentRef);
|
||||
return {
|
||||
chunkId,
|
||||
drugName:
|
||||
primary.drug_name ??
|
||||
(drugSlug ? drugSlug.replace(/_/g, " ").toUpperCase() : chunkId),
|
||||
sectionType: sectionKey ?? "",
|
||||
sourceDocument: primary.source_document ?? "Dược thư Quốc gia Việt Nam 2018",
|
||||
sourcePageRange: [primary.printed_page_start, primary.printed_page_end],
|
||||
physicalPage: primary.physical_page,
|
||||
snippet: primary.evidence_text ?? "",
|
||||
isQuarantined,
|
||||
quarantineNotice: attachmentRef
|
||||
? `Có bảng hoặc công thức tại trang in ${attachmentRef.printed_page_start}${
|
||||
attachmentRef.printed_page_end !== attachmentRef.printed_page_start
|
||||
? `–${attachmentRef.printed_page_end}`
|
||||
: ""
|
||||
} chưa được số hóa tự động — không suy ra số liệu từ đây, cần đối chiếu trực tiếp ảnh PDF gốc.`
|
||||
: undefined,
|
||||
quarantinePhysicalPage: attachmentRef?.physical_page,
|
||||
sourceCropUrl: attachmentRef?.source_crop ?? undefined,
|
||||
};
|
||||
});
|
||||
correlation_id?: string;
|
||||
otel_trace_id?: string | null;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -323,48 +112,14 @@ export async function POST(request: Request) {
|
||||
// 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 isGroundedAnswer =
|
||||
rag.decision === "answerable" && !noAnswer && rag.citations.length > 0;
|
||||
const message: SendMessageResponse["message"] = {
|
||||
id: rag.trace_id || `msg-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: noAnswer ? (REFUSALS[rag.reason] ?? GENERIC_REFUSAL) : (rag.answer ?? GENERIC_REFUSAL),
|
||||
citations: isAbstain || noAnswer ? [] : toCitations(rag.citations),
|
||||
traceId: rag.trace_id,
|
||||
decision: rag.decision,
|
||||
reason: rag.reason,
|
||||
grounded: isGroundedAnswer,
|
||||
generated: isGroundedAnswer ? Boolean(rag.generated) : false,
|
||||
resolvedDrugId: rag.resolved_drug_id ?? undefined,
|
||||
createdAt: new Date().toISOString(),
|
||||
quickReplies:
|
||||
rag.decision === "clarify" && rag.quick_replies && rag.quick_replies.length > 0
|
||||
? rag.quick_replies
|
||||
: undefined,
|
||||
blocks:
|
||||
isGroundedAnswer && rag.blocks && rag.blocks.length > 0
|
||||
? toAnswerBlocks(rag.blocks)
|
||||
: undefined,
|
||||
answerMode: rag.answer_mode,
|
||||
answerPlan: rag.answer_plan ? toAnswerPlan(rag.answer_plan) : undefined,
|
||||
candidateAssessments: rag.candidate_assessments?.map((item) => ({
|
||||
drugId: item.drug_id,
|
||||
drugName: item.drug_name,
|
||||
indicationSupported: item.indication_supported,
|
||||
status: item.status,
|
||||
indicationSourceIds: item.indication_source_ids,
|
||||
safetySourceIds: item.safety_source_ids,
|
||||
})),
|
||||
// Carried on every message, including abstains and clarifications: those
|
||||
// are clinical responses too. The local fallback covers an ai-service
|
||||
// that predates the field, so the guarantee does not depend on both
|
||||
// sides being deployed together.
|
||||
disclaimer: rag.disclaimer?.trim() || FALLBACK_DISCLAIMER,
|
||||
};
|
||||
// REFUSALS/GENERIC_REFUSAL (in `_lib/ragResponse`) are now purely the
|
||||
// fallback for the genuinely answer-less case (retrieval abstained with
|
||||
// no message to show).
|
||||
const message: SendMessageResponse["message"] = buildAssistantMessage(
|
||||
rag,
|
||||
rag.trace_id || `msg-${Date.now()}`,
|
||||
new Date().toISOString()
|
||||
);
|
||||
|
||||
const responseHeaders = new Headers({
|
||||
"X-Correlation-ID": responseCorrelationId,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { ChatMessage } from "@duoc-thu/shared-types";
|
||||
import { buildAssistantMessage, type RagAnswerRaw } from "../_lib/ragResponse";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// Same resolution rule as `/api/chat` and `/api/history` — RAG never goes
|
||||
// through api-gateway (it only proxies `/auth/*`), so this always talks to
|
||||
// ai-service directly.
|
||||
const AI_SERVICE_URL = process.env.AI_SERVICE_URL ?? "http://localhost:8000";
|
||||
|
||||
interface TranscriptMessageRaw {
|
||||
role: "user" | "assistant";
|
||||
trace_id: string;
|
||||
content: string | null;
|
||||
decision?: string | null;
|
||||
reason?: string | null;
|
||||
resolved_drug_id?: string | null;
|
||||
citations?: RagAnswerRaw["citations"];
|
||||
generated?: boolean;
|
||||
quick_replies?: string[];
|
||||
blocks?: RagAnswerRaw["blocks"];
|
||||
answer_mode?: RagAnswerRaw["answer_mode"];
|
||||
answer_plan?: RagAnswerRaw["answer_plan"];
|
||||
candidate_assessments?: RagAnswerRaw["candidate_assessments"];
|
||||
disclaimer?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface TranscriptResponseRaw {
|
||||
messages: TranscriptMessageRaw[];
|
||||
}
|
||||
|
||||
function toChatMessage(row: TranscriptMessageRaw): ChatMessage {
|
||||
if (row.role === "user") {
|
||||
return {
|
||||
id: `user-${row.trace_id}`,
|
||||
role: "user",
|
||||
content: row.content ?? "",
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
// Reuses the exact live-turn mapping (`/api/chat`'s `buildAssistantMessage`)
|
||||
// so a replayed answer renders identically to the one the user originally
|
||||
// saw — same abstain/refusal fallback, same citation grouping.
|
||||
const raw: RagAnswerRaw = {
|
||||
decision: row.decision ?? "answerable",
|
||||
reason: row.reason ?? "",
|
||||
answer: row.content,
|
||||
resolved_drug_id: row.resolved_drug_id ?? null,
|
||||
citations: row.citations ?? [],
|
||||
generated: row.generated,
|
||||
quick_replies: row.quick_replies,
|
||||
blocks: row.blocks,
|
||||
answer_mode: row.answer_mode,
|
||||
answer_plan: row.answer_plan,
|
||||
candidate_assessments: row.candidate_assessments,
|
||||
disclaimer: row.disclaimer,
|
||||
};
|
||||
return buildAssistantMessage(raw, row.trace_id, row.created_at);
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const conversationId = searchParams.get("conversation_id")?.trim() || "";
|
||||
|
||||
if (!conversationId) {
|
||||
return NextResponse.json({ messages: [] });
|
||||
}
|
||||
|
||||
try {
|
||||
const base = AI_SERVICE_URL.replace(/\/v1\/rag(?:\/query)?\/?$/, "");
|
||||
const targetUrl = `${base}/v1/rag/transcript?conversation_id=${encodeURIComponent(conversationId)}`;
|
||||
|
||||
const upstream = await fetch(targetUrl, {
|
||||
method: "GET",
|
||||
headers: { "X-Client-Version": "1.0.0" },
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json({ messages: [] });
|
||||
}
|
||||
|
||||
const data = (await upstream.json()) as TranscriptResponseRaw;
|
||||
const messages = (data.messages ?? []).map(toChatMessage);
|
||||
return NextResponse.json({ messages });
|
||||
} catch {
|
||||
return NextResponse.json({ messages: [] });
|
||||
}
|
||||
}
|
||||
+75
-12
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ChatMessage, Citation, MonographPickerState } from "@duoc-thu/shared-types";
|
||||
import { ChatPanel } from "./_components/ChatPanel";
|
||||
import { Sidebar, ChatSession } from "./_components/Sidebar";
|
||||
@@ -12,14 +12,13 @@ function createSessionId() {
|
||||
return `session-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
// Feature-List #25: the session id is the key `/v1/rag/history` scopes its
|
||||
// listing by, so persisting it is a prerequisite for history surviving a
|
||||
// refresh at all — not just a UX nicety. Chat MESSAGE CONTENT is
|
||||
// deliberately NOT persisted here (server never stores generated answer
|
||||
// text/blocks either, only decision/reason metadata — see
|
||||
// `PostgresTraceRepository.list_by_conversation`'s docstring): a resumed
|
||||
// session's transcript starts empty, matching the spec's own "re-run the
|
||||
// query" wording rather than "replay the old answer".
|
||||
// The session id is the key both `/api/history` (quick-rerun list) and
|
||||
// `/api/transcript` (full replay) scope their reads by, so persisting it is
|
||||
// a prerequisite for either surviving a refresh at all. The session LIST
|
||||
// itself still lives only in this browser's localStorage — there is no
|
||||
// auth yet to scope a server-side list by, so a different browser/device
|
||||
// (or cleared storage) loses the sidebar entries even though the
|
||||
// transcripts themselves are still in Postgres, keyed by the same id.
|
||||
const SESSIONS_STORAGE_KEY = "dt_sessions";
|
||||
const CURRENT_SESSION_STORAGE_KEY = "dt_current_session_id";
|
||||
|
||||
@@ -34,6 +33,20 @@ function loadStoredSessions(): ChatSession[] | null {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SESSION_TITLE = "Phiên tra cứu mới";
|
||||
|
||||
// Real usage testing 2026-08-20: a session's sidebar entry stayed
|
||||
// "Phiên tra cứu mới" forever (every session, indistinguishable) and
|
||||
// picking one loaded an empty chat with no way to continue it — the spec's
|
||||
// "history is a re-run shortcut, not a replay" call turned out to read as
|
||||
// broken, not minimal. `/api/transcript` now persists and replays the full
|
||||
// turn (both question and answer), so a session title can be derived from
|
||||
// its own first question instead of staying a placeholder forever.
|
||||
function deriveTitle(text: string): string {
|
||||
const trimmed = text.trim().replace(/\s+/g, " ");
|
||||
return trimmed.length > 48 ? `${trimmed.slice(0, 48)}…` : trimmed;
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string>("");
|
||||
@@ -41,6 +54,38 @@ export default function ChatPage() {
|
||||
const [queryOverride, setQueryOverride] = useState<{ text: string; token: number } | null>(null);
|
||||
const [monographPicker, setMonographPicker] = useState<MonographPickerState | null>(null);
|
||||
|
||||
// Guards `hydrateSession` to one fetch per session per page load: without
|
||||
// it, switching back and forth between two sessions would re-fetch (and
|
||||
// briefly flash) every time, and a session with messages just sent this
|
||||
// visit could get clobbered by a fetch racing behind it.
|
||||
const hydratedSessionIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const hydrateSession = (id: string) => {
|
||||
if (hydratedSessionIdsRef.current.has(id)) return;
|
||||
hydratedSessionIdsRef.current.add(id);
|
||||
fetch(`/api/transcript?conversation_id=${encodeURIComponent(id)}`)
|
||||
.then((res) => (res.ok ? res.json() : { messages: [] }))
|
||||
.then((data: { messages?: ChatMessage[] }) => {
|
||||
const messages = Array.isArray(data.messages) ? data.messages : [];
|
||||
if (messages.length === 0) return;
|
||||
setMessagesBySession((prev) => ({ ...prev, [id]: messages }));
|
||||
const firstUser = messages.find((m) => m.role === "user");
|
||||
if (firstUser) {
|
||||
setSessions((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === id && s.title === DEFAULT_SESSION_TITLE
|
||||
? { ...s, title: deriveTitle(firstUser.content) }
|
||||
: s
|
||||
)
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort — the session just starts blank, same as before this
|
||||
// feature existed, rather than blocking on a slow/down ai-service.
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const stored = loadStoredSessions();
|
||||
if (stored) {
|
||||
@@ -53,13 +98,17 @@ export default function ChatPage() {
|
||||
})();
|
||||
setSessions(stored);
|
||||
setMessagesBySession(Object.fromEntries(stored.map((s) => [s.id, []])));
|
||||
setCurrentSessionId(stored.find((s) => s.id === storedCurrent)?.id ?? stored[0].id);
|
||||
const resolvedId = stored.find((s) => s.id === storedCurrent)?.id ?? stored[0].id;
|
||||
setCurrentSessionId(resolvedId);
|
||||
hydrateSession(resolvedId);
|
||||
return;
|
||||
}
|
||||
const id = createSessionId();
|
||||
setSessions([{ id, title: "Phiên tra cứu mới", updatedAt: new Date().toISOString() }]);
|
||||
setSessions([{ id, title: DEFAULT_SESSION_TITLE, updatedAt: new Date().toISOString() }]);
|
||||
setMessagesBySession({ [id]: [] });
|
||||
setCurrentSessionId(id);
|
||||
hydratedSessionIdsRef.current.add(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Persist whenever the session list / active session changes — covers
|
||||
@@ -98,12 +147,15 @@ export default function ChatPage() {
|
||||
const newId = createSessionId();
|
||||
const newSession: ChatSession = {
|
||||
id: newId,
|
||||
title: "Phiên tra cứu mới",
|
||||
title: DEFAULT_SESSION_TITLE,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
setSessions((prev) => [newSession, ...prev]);
|
||||
setMessagesBySession((prev) => ({ ...prev, [newId]: [] }));
|
||||
setCurrentSessionId(newId);
|
||||
// Nothing on the server yet for a brand-new session — skip the wasted
|
||||
// round trip `hydrateSession` would otherwise make on first select.
|
||||
hydratedSessionIdsRef.current.add(newId);
|
||||
setQueryOverride(null);
|
||||
setCitations([]);
|
||||
setActiveCitationIndex(null);
|
||||
@@ -113,6 +165,7 @@ export default function ChatPage() {
|
||||
|
||||
const handleSelectSession = (id: string) => {
|
||||
setCurrentSessionId(id);
|
||||
hydrateSession(id);
|
||||
setQueryOverride(null);
|
||||
setCitations([]);
|
||||
setActiveCitationIndex(null);
|
||||
@@ -131,6 +184,7 @@ export default function ChatPage() {
|
||||
const remaining = sessions.filter((s) => s.id !== id);
|
||||
if (remaining.length > 0) {
|
||||
setCurrentSessionId(remaining[0].id);
|
||||
hydrateSession(remaining[0].id);
|
||||
} else {
|
||||
handleNewChat();
|
||||
}
|
||||
@@ -148,6 +202,15 @@ export default function ChatPage() {
|
||||
setMessagesBySession((prev) => {
|
||||
const existing = prev[sessionId] ?? [];
|
||||
const next = typeof update === "function" ? update(existing) : update;
|
||||
// Title the session off its own first question immediately, rather
|
||||
// than leaving every session as the identical, indistinguishable
|
||||
// placeholder until a later reload re-fetches the transcript.
|
||||
if (existing.length === 0 && next.length > 0 && next[0].role === "user") {
|
||||
const title = deriveTitle(next[0].content);
|
||||
setSessions((prevSessions) =>
|
||||
prevSessions.map((s) => (s.id === sessionId ? { ...s, title } : s))
|
||||
);
|
||||
}
|
||||
return { ...prev, [sessionId]: next };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Langfuse (LLM observability / eval viewer) — reads OTel-style traces and
|
||||
# Ragas eval scores, not part of the RAG chat path itself.
|
||||
#
|
||||
# Multi-source Application: source[0] is the upstream `langfuse/langfuse-k8s`
|
||||
# chart (not vendored into this repo); source[1] is this repo, providing only
|
||||
# the override values file via the `$values` ref. Same disaster-recovery
|
||||
# property as the other two Applications here: applying this file recreates
|
||||
# the Application's structure, no follow-up step needed since this release
|
||||
# carries no inline secrets (see infra/helm/langfuse/values-production.yaml
|
||||
# for why: the app's own SALT/ENCRYPTION_KEY/NEXTAUTH_SECRET are chart-
|
||||
# auto-generated on first install, and the Postgres password is read from
|
||||
# the medical-chatbot-data release's existing Secret by name, never
|
||||
# duplicated here).
|
||||
#
|
||||
# Deliberately targets the `medical-chatbot-data` namespace, not its own —
|
||||
# see the values file's header comment for why (Secrets don't cross
|
||||
# namespaces, and this needs the Postgres one that already lives there).
|
||||
# This Application does NOT own the PersistentVolumeClaims that release
|
||||
# depends on (postgres/qdrant) — it only adds new resources (its own
|
||||
# ClickHouse/Redis/web/worker) into the same namespace. `prune: true` below
|
||||
# only prunes resources Langfuse's own chart previously created, standard
|
||||
# ArgoCD behavior scoped to this Application's own tracked resources.
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: langfuse
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: default
|
||||
sources:
|
||||
- repoURL: https://langfuse.github.io/langfuse-k8s
|
||||
chart: langfuse
|
||||
targetRevision: 2.0.0
|
||||
helm:
|
||||
valueFiles:
|
||||
- $values/infra/helm/langfuse/values-production.yaml
|
||||
- repoURL: https://github.com/BaoVu2k4/vsf-duocthu.git
|
||||
targetRevision: master
|
||||
ref: values
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: medical-chatbot-data
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
@@ -0,0 +1,118 @@
|
||||
# Production values for the `langfuse` ArgoCD Application (upstream
|
||||
# `langfuse/langfuse-k8s` chart, not this repo's own medical-chatbot chart).
|
||||
#
|
||||
# Runs in the `medical-chatbot-data` namespace deliberately, not its own —
|
||||
# that is the only namespace holding the K8s Secret with the shared Postgres
|
||||
# password, and Secrets don't cross namespaces. Langfuse never reads that
|
||||
# password directly: everything below points at the secret BY NAME/KEY
|
||||
# (`medical-chatbot-data-medical-chatbot-runtime` / `postgres-password`), the
|
||||
# same way infra/helm/medical-chatbot's own templates do it.
|
||||
#
|
||||
# Deliberately bundled rather than external: Redis and ClickHouse (personal-
|
||||
# scale traffic, no case for a managed service here). Deliberately external:
|
||||
# Postgres (reuse what already exists rather than a second instance) and S3
|
||||
# (a real bucket, `duocthu-langfuse-blobs`, reached via the k3s node's IAM
|
||||
# instance role — no static access keys anywhere in this file or the cluster,
|
||||
# same pattern as ai-service's Bedrock access).
|
||||
#
|
||||
# SALT / ENCRYPTION_KEY / NEXTAUTH_SECRET are deliberately absent: the chart
|
||||
# auto-generates and persists them in a release-managed Secret on first
|
||||
# install when left unset. No manual secret entry needed for this file.
|
||||
|
||||
langfuse:
|
||||
resources:
|
||||
requests: { cpu: 100m, memory: 256Mi }
|
||||
limits: { cpu: "1", memory: 512Mi }
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: traefik
|
||||
hosts:
|
||||
- host: langfuse.realvuxbaro.me
|
||||
paths:
|
||||
- path: /
|
||||
pathType: ImplementationSpecific
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||
tls:
|
||||
enabled: true
|
||||
secretName: langfuse-tls
|
||||
|
||||
nextauth:
|
||||
url: https://langfuse.realvuxbaro.me
|
||||
|
||||
postgresql:
|
||||
deploy: false
|
||||
host: medical-chatbot-data-medical-chatbot-postgres.medical-chatbot-data.svc.cluster.local
|
||||
port: 5432
|
||||
auth:
|
||||
username: duoc_thu
|
||||
existingSecret: medical-chatbot-data-medical-chatbot-runtime
|
||||
secretKeys:
|
||||
userPasswordKey: postgres-password
|
||||
database: langfuse
|
||||
|
||||
redis:
|
||||
deploy: true
|
||||
resources:
|
||||
requests: { cpu: 50m, memory: 64Mi }
|
||||
limits: { cpu: 250m, memory: 256Mi }
|
||||
|
||||
clickhouse:
|
||||
deploy: true
|
||||
cluster:
|
||||
enabled: false
|
||||
storage:
|
||||
size: 20Gi
|
||||
|
||||
s3:
|
||||
deploy: false
|
||||
storageProvider: s3
|
||||
bucket: duocthu-langfuse-blobs
|
||||
region: us-east-1
|
||||
endpoint: https://s3.us-east-1.amazonaws.com
|
||||
forcePathStyle: false
|
||||
# Both left empty on purpose: falls back to the AWS SDK default credential
|
||||
# chain, which picks up the node's IAM instance role automatically.
|
||||
accessKeyId:
|
||||
value: ""
|
||||
secretAccessKey:
|
||||
value: ""
|
||||
|
||||
# One-off Job that creates the `langfuse` database on the existing Postgres
|
||||
# instance before Langfuse's own migration runs. Idempotent (checks first).
|
||||
# Never touches infra/helm/medical-chatbot's own templates or its release.
|
||||
extraManifests:
|
||||
- apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: langfuse-db-init
|
||||
annotations:
|
||||
"helm.sh/hook": pre-install,pre-upgrade
|
||||
"helm.sh/hook-weight": "-5"
|
||||
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
|
||||
spec:
|
||||
backoffLimit: 3
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: create-db
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
- name: PGPASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: medical-chatbot-data-medical-chatbot-runtime
|
||||
key: postgres-password
|
||||
command: ["sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
set -e
|
||||
HOST=medical-chatbot-data-medical-chatbot-postgres.medical-chatbot-data.svc.cluster.local
|
||||
EXISTS=$(psql "postgresql://duoc_thu@$HOST:5432/duoc_thu" -tAc \
|
||||
"SELECT 1 FROM pg_database WHERE datname = 'langfuse'")
|
||||
if [ "$EXISTS" != "1" ]; then
|
||||
psql "postgresql://duoc_thu@$HOST:5432/duoc_thu" -c "CREATE DATABASE langfuse OWNER duoc_thu"
|
||||
fi
|
||||
Reference in New Issue
Block a user