Add Langfuse as a self-hosted eval and trace viewer

This commit is contained in:
2026-08-21 10:35:25 +07:00
parent 4490a1abf0
commit 53582b6030
12 changed files with 1129 additions and 280 deletions
+23 -10
View File
@@ -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
]
+141
View File
@@ -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;
+98
View File
@@ -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()))
+89
View File
@@ -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",