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",
|
||||
|
||||
Reference in New Issue
Block a user