Skip to content

Commit bcb41f1

Browse files
feat(score_traces): offline scoring of production traces with the Opik SDK (#61)
## What A new opik-examples entry — `scripts/score_traces/` — that scores **existing production traces** in an Opik project offline and logs the feedback scores (with reasons) back onto those traces, using the same judge/metric classes and variable mappings an online evaluation rule would use. Designed so teams whose GenAI gateway isn't yet reachable from the Opik platform can run evals on a cron now and migrate to online rules later with **no re-authoring** (same score names, same variable mapping). ## How it works Composes three public SDK primitives in a loop: `search_traces` → `metric.score()` → `log_traces_feedback_scores` (one batched write). - `model.py` — judge-model / gateway wiring (LiteLLM; importable without a gateway) - `metrics.py` — the `EVALS` list: `hallucination` (preset), `relevance` (G-Eval), `exact_match` (custom `BaseMetric`) - `paths.py` — resolves a dotted `variables` mapping into each metric's `score()` kwargs - `score_traces.py` — the runner (windowed search → score → batched log); per-eval error isolation; DRY_RUN-safe - `utils/seed_traces.py` — seeds ~10 hardcoded Q&A traces (no LLM) for an end-to-end demo - `tests/test_score_traces.py` — 18 unit tests (LLM mocked) ## Notes - Runs as plain scripts (`uv run python score_traces.py` / `utils/seed_traces.py`) — no wheel/entry-point packaging. - **DRY_RUN-safe:** with no `OPIK_API_KEY`/`OPIK_WORKSPACE`, both entry points exit 0 without touching the network (the CI contract). - G-Eval is fed a labeled `INPUT:/OUTPUT:` payload so `relevance` is judged against the question (G-Eval's `score()` takes a single string). - Docs cover judge auth (the LiteLLM `openai/` route also needs `OPENAI_API_KEY`), never committing `OPIK_API_KEY` (prefer a service account), and pipeline/CI env config. ## Testing `uv run pytest` → **18 passed**; `uv run ruff check .` clean; both entry points verified DRY_RUN-safe (exit 0, no network). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents d621daf + f982c89 commit bcb41f1

12 files changed

Lines changed: 794 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ Standalone scripts for automating and managing Opik resources.
5757
| [scripts/automate_annotation_queue](scripts/automate_annotation_queue/) | Route traces into annotation queues via batch or real-time assignment |
5858
| [scripts/usage_stats](scripts/usage_stats/) | Fetch trace and span counts per project and visualise trends |
5959
| [scripts/leaderboard_dashboard](scripts/leaderboard_dashboard/) | Create an Experiment Leaderboard dashboard via the REST API |
60+
| [scripts/score_traces](scripts/score_traces/) | Score existing production traces offline with stock SDK judges/metrics, logging feedback back under the same names an online rule would use |
6061

6162
## Community
6263

scripts/score_traces/.env.example

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Copy to .env (git-ignored) and fill in. Credentials come from Opik → User menu → API key.
2+
# SECURITY: never commit real keys. In CI/cron, inject them from your secret store rather
3+
# than a file, and prefer a dedicated Opik *service account* over a personal API key.
4+
OPIK_API_KEY=
5+
OPIK_WORKSPACE=
6+
# Self-hosted only — the Opik API base URL (default: Opik Cloud).
7+
OPIK_URL_OVERRIDE=https://www.comet.com/opik/api
8+
# Project whose production traces will be scored (must already contain traces;
9+
# run `uv run python utils/seed_traces.py` to populate a test project).
10+
OPIK_PROJECT_NAME=score-traces-example
11+
12+
# How far back each run looks (stateless time window). An hourly cron uses 1.
13+
EVAL_WINDOW_HOURS=1
14+
# Max traces fetched per run. Size against your window + traffic; if a run hits
15+
# this cap the summary prints a truncation warning (raise it or shorten the window).
16+
EVAL_MAX_RESULTS=1000
17+
18+
# --- LLM judge model, via LiteLLM (see model.py) ---
19+
# Path A (OpenAI-compatible gateway): base URL + key for LiteLLM.
20+
GATEWAY_BASE_URL=
21+
GATEWAY_API_KEY=
22+
GATEWAY_MODEL=gpt-4o
23+
# CI routes judges to a cheap model via OPIK_EXAMPLES_MODEL (used when GATEWAY_MODEL is unset).
24+
# OPIK_EXAMPLES_MODEL=
25+
# The judge is routed as `openai/$GATEWAY_MODEL`, so LiteLLM also reads the OpenAI
26+
# provider key from OPENAI_API_KEY. Set this (to your gateway/OpenAI key) if the judge
27+
# errors on auth — it is required for the openai/ route even when GATEWAY_* are set.
28+
OPENAI_API_KEY=

scripts/score_traces/README.md

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# Score Production Traces
2+
3+
Score **existing production traces** in an Opik project from your own pipeline — using
4+
the same judge/metric classes an [online evaluation rule](https://www.comet.com/docs/opik/production/rules)
5+
would use — and log the scores back onto those traces. For teams whose GenAI gateway
6+
is reachable **from their code but not yet from the Opik platform**: run evals on a cron
7+
now, then switch to platform online rules later with **no re-authoring** (same score
8+
names, same variable mapping).
9+
10+
> **This is not `opik.evaluate()`.** `evaluate()` runs a *task* over a *dataset* to
11+
> generate new outputs and create an experiment (pre-production). This scores traces that
12+
> **already exist** and attaches feedback in place (post-production) — the job online
13+
> rules do. There is no single turnkey SDK call for that, so this example composes three
14+
> primitives: `search_traces``metric.score()``log_traces_feedback_scores`.
15+
16+
## Prerequisites
17+
18+
- Python 3.12+
19+
- [uv](https://docs.astral.sh/uv/) — this folder is a `uv` project; run `uv sync`
20+
- A GenAI gateway reachable from where you run this (for the LLM judges)
21+
22+
## Environment Setup
23+
24+
| Variable | Required | Default | Description |
25+
|---|---|---|---|
26+
| `OPIK_API_KEY` | for a live run || Opik API key (unset → DRY_RUN, no network) |
27+
| `OPIK_WORKSPACE` | for a live run || Opik workspace name |
28+
| `OPIK_URL_OVERRIDE` | self-hosted | `https://www.comet.com/opik/api` | Opik API base URL |
29+
| `OPIK_PROJECT_NAME` | no | `score-traces-example` | Project whose traces are scored |
30+
| `EVAL_WINDOW_HOURS` | no | `1` | How far back each run looks |
31+
| `EVAL_MAX_RESULTS` | no | `1000` | Max traces per run; warns on truncation |
32+
| `GATEWAY_BASE_URL` | for LLM judges || OpenAI-compatible gateway URL (Path A) |
33+
| `GATEWAY_API_KEY` | for LLM judges || Gateway API key |
34+
| `GATEWAY_MODEL` | no | `gpt-4o` | Judge model name (local knob) |
35+
| `OPIK_EXAMPLES_MODEL` | no || CI routes judges to a cheap model via this (used when `GATEWAY_MODEL` is unset) |
36+
| `OPENAI_API_KEY` | often || OpenAI provider key LiteLLM uses for the `openai/` judge route — set alongside `GATEWAY_*` if the judge errors on auth (see note below) |
37+
38+
> **Judge auth (LiteLLM).** The judges run through LiteLLM, which routes the model as
39+
> `openai/$GATEWAY_MODEL`. Set your gateway via `GATEWAY_BASE_URL` + `GATEWAY_API_KEY`, and
40+
> **also set `OPENAI_API_KEY`** (to your gateway/OpenAI key) — the `openai/` route reads the
41+
> provider key from it and will error on auth without it, even when the `GATEWAY_*` vars are set.
42+
43+
### Credentials & security
44+
45+
- **Never commit or hard-code `OPIK_API_KEY`** or gateway keys — keep them out of `run.sh`,
46+
source, and the committed `.env.example`. Inject them at runtime from your CI secret store
47+
or a secret manager; your real `.env` should stay git-ignored.
48+
- Prefer a **dedicated Opik service account** for scheduled/CI runs over a personal API key,
49+
so the job's access is scoped and can be revoked independently of any individual.
50+
51+
### Running in a pipeline / on a schedule
52+
53+
`OPIK_WORKSPACE` and `OPIK_PROJECT_NAME` are plain env vars — set them wherever the job runs:
54+
export them in your CI/cron pipeline, or edit `run.sh` (the CI entry point). With
55+
`OPIK_API_KEY`/`OPIK_WORKSPACE` unset the run falls back to DRY_RUN and exits 0 without
56+
touching the network, so CI stays green before credentials are wired in.
57+
58+
## Workflow
59+
60+
```mermaid
61+
flowchart LR
62+
A["search_traces<br/>(last N hours)"] --> B["for each trace × eval:<br/>resolve variables → metric.score()"]
63+
B --> C["log_traces_feedback_scores<br/>(one batched call, same names + reason)"]
64+
C --> D["scores visible on the<br/>same traces in the Opik UI"]
65+
subgraph later["when the gateway reaches Opik (Stage 1)"]
66+
E["same metrics.py"] --> F["create online rules<br/>(same names + variable mapping)"]
67+
end
68+
C -. same names & mapping .-> F
69+
```
70+
71+
### Step 0 — seed a test project (optional, no LLM)
72+
73+
```bash
74+
export OPIK_API_KEY=... OPIK_WORKSPACE=...
75+
export OPIK_PROJECT_NAME=score-traces-example
76+
uv run python utils/seed_traces.py # ~10 hardcoded Q&A traces, good/bad mix
77+
```
78+
79+
### Step 1 — configure the judge model
80+
81+
Edit `model.py`. Path A (default) points LiteLLM at an
82+
OpenAI-compatible gateway via `GATEWAY_BASE_URL` / `GATEWAY_API_KEY`. For a non-standard
83+
gateway, use Path B (a custom `OpikBaseModel` subclass). Both paths, with full worked
84+
examples, are documented here:
85+
<https://www.comet.com/docs/opik/evaluation/metrics/custom_model>
86+
([`LiteLLMChatModel`](https://www.comet.com/docs/opik/python-sdk-reference/Objects/LiteLLMChatModel.html) ·
87+
[`OpikBaseModel`](https://www.comet.com/docs/opik/python-sdk-reference/Objects/OpikBaseModel.html)).
88+
Set `GATEWAY_BASE_URL` + `GATEWAY_API_KEY` + `OPENAI_API_KEY` (see the **Judge auth** note above).
89+
90+
### Step 2 — run the evals
91+
92+
```bash
93+
export EVAL_WINDOW_HOURS=1
94+
uv run python score_traces.py # scores the last hour, logs feedback back
95+
```
96+
97+
Open the project in Opik — each trace now carries `hallucination`, `relevance`, and
98+
`exact_match` feedback scores, each with the judge's **reason**.
99+
100+
## How it works (the primitives)
101+
102+
The runner is nothing more than three public SDK primitives composed in a loop:
103+
104+
**1. Pull existing traces**
105+
106+
```python
107+
import opik
108+
109+
client = opik.Opik(project_name="score-traces-example")
110+
traces = client.search_traces(
111+
project_name="score-traces-example",
112+
filter_string='start_time >= "2026-07-29T00:00:00Z"',
113+
max_results=1000,
114+
)
115+
trace = traces[0]
116+
print(trace.id, trace.input, trace.output) # input/output are dicts
117+
```
118+
119+
**2. Score one trace with a metric + variable mapping**
120+
121+
```python
122+
from opik.evaluation.metrics import Hallucination
123+
124+
judge = Hallucination(model="gpt-4o", name="hallucination")
125+
126+
# variable mapping: metric score() param -> trace field path
127+
variables = {"input": "input.question", "output": "output.answer", "context": "output.context"}
128+
kwargs = {
129+
"input": trace.input["question"],
130+
"output": trace.output["answer"],
131+
"context": trace.output["context"],
132+
}
133+
result = judge.score(**kwargs)
134+
print(result.value, result.reason)
135+
```
136+
137+
**3. Write the score back onto the trace**
138+
139+
```python
140+
client.log_traces_feedback_scores(
141+
[
142+
{"id": trace.id, "name": "hallucination", "value": result.value, "reason": result.reason},
143+
]
144+
)
145+
```
146+
147+
**Put it together:** `score_traces.py` is exactly these three steps — for every eval in
148+
`EVALS`, over every trace in the window, with one batched write at the end. Schedule
149+
it on a cron (e.g. hourly with `EVAL_WINDOW_HOURS=1`).
150+
151+
## Adding or changing evals
152+
153+
Edit the `EVALS` list in `metrics.py`. Each `Eval` has a `name` (the feedback-score name),
154+
a live `metric` object, and a `variables` mapping (metric `score()` param → trace field
155+
path). Copy a block to add one. Three styles ship:
156+
157+
| Eval | Type | Migrates to an online rule as |
158+
|---|---|---|
159+
| `hallucination` | built-in preset judge | the platform preset — same engine, **same score name** (strong, not a literal string copy) |
160+
| `relevance` | G-Eval custom judge | an `llm_as_judge` rule — criteria text + name map directly (**clean**) |
161+
| `exact_match` | Python metric (`metrics.py`) | a `user_defined_metric_python` rule — the exact source round-trips (**perfect**) |
162+
163+
The `variables` mapping is the same **"variable mapping"** you set on a rule in the Opik UI —
164+
so when you migrate, the mapping and score names carry over verbatim and your dashboards
165+
don't change. G-Eval's `score()` evaluates a single labeled `output` string, not separate
166+
kwargs, so for `relevance` the runner composes that string from `variables` (e.g.
167+
`INPUT: ...\nOUTPUT: ...`) — the mapping still names the same trace fields an online G-Eval
168+
rule would map.
169+
170+
## Roadmap (not in this example)
171+
172+
- **Stage 1 — promote to online rules.** A small script reading the same `metrics.py`
173+
and creating the online rules (reuses
174+
[`scripts/online_eval_rules`](../online_eval_rules)). For multi-output custom judges it
175+
adds an output-schema hint.
176+
- **Stage 2 — productionize.** Config-driven CLI; **watermarking** (persist a last-processed
177+
timestamp instead of a fixed window); **span- and thread-scope** evaluation. A large
178+
custom Path B model can also move into its own `model_provider.py`.

scripts/score_traces/metrics.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Metrics + eval definitions — single source of truth for offline evaluation.
2+
3+
Contains a minimal user-defined Opik metric (no LLM) for the Python-metric example
4+
(the SDK's own metric interface: subclass BaseMetric and implement score()), plus
5+
the EVALS list: the judges & metrics to run (copy a block to add one).
6+
7+
The score `name` and `variables` mapping here are identical to what an online
8+
evaluation rule needs, so migrating later is a drop-in (see README, Stage 1).
9+
"""
10+
11+
from dataclasses import dataclass
12+
from typing import Any
13+
14+
from opik.evaluation.metrics import GEval, Hallucination, base_metric, score_result
15+
from opik.evaluation.metrics.base_metric import BaseMetric
16+
17+
from model import build_judge_model
18+
19+
20+
class ExactMatch(base_metric.BaseMetric):
21+
"""Scores 1.0 when output exactly equals reference, else 0.0."""
22+
23+
def __init__(self, name: str = "exact_match"):
24+
super().__init__(name=name, track=False)
25+
26+
def score(self, output: str, reference: str, **ignored: Any) -> score_result.ScoreResult:
27+
hit = output == reference
28+
return score_result.ScoreResult(
29+
value=1.0 if hit else 0.0,
30+
name=self.name,
31+
reason="exact match" if hit else f"output {output!r} != reference {reference!r}",
32+
)
33+
34+
35+
judge_model = build_judge_model()
36+
37+
38+
@dataclass
39+
class Eval:
40+
"""One evaluation: its score name, the live SDK metric, and the variable mapping.
41+
42+
`variables` maps each metric score() parameter to a dotted trace field-path
43+
(the Opik UI "variable mapping"). `name` is the feedback-score name.
44+
"""
45+
46+
name: str
47+
metric: BaseMetric
48+
variables: dict[str, str]
49+
50+
51+
# ============================================================
52+
# 2. EVALS — your judges & metrics (copy a block to add one)
53+
# ============================================================
54+
EVALS: list[Eval] = [
55+
# Built-in preset judge (lead example).
56+
Eval(
57+
name="hallucination",
58+
metric=Hallucination(model=judge_model, name="hallucination"),
59+
variables={
60+
"input": "input.question",
61+
"output": "output.answer",
62+
"context": "output.context",
63+
},
64+
),
65+
# G-Eval custom judge (author criteria as text).
66+
Eval(
67+
name="relevance",
68+
metric=GEval(
69+
model=judge_model,
70+
name="relevance",
71+
task_introduction=("You judge whether an answer (OUTPUT) is relevant to the question (INPUT)."),
72+
evaluation_criteria=(
73+
"Return 1 if the OUTPUT directly addresses the INPUT question, 0 if it is "
74+
"off-topic or evasive."
75+
),
76+
),
77+
variables={"input": "input.question", "output": "output.answer"},
78+
),
79+
# User-defined Python metric (no LLM).
80+
Eval(
81+
name="exact_match",
82+
metric=ExactMatch(name="exact_match"),
83+
variables={"output": "output.answer", "reference": "input.expected"},
84+
),
85+
]

scripts/score_traces/model.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Judge model / GenAI gateway wiring.
2+
3+
Point the LLM judges at your GenAI gateway (edit once). This is the ONLY
4+
file about the model.
5+
"""
6+
7+
import os
8+
9+
from opik.evaluation import models
10+
from opik.evaluation.models import base_model
11+
12+
JudgeModel = str | base_model.OpikBaseModel
13+
14+
15+
# ============================================================
16+
# 1. JUDGE MODEL — set up your GenAI gateway here (edit once)
17+
# Path A: LiteLLMChatModel(...) ← OpenAI-compatible gateway (default)
18+
# Path B: a custom OpikBaseModel subclass ← non-standard gateway
19+
# Full worked examples for BOTH paths:
20+
# https://www.comet.com/docs/opik/evaluation/metrics/custom_model
21+
# ============================================================
22+
def build_judge_model() -> JudgeModel:
23+
"""Return the model every LLM judge uses.
24+
25+
Path A (default): an OpenAI-compatible gateway via LiteLLM. Set GATEWAY_BASE_URL
26+
+ GATEWAY_API_KEY + GATEWAY_MODEL. LiteLLMChatModel forwards base_url/api_key to
27+
litellm.completion, so the judge's calls route through your gateway. Because the
28+
model is routed as ``openai/<GATEWAY_MODEL>``, LiteLLM also reads OPENAI_API_KEY as
29+
the provider key — set it (to your gateway/OpenAI key) if the judge errors on auth.
30+
31+
If GATEWAY_BASE_URL is unset, falls back to a bare model name (GATEWAY_MODEL, or
32+
OPIK_EXAMPLES_MODEL), which lets the module import and unit-test without a gateway.
33+
"""
34+
# GATEWAY_MODEL is the local knob; OPIK_EXAMPLES_MODEL lets CI route judges to a
35+
# cheap model (used when GATEWAY_MODEL is unset).
36+
model_name = os.environ.get("GATEWAY_MODEL") or os.environ.get("OPIK_EXAMPLES_MODEL", "gpt-4o")
37+
base_url = os.environ.get("GATEWAY_BASE_URL")
38+
if not base_url:
39+
return model_name # importable / testable without a gateway
40+
return models.LiteLLMChatModel(
41+
model_name=f"openai/{model_name}",
42+
base_url=base_url,
43+
api_key=os.environ.get("GATEWAY_API_KEY"),
44+
temperature=0.0,
45+
)
46+
47+
# ---- Path B: non-standard gateway (uncomment & implement) --------------
48+
# from opik.evaluation.models import OpikBaseModel
49+
# class MyGatewayModel(OpikBaseModel):
50+
# def __init__(self, model_name: str):
51+
# super().__init__(model_name=model_name)
52+
# def generate_string(self, input: str, **kwargs) -> str: ...
53+
# def generate_provider_response(self, **kwargs): ...
54+
# return MyGatewayModel(model_name)
55+
# See: https://www.comet.com/docs/opik/evaluation/metrics/custom_model

scripts/score_traces/paths.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Resolve dotted trace field-paths into the kwargs a metric's score() expects.
2+
3+
A `variables` mapping looks like {"input": "input.question", "output": "output.answer"}:
4+
each key is a metric score() parameter, each value a dotted path into the trace's
5+
top-level dicts (`input`, `output`, `metadata`). This is the Opik "variable mapping".
6+
"""
7+
8+
from typing import Any
9+
10+
11+
class MissingField(Exception):
12+
"""A variable's dotted path could not be resolved against a trace."""
13+
14+
15+
def resolve_path(trace_data: dict[str, Any], path: str) -> Any:
16+
"""Read `path` (e.g. "output.context") out of trace_data.
17+
18+
First segment selects a top-level key (input/output/metadata); remaining
19+
segments index into nested dicts. List/scalar leaf values are returned as-is.
20+
Raises MissingField if any segment is absent or a non-dict is indexed.
21+
"""
22+
segments = path.split(".")
23+
current: Any = trace_data
24+
traversed: list[str] = []
25+
for segment in segments:
26+
if not isinstance(current, dict) or segment not in current:
27+
where = ".".join(traversed) or "<root>"
28+
raise MissingField(f"cannot resolve '{path}': '{segment}' missing under '{where}'")
29+
current = current[segment]
30+
traversed.append(segment)
31+
return current
32+
33+
34+
def resolve_variables(trace_data: dict[str, Any], variables: dict[str, str]) -> dict[str, Any]:
35+
"""Map {param: dotted_path} to {param: resolved_value}. Propagates MissingField."""
36+
return {param: resolve_path(trace_data, path) for param, path in variables.items()}

0 commit comments

Comments
 (0)