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
+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)