Make a Langfuse trace worth opening: question, answer, session, no probe noise
This commit is contained in:
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user