llm-eval-exporter 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- llm_eval_exporter-0.2.0/LICENSE +21 -0
- llm_eval_exporter-0.2.0/PKG-INFO +172 -0
- llm_eval_exporter-0.2.0/README.md +118 -0
- llm_eval_exporter-0.2.0/llm_eval_exporter/__init__.py +5 -0
- llm_eval_exporter-0.2.0/llm_eval_exporter/alerting.py +29 -0
- llm_eval_exporter-0.2.0/llm_eval_exporter/cli.py +130 -0
- llm_eval_exporter-0.2.0/llm_eval_exporter/drift.py +65 -0
- llm_eval_exporter-0.2.0/llm_eval_exporter/judge.py +65 -0
- llm_eval_exporter-0.2.0/llm_eval_exporter/metrics.py +79 -0
- llm_eval_exporter-0.2.0/llm_eval_exporter/pricing.py +25 -0
- llm_eval_exporter-0.2.0/llm_eval_exporter/server.py +16 -0
- llm_eval_exporter-0.2.0/llm_eval_exporter/storage.py +131 -0
- llm_eval_exporter-0.2.0/llm_eval_exporter/tracker.py +170 -0
- llm_eval_exporter-0.2.0/llm_eval_exporter.egg-info/PKG-INFO +172 -0
- llm_eval_exporter-0.2.0/llm_eval_exporter.egg-info/SOURCES.txt +20 -0
- llm_eval_exporter-0.2.0/llm_eval_exporter.egg-info/dependency_links.txt +1 -0
- llm_eval_exporter-0.2.0/llm_eval_exporter.egg-info/entry_points.txt +2 -0
- llm_eval_exporter-0.2.0/llm_eval_exporter.egg-info/requires.txt +12 -0
- llm_eval_exporter-0.2.0/llm_eval_exporter.egg-info/top_level.txt +1 -0
- llm_eval_exporter-0.2.0/pyproject.toml +48 -0
- llm_eval_exporter-0.2.0/setup.cfg +4 -0
- llm_eval_exporter-0.2.0/tests/test_core.py +68 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Harshitha
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: llm-eval-exporter
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A lightweight Prometheus exporter for LLM eval metrics: faithfulness, semantic drift, cost, and CI/CD regression gating.
|
|
5
|
+
Author-email: Harshitha <your-email@example.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Harshitha
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
Project-URL: Homepage, https://github.com/your-username/llm-eval-exporter
|
|
28
|
+
Project-URL: Repository, https://github.com/your-username/llm-eval-exporter
|
|
29
|
+
Project-URL: Issues, https://github.com/your-username/llm-eval-exporter/issues
|
|
30
|
+
Keywords: llm,observability,prometheus,eval,mlops,monitoring,grafana,ci-cd
|
|
31
|
+
Classifier: Development Status :: 3 - Alpha
|
|
32
|
+
Classifier: Intended Audience :: Developers
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Programming Language :: Python :: 3
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
40
|
+
Classifier: Topic :: System :: Monitoring
|
|
41
|
+
Requires-Python: >=3.9
|
|
42
|
+
Description-Content-Type: text/markdown
|
|
43
|
+
License-File: LICENSE
|
|
44
|
+
Requires-Dist: prometheus-client>=0.20.0
|
|
45
|
+
Requires-Dist: numpy>=1.24.0
|
|
46
|
+
Requires-Dist: pyyaml>=6.0
|
|
47
|
+
Provides-Extra: embeddings
|
|
48
|
+
Requires-Dist: sentence-transformers>=2.2.0; extra == "embeddings"
|
|
49
|
+
Provides-Extra: anthropic
|
|
50
|
+
Requires-Dist: anthropic>=0.30.0; extra == "anthropic"
|
|
51
|
+
Provides-Extra: dev
|
|
52
|
+
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
53
|
+
Dynamic: license-file
|
|
54
|
+
|
|
55
|
+
# llm-eval-exporter
|
|
56
|
+
|
|
57
|
+
A lightweight Prometheus exporter for LLM eval metrics — hallucination
|
|
58
|
+
risk (via LLM-as-judge faithfulness scoring), semantic drift across
|
|
59
|
+
model/prompt versions, latency, and token cost — designed to plug into
|
|
60
|
+
observability stacks teams already run, instead of requiring adoption
|
|
61
|
+
of a new platform.
|
|
62
|
+
|
|
63
|
+
## Why this exists
|
|
64
|
+
|
|
65
|
+
Most LLM observability tools (Langfuse, Braintrust, Arize Phoenix,
|
|
66
|
+
etc.) are full platforms: their own UI, their own storage, their own
|
|
67
|
+
workflow. That's the right call for teams building observability from
|
|
68
|
+
scratch. But plenty of teams already run Prometheus + Grafana for
|
|
69
|
+
every other service they operate, and would rather their LLM calls
|
|
70
|
+
show up as three more panels on an existing dashboard than as a
|
|
71
|
+
separate tool with a separate login.
|
|
72
|
+
|
|
73
|
+
`llm-eval-exporter` wraps your LLM calls, computes eval scores, and
|
|
74
|
+
exposes them as standard Prometheus metrics. It's a library, not a
|
|
75
|
+
platform — a few lines of Python, one `/metrics` endpoint, no new UI
|
|
76
|
+
to learn.
|
|
77
|
+
|
|
78
|
+
## What it tracks
|
|
79
|
+
|
|
80
|
+
| Metric | What it tells you |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `llm_request_latency_seconds` | p50/p95/p99 latency per model/provider |
|
|
83
|
+
| `llm_request_total` | Request volume and error rate |
|
|
84
|
+
| `llm_tokens_total` | Prompt/completion token usage (cost proxy) |
|
|
85
|
+
| `llm_faithfulness_score` | LLM-judge score (0-1) for how well a response sticks to its provided context — a hallucination proxy |
|
|
86
|
+
| `llm_semantic_drift` | Cosine distance between a response and its recorded baseline for the same prompt — catches silent behavior changes across model upgrades or prompt edits |
|
|
87
|
+
| `llm_eval_failures_total` | Count of responses that failed a faithfulness or drift threshold |
|
|
88
|
+
|
|
89
|
+
## Quickstart
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pip install -r requirements.txt
|
|
93
|
+
python examples/demo.py
|
|
94
|
+
# metrics now live at http://localhost:9464/metrics
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
To run against the real Anthropic API instead of mocks:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
101
|
+
python examples/anthropic_example.py
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
This traces a real `claude-sonnet-5` call and judges its faithfulness
|
|
105
|
+
with a cheaper `claude-haiku-4-5` call — the standard cost-effective
|
|
106
|
+
pattern for LLM-as-judge in production (you don't need your most
|
|
107
|
+
expensive model just to check "did this answer stick to the
|
|
108
|
+
context").
|
|
109
|
+
|
|
110
|
+
Then, optionally, bring up Prometheus + Grafana pointed at it:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
docker compose -f examples/docker-compose.yml up
|
|
114
|
+
# Grafana at http://localhost:3000 (anonymous admin access, demo only)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Usage in your own code
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from llm_eval_exporter import LLMTracker, start_metrics_server
|
|
121
|
+
|
|
122
|
+
start_metrics_server(port=9464) # call once at app startup
|
|
123
|
+
|
|
124
|
+
tracker = LLMTracker(
|
|
125
|
+
judge_fn=my_judge_fn, # any callable: str prompt -> str response
|
|
126
|
+
embed_fn=my_embed_fn, # optional: defaults to sentence-transformers locally
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
with tracker.track(
|
|
130
|
+
model="gpt-4o",
|
|
131
|
+
provider="openai",
|
|
132
|
+
prompt_id="refund_policy_qa", # stable id -> enables drift tracking
|
|
133
|
+
context=retrieved_context, # enables faithfulness scoring
|
|
134
|
+
) as call:
|
|
135
|
+
response = your_llm_client.call(prompt)
|
|
136
|
+
call.set_response(
|
|
137
|
+
response.text,
|
|
138
|
+
prompt_tokens=response.usage.prompt_tokens,
|
|
139
|
+
completion_tokens=response.usage.completion_tokens,
|
|
140
|
+
)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`judge_fn` and `embed_fn` are intentionally pluggable — bring your own
|
|
144
|
+
LLM client (Anthropic, OpenAI, a local model) rather than depending on
|
|
145
|
+
one vendor's SDK.
|
|
146
|
+
|
|
147
|
+
## Design notes
|
|
148
|
+
|
|
149
|
+
- **SQLite by default** for baseline embeddings and eval logs — a
|
|
150
|
+
single-file, zero-ops dependency, matching this project's "drop it
|
|
151
|
+
into an existing stack" philosophy. Swap for Postgres if it needs to
|
|
152
|
+
scale past one instance.
|
|
153
|
+
- **Faithfulness scoring is a lightweight, single-call analogue of
|
|
154
|
+
RAGAS-style faithfulness** — not a claim to replace a dedicated eval
|
|
155
|
+
framework, just enough signal to catch regressions cheaply.
|
|
156
|
+
- **Drift detection needs a `prompt_id`** you assign to prompts you
|
|
157
|
+
care about tracking over time; the first call for a given id just
|
|
158
|
+
records a baseline, so drift shows up starting from the second call.
|
|
159
|
+
|
|
160
|
+
## Status
|
|
161
|
+
|
|
162
|
+
Early-stage side project — built to explore a gap in the current LLM
|
|
163
|
+
observability tooling landscape (see `docs/market-notes.md` for the
|
|
164
|
+
reasoning), not a production-ready alternative to the platforms above.
|
|
165
|
+
Contributions and issues welcome.
|
|
166
|
+
|
|
167
|
+
## Running tests
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
pip install -r requirements.txt
|
|
171
|
+
pytest tests/ -v
|
|
172
|
+
```
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# llm-eval-exporter
|
|
2
|
+
|
|
3
|
+
A lightweight Prometheus exporter for LLM eval metrics — hallucination
|
|
4
|
+
risk (via LLM-as-judge faithfulness scoring), semantic drift across
|
|
5
|
+
model/prompt versions, latency, and token cost — designed to plug into
|
|
6
|
+
observability stacks teams already run, instead of requiring adoption
|
|
7
|
+
of a new platform.
|
|
8
|
+
|
|
9
|
+
## Why this exists
|
|
10
|
+
|
|
11
|
+
Most LLM observability tools (Langfuse, Braintrust, Arize Phoenix,
|
|
12
|
+
etc.) are full platforms: their own UI, their own storage, their own
|
|
13
|
+
workflow. That's the right call for teams building observability from
|
|
14
|
+
scratch. But plenty of teams already run Prometheus + Grafana for
|
|
15
|
+
every other service they operate, and would rather their LLM calls
|
|
16
|
+
show up as three more panels on an existing dashboard than as a
|
|
17
|
+
separate tool with a separate login.
|
|
18
|
+
|
|
19
|
+
`llm-eval-exporter` wraps your LLM calls, computes eval scores, and
|
|
20
|
+
exposes them as standard Prometheus metrics. It's a library, not a
|
|
21
|
+
platform — a few lines of Python, one `/metrics` endpoint, no new UI
|
|
22
|
+
to learn.
|
|
23
|
+
|
|
24
|
+
## What it tracks
|
|
25
|
+
|
|
26
|
+
| Metric | What it tells you |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `llm_request_latency_seconds` | p50/p95/p99 latency per model/provider |
|
|
29
|
+
| `llm_request_total` | Request volume and error rate |
|
|
30
|
+
| `llm_tokens_total` | Prompt/completion token usage (cost proxy) |
|
|
31
|
+
| `llm_faithfulness_score` | LLM-judge score (0-1) for how well a response sticks to its provided context — a hallucination proxy |
|
|
32
|
+
| `llm_semantic_drift` | Cosine distance between a response and its recorded baseline for the same prompt — catches silent behavior changes across model upgrades or prompt edits |
|
|
33
|
+
| `llm_eval_failures_total` | Count of responses that failed a faithfulness or drift threshold |
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install -r requirements.txt
|
|
39
|
+
python examples/demo.py
|
|
40
|
+
# metrics now live at http://localhost:9464/metrics
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
To run against the real Anthropic API instead of mocks:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
47
|
+
python examples/anthropic_example.py
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
This traces a real `claude-sonnet-5` call and judges its faithfulness
|
|
51
|
+
with a cheaper `claude-haiku-4-5` call — the standard cost-effective
|
|
52
|
+
pattern for LLM-as-judge in production (you don't need your most
|
|
53
|
+
expensive model just to check "did this answer stick to the
|
|
54
|
+
context").
|
|
55
|
+
|
|
56
|
+
Then, optionally, bring up Prometheus + Grafana pointed at it:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
docker compose -f examples/docker-compose.yml up
|
|
60
|
+
# Grafana at http://localhost:3000 (anonymous admin access, demo only)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Usage in your own code
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from llm_eval_exporter import LLMTracker, start_metrics_server
|
|
67
|
+
|
|
68
|
+
start_metrics_server(port=9464) # call once at app startup
|
|
69
|
+
|
|
70
|
+
tracker = LLMTracker(
|
|
71
|
+
judge_fn=my_judge_fn, # any callable: str prompt -> str response
|
|
72
|
+
embed_fn=my_embed_fn, # optional: defaults to sentence-transformers locally
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
with tracker.track(
|
|
76
|
+
model="gpt-4o",
|
|
77
|
+
provider="openai",
|
|
78
|
+
prompt_id="refund_policy_qa", # stable id -> enables drift tracking
|
|
79
|
+
context=retrieved_context, # enables faithfulness scoring
|
|
80
|
+
) as call:
|
|
81
|
+
response = your_llm_client.call(prompt)
|
|
82
|
+
call.set_response(
|
|
83
|
+
response.text,
|
|
84
|
+
prompt_tokens=response.usage.prompt_tokens,
|
|
85
|
+
completion_tokens=response.usage.completion_tokens,
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`judge_fn` and `embed_fn` are intentionally pluggable — bring your own
|
|
90
|
+
LLM client (Anthropic, OpenAI, a local model) rather than depending on
|
|
91
|
+
one vendor's SDK.
|
|
92
|
+
|
|
93
|
+
## Design notes
|
|
94
|
+
|
|
95
|
+
- **SQLite by default** for baseline embeddings and eval logs — a
|
|
96
|
+
single-file, zero-ops dependency, matching this project's "drop it
|
|
97
|
+
into an existing stack" philosophy. Swap for Postgres if it needs to
|
|
98
|
+
scale past one instance.
|
|
99
|
+
- **Faithfulness scoring is a lightweight, single-call analogue of
|
|
100
|
+
RAGAS-style faithfulness** — not a claim to replace a dedicated eval
|
|
101
|
+
framework, just enough signal to catch regressions cheaply.
|
|
102
|
+
- **Drift detection needs a `prompt_id`** you assign to prompts you
|
|
103
|
+
care about tracking over time; the first call for a given id just
|
|
104
|
+
records a baseline, so drift shows up starting from the second call.
|
|
105
|
+
|
|
106
|
+
## Status
|
|
107
|
+
|
|
108
|
+
Early-stage side project — built to explore a gap in the current LLM
|
|
109
|
+
observability tooling landscape (see `docs/market-notes.md` for the
|
|
110
|
+
reasoning), not a production-ready alternative to the platforms above.
|
|
111
|
+
Contributions and issues welcome.
|
|
112
|
+
|
|
113
|
+
## Running tests
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
pip install -r requirements.txt
|
|
117
|
+
pytest tests/ -v
|
|
118
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Optional webhook alerting (Slack-compatible JSON payload) fired when an
|
|
3
|
+
eval check fails. Uses only the standard library (urllib) so this
|
|
4
|
+
doesn't add a new dependency just for an optional feature.
|
|
5
|
+
|
|
6
|
+
Best-effort by design: a failed alert never raises and never blocks
|
|
7
|
+
the tracked call. Observability tooling should never be able to break
|
|
8
|
+
the thing it's observing.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import urllib.request
|
|
13
|
+
import urllib.error
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def send_webhook_alert(webhook_url: str, message: str) -> bool:
|
|
17
|
+
if not webhook_url:
|
|
18
|
+
return False
|
|
19
|
+
|
|
20
|
+
payload = json.dumps({"text": message}).encode("utf-8")
|
|
21
|
+
req = urllib.request.Request(
|
|
22
|
+
webhook_url, data=payload, headers={"Content-Type": "application/json"}
|
|
23
|
+
)
|
|
24
|
+
try:
|
|
25
|
+
urllib.request.urlopen(req, timeout=5)
|
|
26
|
+
return True
|
|
27
|
+
except (urllib.error.URLError, urllib.error.HTTPError) as e:
|
|
28
|
+
print(f"llm-eval-exporter: webhook alert failed: {e}")
|
|
29
|
+
return False
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI entrypoint for CI/CD regression testing.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
llm-eval-exporter check --config eval-suite.yaml --adapter my_project.eval_adapter
|
|
6
|
+
|
|
7
|
+
Runs a fixed set of prompts through your LLM, scores faithfulness and
|
|
8
|
+
semantic drift, and exits non-zero if any score regresses past your
|
|
9
|
+
configured thresholds. Designed to run as a CI step so a prompt change,
|
|
10
|
+
model swap, or provider upgrade that silently degrades answer quality
|
|
11
|
+
fails the build instead of shipping -- the same "shift left" idea as
|
|
12
|
+
unit tests, applied to LLM behavior.
|
|
13
|
+
|
|
14
|
+
The --db path should persist across CI runs (e.g. via actions/cache)
|
|
15
|
+
so drift has a real baseline to compare against, not a fresh one every run.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import importlib
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
from .tracker import LLMTracker
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_adapter(adapter_path: str):
|
|
28
|
+
"""
|
|
29
|
+
adapter_path is a Python import path, e.g. "my_project.eval_adapter".
|
|
30
|
+
That module must define:
|
|
31
|
+
call_fn(prompt: str) -> (text: str, prompt_tokens: int, completion_tokens: int)
|
|
32
|
+
judge_fn(prompt: str) -> str
|
|
33
|
+
And may optionally define:
|
|
34
|
+
embed_fn(text: str) -> list[float]
|
|
35
|
+
This keeps the CLI provider-agnostic -- you bring whichever LLM
|
|
36
|
+
client and credentials your CI environment already has configured.
|
|
37
|
+
"""
|
|
38
|
+
module = importlib.import_module(adapter_path)
|
|
39
|
+
if not hasattr(module, "call_fn") or not hasattr(module, "judge_fn"):
|
|
40
|
+
raise ImportError(
|
|
41
|
+
f"Adapter '{adapter_path}' must define call_fn(prompt) and judge_fn(prompt)."
|
|
42
|
+
)
|
|
43
|
+
return module
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run_check(config_path: str, adapter_path: str, db_path: str) -> int:
|
|
47
|
+
with open(config_path) as f:
|
|
48
|
+
config = yaml.safe_load(f)
|
|
49
|
+
|
|
50
|
+
adapter = load_adapter(adapter_path)
|
|
51
|
+
embed_fn = getattr(adapter, "embed_fn", None)
|
|
52
|
+
|
|
53
|
+
tracker = LLMTracker(judge_fn=adapter.judge_fn, embed_fn=embed_fn, db_path=db_path)
|
|
54
|
+
|
|
55
|
+
faithfulness_threshold = config.get("faithfulness_threshold", 0.7)
|
|
56
|
+
drift_threshold = config.get("drift_threshold", 0.3)
|
|
57
|
+
model = config.get("model", "unknown")
|
|
58
|
+
provider = config.get("provider", "unknown")
|
|
59
|
+
|
|
60
|
+
failures = []
|
|
61
|
+
print(f"Running {len(config['cases'])} eval case(s) against {model} ({provider})\n")
|
|
62
|
+
|
|
63
|
+
for case in config["cases"]:
|
|
64
|
+
prompt_id = case["prompt_id"]
|
|
65
|
+
prompt = case["prompt"]
|
|
66
|
+
context = case.get("context")
|
|
67
|
+
|
|
68
|
+
with tracker.track(
|
|
69
|
+
model=model,
|
|
70
|
+
provider=provider,
|
|
71
|
+
prompt=prompt,
|
|
72
|
+
prompt_id=prompt_id,
|
|
73
|
+
context=context,
|
|
74
|
+
) as call:
|
|
75
|
+
text, ptoks, ctoks = adapter.call_fn(prompt)
|
|
76
|
+
call.set_response(text, prompt_tokens=ptoks, completion_tokens=ctoks)
|
|
77
|
+
|
|
78
|
+
row = tracker.store.get_latest_eval(prompt_id)
|
|
79
|
+
faithfulness = row.get("faithfulness_score")
|
|
80
|
+
drift = row.get("semantic_drift")
|
|
81
|
+
|
|
82
|
+
status = "PASS"
|
|
83
|
+
if faithfulness is not None and faithfulness < faithfulness_threshold:
|
|
84
|
+
status = "FAIL"
|
|
85
|
+
failures.append(
|
|
86
|
+
f"{prompt_id}: faithfulness {faithfulness:.2f} < threshold {faithfulness_threshold}"
|
|
87
|
+
)
|
|
88
|
+
if drift is not None and drift > drift_threshold:
|
|
89
|
+
status = "FAIL"
|
|
90
|
+
failures.append(f"{prompt_id}: drift {drift:.3f} > threshold {drift_threshold}")
|
|
91
|
+
|
|
92
|
+
print(f"[{status}] {prompt_id} faithfulness={faithfulness} drift={drift}")
|
|
93
|
+
|
|
94
|
+
print()
|
|
95
|
+
if failures:
|
|
96
|
+
print(f"{len(failures)} check(s) failed:")
|
|
97
|
+
for f in failures:
|
|
98
|
+
print(f" - {f}")
|
|
99
|
+
return 1
|
|
100
|
+
|
|
101
|
+
print("All checks passed.")
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def main():
|
|
106
|
+
parser = argparse.ArgumentParser(prog="llm-eval-exporter")
|
|
107
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
108
|
+
|
|
109
|
+
check_parser = subparsers.add_parser("check", help="Run eval suite and fail on regression")
|
|
110
|
+
check_parser.add_argument("--config", required=True, help="Path to eval-suite.yaml")
|
|
111
|
+
check_parser.add_argument(
|
|
112
|
+
"--adapter",
|
|
113
|
+
required=True,
|
|
114
|
+
help="Python import path to your call_fn/judge_fn adapter module",
|
|
115
|
+
)
|
|
116
|
+
check_parser.add_argument(
|
|
117
|
+
"--db",
|
|
118
|
+
default="eval_baseline.db",
|
|
119
|
+
help="Path to baseline DB (persist across CI runs for drift detection)",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
args = parser.parse_args()
|
|
123
|
+
|
|
124
|
+
if args.command == "check":
|
|
125
|
+
exit_code = run_check(args.config, args.adapter, args.db)
|
|
126
|
+
sys.exit(exit_code)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
main()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Semantic drift detection.
|
|
3
|
+
|
|
4
|
+
The idea: for a given prompt_id (a stable name you assign to a prompt
|
|
5
|
+
you care about, e.g. "customer_refund_policy_qa"), store the
|
|
6
|
+
embedding of the first response as a baseline. On every later call
|
|
7
|
+
with the same prompt_id, embed the new response and compute cosine
|
|
8
|
+
distance from baseline. A rising trend means the model's answer to
|
|
9
|
+
the same question is drifting over time (model upgrades, prompt
|
|
10
|
+
changes upstream, temperature variance, etc).
|
|
11
|
+
|
|
12
|
+
Embeddings are pluggable via `embed_fn` so this doesn't hard-depend
|
|
13
|
+
on any one provider. Default uses sentence-transformers locally
|
|
14
|
+
(no API cost, no external call).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from typing import Callable, Optional
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from .storage import EvalStore
|
|
21
|
+
|
|
22
|
+
_default_model = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _default_embed_fn(text: str) -> list:
|
|
26
|
+
"""Lazy-loaded local embedding model (all-MiniLM-L6-v2, ~80MB)."""
|
|
27
|
+
global _default_model
|
|
28
|
+
if _default_model is None:
|
|
29
|
+
from sentence_transformers import SentenceTransformer
|
|
30
|
+
|
|
31
|
+
_default_model = SentenceTransformer("all-MiniLM-L6-v2")
|
|
32
|
+
return _default_model.encode(text).tolist()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cosine_distance(a: list, b: list) -> float:
|
|
36
|
+
a, b = np.array(a), np.array(b)
|
|
37
|
+
denom = np.linalg.norm(a) * np.linalg.norm(b)
|
|
38
|
+
if denom == 0:
|
|
39
|
+
return 0.0
|
|
40
|
+
similarity = np.dot(a, b) / denom
|
|
41
|
+
return float(1.0 - similarity)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DriftTracker:
|
|
45
|
+
def __init__(self, store: EvalStore, embed_fn: Optional[Callable[[str], list]] = None):
|
|
46
|
+
self.store = store
|
|
47
|
+
self.embed_fn = embed_fn or _default_embed_fn
|
|
48
|
+
|
|
49
|
+
def score(self, prompt_id: str, model: str, response_text: str) -> Optional[float]:
|
|
50
|
+
"""
|
|
51
|
+
Returns cosine distance from baseline (0 = identical, higher = more drift).
|
|
52
|
+
Returns None if this is the first time we've seen prompt_id
|
|
53
|
+
(a baseline is recorded instead, nothing to compare yet).
|
|
54
|
+
"""
|
|
55
|
+
if not prompt_id:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
current_embedding = self.embed_fn(response_text)
|
|
59
|
+
baseline = self.store.get_baseline(prompt_id)
|
|
60
|
+
|
|
61
|
+
if baseline is None:
|
|
62
|
+
self.store.set_baseline(prompt_id, model, current_embedding)
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
return cosine_distance(baseline, current_embedding)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Faithfulness scoring via LLM-as-judge.
|
|
3
|
+
|
|
4
|
+
Given a response and the context it was supposed to be grounded in,
|
|
5
|
+
ask a judge model to score 0.0-1.0 how well the response sticks to
|
|
6
|
+
what the context actually supports (a lightweight, single-call
|
|
7
|
+
analogue of RAGAS-style faithfulness).
|
|
8
|
+
|
|
9
|
+
The judge call itself is pluggable via `judge_fn` so this package
|
|
10
|
+
never hard-depends on one vendor's SDK. Bring your own client.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from typing import Callable, Optional
|
|
16
|
+
|
|
17
|
+
JUDGE_PROMPT_TEMPLATE = """You are grading whether a RESPONSE is faithful to a CONTEXT.
|
|
18
|
+
Faithful means every factual claim in the response is supported by the context.
|
|
19
|
+
Score from 0.0 (completely unsupported / hallucinated) to 1.0 (fully supported).
|
|
20
|
+
|
|
21
|
+
CONTEXT:
|
|
22
|
+
{context}
|
|
23
|
+
|
|
24
|
+
RESPONSE:
|
|
25
|
+
{response}
|
|
26
|
+
|
|
27
|
+
Reply with ONLY a JSON object: {{"score": <float 0.0-1.0>, "reason": "<one sentence>"}}
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_judge_output(raw: str) -> dict:
|
|
32
|
+
"""Judge models occasionally wrap JSON in prose or code fences; extract it defensively."""
|
|
33
|
+
match = re.search(r"\{.*\}", raw, re.DOTALL)
|
|
34
|
+
if not match:
|
|
35
|
+
raise ValueError(f"Could not parse judge output: {raw!r}")
|
|
36
|
+
return json.loads(match.group(0))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FaithfulnessJudge:
|
|
40
|
+
def __init__(self, judge_fn: Callable[[str], str]):
|
|
41
|
+
"""
|
|
42
|
+
judge_fn: a function that takes a prompt string and returns the
|
|
43
|
+
judge model's raw text response. E.g.:
|
|
44
|
+
|
|
45
|
+
def judge_fn(prompt: str) -> str:
|
|
46
|
+
resp = anthropic_client.messages.create(
|
|
47
|
+
model="claude-haiku-4-5-20251001",
|
|
48
|
+
max_tokens=200,
|
|
49
|
+
messages=[{"role": "user", "content": prompt}],
|
|
50
|
+
)
|
|
51
|
+
return resp.content[0].text
|
|
52
|
+
"""
|
|
53
|
+
self.judge_fn = judge_fn
|
|
54
|
+
|
|
55
|
+
def score(self, response: str, context: Optional[str]) -> Optional[dict]:
|
|
56
|
+
"""
|
|
57
|
+
Returns {"score": float, "reason": str}, or None if no context
|
|
58
|
+
was provided (faithfulness is undefined without grounding context).
|
|
59
|
+
"""
|
|
60
|
+
if not context:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
prompt = JUDGE_PROMPT_TEMPLATE.format(context=context, response=response)
|
|
64
|
+
raw = self.judge_fn(prompt)
|
|
65
|
+
return parse_judge_output(raw)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Prometheus metric definitions for LLM eval observability.
|
|
3
|
+
|
|
4
|
+
These are the metrics that get scraped by Prometheus and visualized
|
|
5
|
+
in Grafana. Everything else in this package exists to compute values
|
|
6
|
+
for these metrics.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from prometheus_client import Counter, Histogram, Gauge
|
|
10
|
+
|
|
11
|
+
# --- Request-level metrics ---
|
|
12
|
+
|
|
13
|
+
LLM_REQUEST_LATENCY = Histogram(
|
|
14
|
+
"llm_request_latency_seconds",
|
|
15
|
+
"Latency of LLM calls",
|
|
16
|
+
["model", "provider"],
|
|
17
|
+
buckets=(0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
LLM_REQUEST_TOTAL = Counter(
|
|
21
|
+
"llm_request_total",
|
|
22
|
+
"Total number of LLM requests made",
|
|
23
|
+
["model", "provider", "status"],
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
LLM_TOKENS_TOTAL = Counter(
|
|
27
|
+
"llm_tokens_total",
|
|
28
|
+
"Total tokens consumed",
|
|
29
|
+
["model", "provider", "type"], # type = prompt|completion
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# --- Eval metrics (the actual differentiator) ---
|
|
33
|
+
|
|
34
|
+
LLM_FAITHFULNESS_SCORE = Histogram(
|
|
35
|
+
"llm_faithfulness_score",
|
|
36
|
+
"Judge-model faithfulness score (0-1) for responses grounded in provided context",
|
|
37
|
+
["model"],
|
|
38
|
+
buckets=(0.0, 0.2, 0.4, 0.6, 0.7, 0.8, 0.9, 1.0),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
LLM_SEMANTIC_DRIFT = Gauge(
|
|
42
|
+
"llm_semantic_drift",
|
|
43
|
+
"Cosine distance between current and baseline embedding for a tracked prompt",
|
|
44
|
+
["model", "prompt_id"],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
LLM_EVAL_FAILURES_TOTAL = Counter(
|
|
48
|
+
"llm_eval_failures_total",
|
|
49
|
+
"Count of responses that failed an eval check (below threshold)",
|
|
50
|
+
["model", "check"], # check = faithfulness|drift
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# --- Cost metrics ---
|
|
54
|
+
|
|
55
|
+
LLM_COST_USD_TOTAL = Counter(
|
|
56
|
+
"llm_cost_usd_total",
|
|
57
|
+
"Estimated cost in USD based on token usage and a static price table",
|
|
58
|
+
["model", "provider"],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# --- Session-level metrics (a session = a multi-turn conversation) ---
|
|
62
|
+
|
|
63
|
+
LLM_SESSION_FAITHFULNESS_AVG = Gauge(
|
|
64
|
+
"llm_session_faithfulness_avg",
|
|
65
|
+
"Rolling average faithfulness score across all turns in a session",
|
|
66
|
+
["session_id"],
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
LLM_SESSION_DRIFT_MAX = Gauge(
|
|
70
|
+
"llm_session_drift_max",
|
|
71
|
+
"Maximum semantic drift observed across all turns in a session",
|
|
72
|
+
["session_id"],
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
LLM_SESSION_TURNS_TOTAL = Counter(
|
|
76
|
+
"llm_session_turns_total",
|
|
77
|
+
"Number of tracked turns within a session",
|
|
78
|
+
["session_id"],
|
|
79
|
+
)
|