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.
@@ -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,5 @@
1
+ from .tracker import LLMTracker
2
+ from .server import start_metrics_server
3
+
4
+ __all__ = ["LLMTracker", "start_metrics_server"]
5
+ __version__ = "0.1.0"
@@ -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
+ )