Make a Langfuse trace worth opening: question, answer, session, no probe noise
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
"""Post scores to a Langfuse project, or do nothing at all.
|
||||
|
||||
Traces already reach Langfuse as OTLP spans (see rag/telemetry.py). A *score*
|
||||
is the other half: it attaches a number to one of those traces, which is what
|
||||
turns Langfuse from a trace viewer into a place where answer quality is
|
||||
visible over time.
|
||||
|
||||
Two producers exist:
|
||||
- `scripts/score_evals_ragas.py` posts Ragas metrics for eval runs.
|
||||
- This adapter posts the user's thumb up/down from `/v1/rag/feedback`,
|
||||
which is the only signal in the whole system that comes from a human
|
||||
rather than a model judging a model.
|
||||
|
||||
Deliberately best-effort. Feedback is stored in Postgres first and that write
|
||||
is what must not fail; a Langfuse outage may not turn a working thumbs-up into
|
||||
a 503 for the user. Every failure here is swallowed and counted, never raised.
|
||||
|
||||
Scores attach by OpenTelemetry trace id (32 hex), NOT by the `trace_id` UUID
|
||||
that identifies the Postgres row -- Langfuse has never seen that one. The
|
||||
caller is responsible for looking the OTel id up.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
|
||||
class LangfuseScoreClient:
|
||||
"""Minimal client: one endpoint, one verb, no dependency on the SDK."""
|
||||
|
||||
def __init__(self, base_url: str, public_key: str, secret_key: str, timeout: float = 5.0):
|
||||
self.endpoint = f"{base_url.rstrip('/')}/api/public/scores"
|
||||
token = base64.b64encode(f"{public_key}:{secret_key}".encode()).decode()
|
||||
self._auth = f"Basic {token}"
|
||||
self._timeout = timeout
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, settings: Any) -> "LangfuseScoreClient | None":
|
||||
"""Return a client, or None when Langfuse is not fully configured.
|
||||
|
||||
Same all-or-nothing rule as the span exporter: a base URL without keys
|
||||
would authenticate nothing, so treat a partial config as "off" rather
|
||||
than posting requests that can only 401.
|
||||
"""
|
||||
base_url = str(getattr(settings, "langfuse_base_url", "") or "").strip()
|
||||
public_key = str(getattr(settings, "langfuse_public_key", "") or "").strip()
|
||||
secret_key = str(getattr(settings, "langfuse_secret_key", "") or "").strip()
|
||||
if not (base_url and public_key and secret_key):
|
||||
return None
|
||||
return cls(base_url, public_key, secret_key)
|
||||
|
||||
def post_score(
|
||||
self,
|
||||
*,
|
||||
otel_trace_id: str,
|
||||
name: str,
|
||||
value: float,
|
||||
comment: str | None = None,
|
||||
) -> bool:
|
||||
"""True if Langfuse accepted the score. Never raises."""
|
||||
body: dict[str, Any] = {
|
||||
"traceId": otel_trace_id,
|
||||
"name": name,
|
||||
"value": value,
|
||||
"dataType": "NUMERIC",
|
||||
}
|
||||
if comment:
|
||||
body["comment"] = comment
|
||||
request = urllib.request.Request(
|
||||
self.endpoint,
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json", "Authorization": self._auth},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self._timeout):
|
||||
return True
|
||||
except Exception: # noqa: BLE001 - see module docstring: never raise
|
||||
return False
|
||||
@@ -191,6 +191,28 @@ class PostgresTraceRepository:
|
||||
raise FeedbackTraceNotFound(trace_id)
|
||||
return str(row[0])
|
||||
|
||||
def otel_trace_id_for(self, trace_id: str) -> str | None:
|
||||
"""The OpenTelemetry id of a persisted answer, for attaching a score.
|
||||
|
||||
Separate from `save_feedback` rather than folded into its RETURNING
|
||||
clause: that method's `-> str` shape is part of the `TraceWriter`
|
||||
protocol the router depends on, and widening it to carry an unrelated
|
||||
id would make every implementation and test carry it too. Feedback is
|
||||
rare enough that a second short query costs nothing.
|
||||
|
||||
None when the row has no OTel id -- true for every answer produced
|
||||
while tracing was off, which is most of the history predating
|
||||
2026-08-21. Callers skip posting rather than inventing one.
|
||||
"""
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(self._dsn, connect_timeout=5) as connection:
|
||||
row = connection.execute(
|
||||
"SELECT otel_trace_id FROM rag_retrieval_trace WHERE trace_id = %s",
|
||||
(trace_id,),
|
||||
).fetchone()
|
||||
return str(row[0]) if row and row[0] else None
|
||||
|
||||
|
||||
class PostgresConversationStore:
|
||||
"""Durable, cross-worker replacement for `RagAgent`'s in-process
|
||||
|
||||
Reference in New Issue
Block a user