evalban 0.1.0__py3-none-any.whl

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.
evalban/__init__.py ADDED
@@ -0,0 +1,244 @@
1
+ """Evalban eval SDK for Python. Standard library only.
2
+
3
+ from evalban import Evalban
4
+ client = Evalban(api_key=os.environ["EVALBAN_API_KEY"], url=os.environ["EVALBAN_URL"])
5
+
6
+ # Pull the eval set (includes cases promoted from fixed bugs), run it, send the results.
7
+ cases = client.get_eval_cases("listing-classifier")
8
+ res = client.log_run("listing-classifier", 0.871, version="ft-1027",
9
+ failures=[{"input": "Grey sofa", "expected": "Furniture", "output": "Outdoor"}],
10
+ slices={"furniture": 0.74, "electronics": 0.91},
11
+ scores={"hallucination_rate": 0.03},
12
+ examples=[{"key": c["key"], "passed": True} for c in cases])
13
+ if res["gate"] == "regressed":
14
+ print("Evalban:", res["bug_key"])
15
+
16
+ # Production feedback
17
+ client.send_feedback("reply-drafts", [{"kind": "negative", "input": "...", "output": "..."}])
18
+
19
+ Command line (import results from another tool and send them):
20
+ python -m evalban log --experiment listing-classifier --file results.json --format promptfoo --version ft-1027
21
+ Formats: evalban, promptfoo, jsonl, csv. Reads EVALBAN_API_KEY and EVALBAN_URL from the environment.
22
+ """
23
+ import csv
24
+ import io
25
+ import json
26
+ import os
27
+ import sys
28
+ import urllib.error
29
+ import urllib.parse
30
+ import urllib.request
31
+
32
+ __all__ = ["Evalban", "EvalbanError", "load_results"]
33
+
34
+
35
+ class EvalbanError(Exception):
36
+ def __init__(self, message, status):
37
+ super().__init__(message)
38
+ self.status = status
39
+
40
+
41
+ class Evalban:
42
+ def __init__(self, api_key, url, timeout=15):
43
+ if not api_key:
44
+ raise ValueError("Evalban: api_key is required")
45
+ if not url:
46
+ raise ValueError("Evalban: url is required (your Supabase project URL)")
47
+ self.api_key = api_key
48
+ url = url.rstrip("/")
49
+ self.endpoint = url if url.endswith("/eval-ingest") else url + "/functions/v1/eval-ingest"
50
+ self.base = self.endpoint[: -len("/eval-ingest")]
51
+ self.timeout = timeout
52
+
53
+ def _request(self, url, payload=None):
54
+ data = None if payload is None else json.dumps(payload).encode("utf-8")
55
+ req = urllib.request.Request(url, data=data, method="GET" if payload is None else "POST", headers={
56
+ "Authorization": "Bearer " + self.api_key,
57
+ "Content-Type": "application/json",
58
+ })
59
+ try:
60
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
61
+ return json.loads(resp.read().decode("utf-8"))
62
+ except urllib.error.HTTPError as e:
63
+ try:
64
+ message = json.loads(e.read().decode("utf-8")).get("error")
65
+ except Exception:
66
+ message = None
67
+ raise EvalbanError(message or "Evalban returned %d" % e.code, e.code) from None
68
+
69
+ def log_run(self, experiment, score, version="", failures=None,
70
+ cost_per_call_usd=None, latency_p95_ms=None, tokens_per_call=None,
71
+ slices=None, scores=None, examples=None):
72
+ """Send one eval run.
73
+
74
+ Returns a dict with gate ('pass', 'suspect' or 'regressed'), verdict, reasons, bug_key and bug_filed,
75
+ returned (keys of fixed bugs that failed again), and over_budget (e.g. ['cost'])."""
76
+ payload = {
77
+ "experiment": experiment,
78
+ "score": float(score),
79
+ "version": version or "",
80
+ "failures": list(failures or []),
81
+ "cost_per_call_usd": cost_per_call_usd,
82
+ "latency_p95_ms": latency_p95_ms,
83
+ "tokens_per_call": tokens_per_call,
84
+ }
85
+ for name, value in (("slices", slices), ("scores", scores), ("examples", examples)):
86
+ if value is not None:
87
+ payload[name] = value
88
+ return self._request(self.endpoint, payload)
89
+
90
+ def get_eval_cases(self, experiment):
91
+ """The experiment's eval set: [{"key", "input", "expected"}], including cases promoted from bugs."""
92
+ return self._request(self.base + "/eval-cases?experiment=" + urllib.parse.quote(experiment))["cases"]
93
+
94
+ def send_feedback(self, experiment, events):
95
+ """Send production feedback. Each event: {"kind": "negative" | "positive" | "flagged", "input", "output", "version", "trace_id"}."""
96
+ return self._request(self.base + "/feedback-ingest", {"experiment": experiment, "events": list(events)})
97
+
98
+
99
+ # Importers: results from other tools -> one Evalban run. Mirrors actions/evalban-eval/importers.mjs.
100
+
101
+ def _truthy(v):
102
+ if isinstance(v, bool):
103
+ return v
104
+ return str(v if v is not None else "").strip().lower() in ("true", "1", "yes", "pass", "passed")
105
+
106
+
107
+ def _text(v):
108
+ if v is None:
109
+ return ""
110
+ return v if isinstance(v, str) else json.dumps(v, separators=(",", ":"), ensure_ascii=False)
111
+
112
+
113
+ def _number(v):
114
+ if v is None or v == "" or isinstance(v, bool):
115
+ return None
116
+ try:
117
+ f = float(v)
118
+ except (TypeError, ValueError):
119
+ return None
120
+ return f if f == f and f not in (float("inf"), float("-inf")) else None
121
+
122
+
123
+ def _round(x):
124
+ r = round(x * 10000) / 10000
125
+ return int(r) if r == int(r) else r
126
+
127
+
128
+ def from_rows(rows):
129
+ if not rows:
130
+ raise ValueError("No results found in the file")
131
+ norm = []
132
+ for i, r in enumerate(rows):
133
+ score = _number(r.get("score"))
134
+ has_passed = r.get("passed") not in (None, "")
135
+ passed = _truthy(r.get("passed")) if has_passed else (score >= 0.5 if score is not None else False)
136
+ key = r.get("id") if r.get("id") is not None else r.get("key") if r.get("key") is not None else "row-%d" % (i + 1)
137
+ sl = r.get("slice") if r.get("slice") is not None else r.get("category") if r.get("category") is not None else ""
138
+ norm.append({"key": _text(key)[:100], "input": _text(r.get("input")), "expected": _text(r.get("expected")),
139
+ "output": _text(r.get("output")), "passed": passed, "score": score, "slice": _text(sl)})
140
+
141
+ def score_of(rs):
142
+ s = [r["score"] for r in rs if r["score"] is not None]
143
+ return _round(sum(s) / len(s) if s else sum(1 for r in rs if r["passed"]) / len(rs))
144
+
145
+ out = {
146
+ "score": score_of(norm),
147
+ "failures": [{"input": r["input"], "expected": r["expected"], "output": r["output"]} for r in norm if not r["passed"]][:100],
148
+ "examples": [{"key": r["key"], "passed": r["passed"], "output": r["output"][:2000]} for r in norm[:5000]],
149
+ }
150
+ names = []
151
+ for r in norm:
152
+ if r["slice"] and r["slice"] not in names:
153
+ names.append(r["slice"])
154
+ if names:
155
+ out["slices"] = {n: score_of([r for r in norm if r["slice"] == n]) for n in names[:200]}
156
+ return out
157
+
158
+
159
+ def _from_promptfoo(doc):
160
+ res = doc.get("results") if isinstance(doc, dict) else None
161
+ items = res.get("results") if isinstance(res, dict) else res
162
+ if not isinstance(items, list):
163
+ raise ValueError('This does not look like a promptfoo results file (no "results" list)')
164
+ rows = []
165
+ for i, r in enumerate(items):
166
+ tc = r.get("testCase") or {}
167
+ vars_ = r.get("vars") or tc.get("vars") or {}
168
+ inp = next((vars_[k] for k in ("input", "question", "prompt", "query") if k in vars_), None)
169
+ if inp is None:
170
+ inp = vars_ if vars_ else (r.get("prompt") or {}).get("raw", "")
171
+ if tc.get("description") is not None:
172
+ key = tc["description"]
173
+ elif r.get("testIdx") is not None:
174
+ key = "test-%s%s" % (r["testIdx"], "-p%s" % r["promptIdx"] if r.get("promptIdx") else "")
175
+ else:
176
+ key = "test-%d" % (i + 1)
177
+ grading = r.get("gradingResult") or {}
178
+ meta = tc.get("metadata") or {}
179
+ rows.append({
180
+ "id": key, "input": inp, "expected": vars_.get("expected", vars_.get("answer", "")),
181
+ "output": (r.get("response") or {}).get("output", r.get("output", "")),
182
+ "passed": r.get("success") if r.get("success") is not None else grading.get("pass"),
183
+ "score": r.get("score") if isinstance(r.get("score"), (int, float)) and not isinstance(r.get("score"), bool) else grading.get("score"),
184
+ "slice": meta.get("slice", meta.get("category", vars_.get("slice", vars_.get("category", "")))),
185
+ })
186
+ return from_rows(rows)
187
+
188
+
189
+ def load_results(src, fmt="evalban"):
190
+ """Parse eval results text in one of: evalban, promptfoo, jsonl, csv."""
191
+ if fmt == "evalban":
192
+ return json.loads(src)
193
+ if fmt == "jsonl":
194
+ rows = []
195
+ for i, line in enumerate(l.strip() for l in src.splitlines()):
196
+ if not line:
197
+ continue
198
+ try:
199
+ rows.append(json.loads(line))
200
+ except ValueError:
201
+ raise ValueError("Line %d is not valid JSON" % (i + 1)) from None
202
+ return from_rows(rows)
203
+ if fmt == "csv":
204
+ reader = csv.reader(io.StringIO(src))
205
+ all_rows = [r for r in reader if any(c.strip() for c in r)]
206
+ if not all_rows:
207
+ return from_rows([])
208
+ head = [h.strip().lower() for h in all_rows[0]]
209
+ return from_rows([{h: (r[i] if i < len(r) else "") for i, h in enumerate(head)} for r in all_rows[1:]])
210
+ if fmt == "promptfoo":
211
+ return _from_promptfoo(json.loads(src))
212
+ raise ValueError('Unknown format "%s". Use evalban, jsonl, csv or promptfoo.' % fmt)
213
+
214
+
215
+ def _main(argv):
216
+ import argparse
217
+ p = argparse.ArgumentParser(prog="python -m evalban", description="Send eval results to Evalban.")
218
+ sub = p.add_subparsers(dest="cmd", required=True)
219
+ lg = sub.add_parser("log", help="Send a results file as one eval run")
220
+ lg.add_argument("--experiment", required=True)
221
+ lg.add_argument("--file", required=True)
222
+ lg.add_argument("--format", default="evalban", choices=["evalban", "promptfoo", "jsonl", "csv"])
223
+ lg.add_argument("--version", default="")
224
+ lg.add_argument("--dry-run", action="store_true", help="Print what would be sent")
225
+ a = p.parse_args(argv)
226
+ with open(a.file, encoding="utf-8") as fh:
227
+ run = load_results(fh.read(), a.format)
228
+ if a.dry_run:
229
+ print(json.dumps(run, indent=2))
230
+ return 0
231
+ client = Evalban(os.environ.get("EVALBAN_API_KEY", ""), os.environ.get("EVALBAN_URL", ""))
232
+ res = client.log_run(a.experiment, run["score"], version=a.version, failures=run.get("failures"),
233
+ slices=run.get("slices"), scores=run.get("scores"), examples=run.get("examples"))
234
+ print(json.dumps(res))
235
+ return 1 if res.get("gate") == "regressed" else 0
236
+
237
+
238
+ if __name__ == "__main__":
239
+ sys.exit(_main(sys.argv[1:]))
240
+
241
+
242
+ def _cli():
243
+ """Entry point for the `evalban` command."""
244
+ sys.exit(_main(sys.argv[1:]))
evalban/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ import sys
2
+ from . import _main
3
+ sys.exit(_main(sys.argv[1:]))
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: evalban
3
+ Version: 0.1.0
4
+ Summary: Send eval runs and production feedback to Evalban, the board that knows when your model got worse.
5
+ Author-email: Evalban <hello@evalban.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://evalban.com
8
+ Keywords: evals,llm,machine learning,regression testing,project management
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Software Development :: Testing
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # evalban
19
+
20
+ Python client for [Evalban](https://evalban.com): project management for AI product teams, with your evals built in.
21
+ Send an eval run, and if the score lands below baseline Evalban files a bug with the failing examples, blocks the
22
+ merge and tells Slack.
23
+
24
+ ```bash
25
+ pip install evalban
26
+ ```
27
+
28
+ No dependencies beyond the Python standard library.
29
+
30
+ ## Send an eval run
31
+
32
+ ```python
33
+ import os
34
+ from evalban import Evalban
35
+
36
+ client = Evalban(api_key=os.environ["EVALBAN_API_KEY"], url=os.environ["EVALBAN_URL"])
37
+
38
+ res = client.log_run("listing-classifier", 0.871,
39
+ version="ft-1027",
40
+ failures=[{"input": "Grey sofa", "expected": "Furniture", "output": "Outdoor"}],
41
+ slices={"furniture": 0.74},
42
+ cost_per_call_usd=0.0004, latency_p95_ms=168)
43
+
44
+ if res["gate"] == "regressed":
45
+ print("Filed", res["bug_key"])
46
+ ```
47
+
48
+ `EVALBAN_URL` and your API key are on the **API keys** page in Evalban.
49
+
50
+ ## Use your eval set
51
+
52
+ Cases promoted from fixed bugs come back in the eval set, so a fixed failure can't quietly return.
53
+
54
+ ```python
55
+ cases = client.get_eval_cases("listing-classifier")
56
+ ```
57
+
58
+ ## Production feedback
59
+
60
+ ```python
61
+ client.send_feedback("reply-drafts", [{"kind": "negative", "input": "...", "output": "..."}])
62
+ ```
63
+
64
+ ## Command line
65
+
66
+ ```bash
67
+ evalban log --experiment listing-classifier --file results.json --format promptfoo --version ft-1027
68
+ ```
69
+
70
+ Formats: `evalban`, `promptfoo`, `jsonl`, `csv`. Reads `EVALBAN_API_KEY` and `EVALBAN_URL` from the environment.
71
+ Exits with status 1 when the run regressed, so it can fail a CI job.
72
+
73
+ ## License
74
+
75
+ MIT
@@ -0,0 +1,8 @@
1
+ evalban/__init__.py,sha256=s6_7pyZ_4jtzDyj66wX5M6Vvm30maPzcfDiivG41Xtc,10689
2
+ evalban/__main__.py,sha256=WEJTlvTrTox0g1lPXsFVFmVJBPuUZ7laXwYut8WNgy0,61
3
+ evalban-0.1.0.dist-info/licenses/LICENSE,sha256=32lHQAA4t8uqo-atgDMB1FHZsdBpH9CYtDPzIzinhUE,1074
4
+ evalban-0.1.0.dist-info/METADATA,sha256=OHAfQM8OOk7GCC5XbJLS--v_ZrlH5gA2rPPSr3CaCUk,2186
5
+ evalban-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
6
+ evalban-0.1.0.dist-info/entry_points.txt,sha256=sk6HTIrrav16Z87JzRqCbBBDRwYd2p9821dQC7IxI0Q,41
7
+ evalban-0.1.0.dist-info/top_level.txt,sha256=VwGwgHzxtgtwLvYDJTE3KZRybQqmbFaBNJH4X7Fx-eY,8
8
+ evalban-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ evalban = evalban:_cli
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jeunome Achenjang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ evalban