Make a Langfuse trace worth opening: question, answer, session, no probe noise

This commit is contained in:
2026-08-21 14:45:32 +07:00
parent 53582b6030
commit f3eaab0948
16 changed files with 733 additions and 5 deletions
+81
View File
@@ -161,9 +161,24 @@ def _section_retriever(request: Request) -> SectionListRetriever:
router = APIRouter(prefix="/v1/rag", tags=["rag"])
def _clip(text: str | None, limit: int = 2000) -> str:
"""Bounded text for a span attribute.
Span attributes are not a place for unbounded content: every answer would
otherwise be sent twice per turn (Tempo and Langfuse) and stored forever.
2000 characters is past the end of a normal formulary answer, so this
almost never truncates -- it exists for the pathological case.
"""
value = (text or "").strip()
return value if len(value) <= limit else value[: limit - 1] + ""
@router.post("/feedback", response_model=RagFeedbackResponse)
def save_feedback(
payload: RagFeedbackRequest,
request: Request,
traces: Annotated[TraceWriter, Depends(_trace_writer)],
) -> RagFeedbackResponse:
comment = payload.comment.strip() if payload.comment else None
@@ -178,9 +193,53 @@ def save_feedback(
raise HTTPException(status_code=404, detail="trace_not_found") from exc
except Exception as exc:
raise HTTPException(status_code=503, detail="feedback_store_unavailable") from exc
_mirror_feedback_to_langfuse(request, traces, str(payload.trace_id), payload.rating, comment)
return RagFeedbackResponse(feedback_id=feedback_id)
def _mirror_feedback_to_langfuse(
request: Request,
traces: TraceWriter,
trace_id: str,
rating: str,
comment: str | None,
) -> None:
"""Attach the user's verdict to its Langfuse trace, or quietly do nothing.
This is the only quality signal in the system that comes from a human
instead of a model judging a model, so it is worth having next to the
Ragas scores rather than only in Postgres.
Runs AFTER the Postgres write and cannot affect its outcome: the durable
record is what matters, and a Langfuse outage must never turn a working
thumbs-up into a 503. Everything here is guarded -- a missing client, an
answer from before tracing was on, a database hiccup on the lookup, or a
refused post all end the same way: silently.
1.0/0.0 rather than a label so it averages: the mean of `user_feedback`
over a period reads directly as "share of answers users found helpful".
"""
client = getattr(request.app.state, "langfuse_scores", None)
if client is None:
return
lookup = getattr(traces, "otel_trace_id_for", None)
if lookup is None:
return
try:
otel_trace_id = lookup(trace_id)
except Exception: # noqa: BLE001 - feedback is already safely stored
return
if not otel_trace_id:
return
client.post_score(
otel_trace_id=otel_trace_id,
name="user_feedback",
value=1.0 if rating == "helpful" else 0.0,
comment=comment,
)
class HistoryItem(BaseModel):
trace_id: str
query: str
@@ -614,6 +673,28 @@ def query_rag(
"duocthu.citation_count": len(citations),
"duocthu.generated": generated,
"duocthu.persisted_trace_id": trace_id,
# Langfuse-mapped names (see its OpenTelemetry attribute mapping).
# Without these a trace shows stage timings but neither the
# question nor the answer, which is most of what makes a trace
# worth opening -- and scores attached to it unreadable, since
# "faithfulness 0.42" means nothing without the answer it judged.
#
# NOTE this is a deliberate change of posture: these spans also go
# to Tempo, and the README previously stated that full prompts and
# responses are not stored. Both destinations are self-hosted on
# this cluster and the corpus is a published formulary, so the
# exposure is bounded -- but it is a change, not an oversight, and
# the text is truncated rather than unbounded.
"langfuse.trace.name": f"rag.{decision}",
"langfuse.observation.input": _clip(payload.query),
"langfuse.observation.output": _clip(answer),
"langfuse.trace.metadata.decision": decision,
"langfuse.trace.metadata.reason": reason,
"langfuse.trace.metadata.resolved_drug_id": resolved_drug_id or "",
# Groups a multi-turn conversation into one Langfuse session, so a
# clarify turn and the answer that follows it read together
# instead of as two unrelated traces.
"langfuse.session.id": payload.conversation_id or "",
}
)
with stage("response"):