evalban 0.1.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.
- evalban-0.1.0/LICENSE +9 -0
- evalban-0.1.0/PKG-INFO +75 -0
- evalban-0.1.0/README.md +58 -0
- evalban-0.1.0/pyproject.toml +28 -0
- evalban-0.1.0/setup.cfg +4 -0
- evalban-0.1.0/src/evalban/__init__.py +244 -0
- evalban-0.1.0/src/evalban/__main__.py +3 -0
- evalban-0.1.0/src/evalban.egg-info/PKG-INFO +75 -0
- evalban-0.1.0/src/evalban.egg-info/SOURCES.txt +10 -0
- evalban-0.1.0/src/evalban.egg-info/dependency_links.txt +1 -0
- evalban-0.1.0/src/evalban.egg-info/entry_points.txt +2 -0
- evalban-0.1.0/src/evalban.egg-info/top_level.txt +1 -0
evalban-0.1.0/LICENSE
ADDED
|
@@ -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.
|
evalban-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
evalban-0.1.0/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# evalban
|
|
2
|
+
|
|
3
|
+
Python client for [Evalban](https://evalban.com): project management for AI product teams, with your evals built in.
|
|
4
|
+
Send an eval run, and if the score lands below baseline Evalban files a bug with the failing examples, blocks the
|
|
5
|
+
merge and tells Slack.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install evalban
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
No dependencies beyond the Python standard library.
|
|
12
|
+
|
|
13
|
+
## Send an eval run
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import os
|
|
17
|
+
from evalban import Evalban
|
|
18
|
+
|
|
19
|
+
client = Evalban(api_key=os.environ["EVALBAN_API_KEY"], url=os.environ["EVALBAN_URL"])
|
|
20
|
+
|
|
21
|
+
res = client.log_run("listing-classifier", 0.871,
|
|
22
|
+
version="ft-1027",
|
|
23
|
+
failures=[{"input": "Grey sofa", "expected": "Furniture", "output": "Outdoor"}],
|
|
24
|
+
slices={"furniture": 0.74},
|
|
25
|
+
cost_per_call_usd=0.0004, latency_p95_ms=168)
|
|
26
|
+
|
|
27
|
+
if res["gate"] == "regressed":
|
|
28
|
+
print("Filed", res["bug_key"])
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`EVALBAN_URL` and your API key are on the **API keys** page in Evalban.
|
|
32
|
+
|
|
33
|
+
## Use your eval set
|
|
34
|
+
|
|
35
|
+
Cases promoted from fixed bugs come back in the eval set, so a fixed failure can't quietly return.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
cases = client.get_eval_cases("listing-classifier")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Production feedback
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
client.send_feedback("reply-drafts", [{"kind": "negative", "input": "...", "output": "..."}])
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Command line
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
evalban log --experiment listing-classifier --file results.json --format promptfoo --version ft-1027
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Formats: `evalban`, `promptfoo`, `jsonl`, `csv`. Reads `EVALBAN_API_KEY` and `EVALBAN_URL` from the environment.
|
|
54
|
+
Exits with status 1 when the run regressed, so it can fail a CI job.
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
MIT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "evalban"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Send eval runs and production feedback to Evalban, the board that knows when your model got worse."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Evalban", email = "hello@evalban.com" }]
|
|
13
|
+
keywords = ["evals", "llm", "machine learning", "regression testing", "project management"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Software Development :: Testing",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://evalban.com"
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
evalban = "evalban:_cli"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["src"]
|
evalban-0.1.0/setup.cfg
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:]))
|
|
@@ -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,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/evalban/__init__.py
|
|
5
|
+
src/evalban/__main__.py
|
|
6
|
+
src/evalban.egg-info/PKG-INFO
|
|
7
|
+
src/evalban.egg-info/SOURCES.txt
|
|
8
|
+
src/evalban.egg-info/dependency_links.txt
|
|
9
|
+
src/evalban.egg-info/entry_points.txt
|
|
10
|
+
src/evalban.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
evalban
|