eval-builder 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.
@@ -0,0 +1,149 @@
1
+ """Optional judge runner. OFF by default.
2
+
3
+ eval-builder never calls a model provider and ships no API keys. This module only
4
+ starts a command YOU name (for example a script that calls your local model or your
5
+ provider), sends it one JSON request per line on stdin, and reads one JSON response
6
+ per line from stdout:
7
+
8
+ request (stdin): the row from judge_requests.jsonl (includes "prompt" when the
9
+ rubric judge has a prompt template)
10
+ response (stdout): {"verdict": "<label>", "raw": "<optional raw text>"}
11
+
12
+ It refuses to run unless enabled with --enable-judge-plugin or the environment
13
+ variable EVAL_BUILDER_ENABLE_JUDGE_PLUGIN=1.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import queue
21
+ import shlex
22
+ import subprocess
23
+ import threading
24
+ import time
25
+ from datetime import UTC, datetime
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from ..io import read_json, read_jsonl, write_json
30
+ from ..workspace import Workspace
31
+
32
+ ENV_FLAG = "EVAL_BUILDER_ENABLE_JUDGE_PLUGIN"
33
+
34
+
35
+ class PluginDisabled(PermissionError):
36
+ pass
37
+
38
+
39
+ def plugin_enabled(flag: bool) -> bool:
40
+ return flag or os.environ.get(ENV_FLAG) == "1"
41
+
42
+
43
+ class _LineProcess:
44
+ def __init__(self, argv: list[str]) -> None:
45
+ self.proc = subprocess.Popen(
46
+ argv,
47
+ stdin=subprocess.PIPE,
48
+ stdout=subprocess.PIPE,
49
+ stderr=subprocess.PIPE,
50
+ text=True,
51
+ bufsize=1,
52
+ )
53
+ self.lines: queue.Queue[str | None] = queue.Queue()
54
+ threading.Thread(target=self._pump, daemon=True).start()
55
+
56
+ def _pump(self) -> None:
57
+ assert self.proc.stdout is not None
58
+ for line in self.proc.stdout:
59
+ self.lines.put(line)
60
+ self.lines.put(None)
61
+
62
+ def ask(self, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
63
+ assert self.proc.stdin is not None
64
+ self.proc.stdin.write(json.dumps(payload, ensure_ascii=False) + "\n")
65
+ self.proc.stdin.flush()
66
+ try:
67
+ line = self.lines.get(timeout=timeout)
68
+ except queue.Empty as e:
69
+ raise TimeoutError(f"no response within {timeout}s") from e
70
+ if line is None:
71
+ err = self.proc.stderr.read() if self.proc.stderr else ""
72
+ raise RuntimeError(f"judge command exited: {err.strip()[-500:]}")
73
+ return json.loads(line)
74
+
75
+ def close(self) -> int:
76
+ if self.proc.stdin:
77
+ self.proc.stdin.close()
78
+ try:
79
+ return self.proc.wait(timeout=10)
80
+ except subprocess.TimeoutExpired:
81
+ self.proc.kill()
82
+ return -9
83
+
84
+
85
+ def judge_run(
86
+ workspace: str | Path,
87
+ commands: dict[str, str],
88
+ enable: bool = False,
89
+ timeout: float = 120.0,
90
+ resume: bool = True,
91
+ ) -> dict[str, Any]:
92
+ if not plugin_enabled(enable):
93
+ raise PluginDisabled(
94
+ "the judge plugin is off by default; pass --enable-judge-plugin or set "
95
+ f"{ENV_FLAG}=1 to let eval-builder run the judge command you provide"
96
+ )
97
+ ws = Workspace.at(workspace)
98
+ if not ws.judge_requests.exists():
99
+ raise FileNotFoundError(f"{ws.judge_requests} not found; run `eval-builder judge-plan`")
100
+ requests = list(read_jsonl(ws.judge_requests))
101
+ done: set[str] = set()
102
+ if resume and ws.judgments.exists():
103
+ done = {r["request_id"] for r in read_jsonl(ws.judgments) if "request_id" in r}
104
+ elif ws.judgments.exists():
105
+ ws.judgments.unlink()
106
+ log: dict[str, Any] = {"started": datetime.now(UTC).isoformat(), "judges": {}}
107
+ with open(ws.judgments, "a", encoding="utf-8") as out:
108
+ for jid, cmd in commands.items():
109
+ todo = [r for r in requests if r["judge"] == jid and r["request_id"] not in done]
110
+ entry: dict[str, Any] = {
111
+ "command": cmd,
112
+ "requests": len(todo),
113
+ "ok": 0,
114
+ "errors": 0,
115
+ "seconds": 0.0,
116
+ }
117
+ log["judges"][jid] = entry
118
+ if not todo:
119
+ continue
120
+ t0 = time.monotonic()
121
+ proc = _LineProcess(shlex.split(cmd))
122
+ try:
123
+ for r in todo:
124
+ row = {k: v for k, v in r.items() if k not in ("presented", "prompt")}
125
+ try:
126
+ resp = proc.ask(r, timeout)
127
+ row["verdict"] = resp.get("verdict")
128
+ # keep extra plugin fields (raw text, token counts) as evidence
129
+ row.update({k: v for k, v in resp.items() if k not in row})
130
+ entry["ok"] += 1
131
+ except (TimeoutError, RuntimeError, json.JSONDecodeError) as e:
132
+ row["verdict"] = None
133
+ row["error"] = str(e)
134
+ entry["errors"] += 1
135
+ if isinstance(e, RuntimeError):
136
+ out.write(json.dumps(row, ensure_ascii=False) + "\n")
137
+ break
138
+ out.write(json.dumps(row, ensure_ascii=False) + "\n")
139
+ out.flush()
140
+ finally:
141
+ entry["exit_code"] = proc.close()
142
+ entry["seconds"] = round(time.monotonic() - t0, 2)
143
+ missing = sorted({r["judge"] for r in requests} - set(commands))
144
+ log["finished"] = datetime.now(UTC).isoformat()
145
+ log["judges_without_command"] = missing
146
+ log_path = ws.root / "judge_run_log.json"
147
+ runs = read_json(log_path).get("runs", []) if log_path.exists() else []
148
+ write_json(log_path, {"runs": [*runs, log]})
149
+ return log
@@ -0,0 +1,76 @@
1
+ """Plain statistics used by judge-check. Kept small so tests can verify them by hand."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections import Counter
7
+ from collections.abc import Mapping, Sequence
8
+
9
+ Z95 = 1.959963984540054
10
+
11
+
12
+ def wilson(k: int, n: int, z: float = Z95) -> tuple[float, float] | None:
13
+ """Wilson score interval for a binomial proportion k/n."""
14
+ if n <= 0:
15
+ return None
16
+ p = k / n
17
+ denom = 1 + z * z / n
18
+ centre = (p + z * z / (2 * n)) / denom
19
+ half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denom
20
+ return (max(0.0, centre - half), min(1.0, centre + half))
21
+
22
+
23
+ def cohen_kappa(a: Sequence[str], b: Sequence[str]) -> tuple[float | None, float, float]:
24
+ """Return (kappa, observed agreement po, chance agreement pe). kappa is None if pe == 1."""
25
+ if len(a) != len(b):
26
+ raise ValueError("rating lists must have the same length")
27
+ n = len(a)
28
+ if n == 0:
29
+ return None, 0.0, 0.0
30
+ po = sum(x == y for x, y in zip(a, b, strict=True)) / n
31
+ ca, cb = Counter(a), Counter(b)
32
+ pe = sum(ca[label] * cb[label] for label in set(ca) | set(cb)) / (n * n)
33
+ if pe >= 1.0:
34
+ return None, po, pe
35
+ return (po - pe) / (1 - pe), po, pe
36
+
37
+
38
+ def kappa_interval(po: float, pe: float, n: int, z: float = Z95) -> tuple[float, float] | None:
39
+ """Approximate 95% interval using Cohen's (1960) large-sample standard error."""
40
+ if n <= 0 or pe >= 1.0:
41
+ return None
42
+ kappa = (po - pe) / (1 - pe)
43
+ se = math.sqrt(po * (1 - po) / (n * (1 - pe) ** 2))
44
+ return (max(-1.0, kappa - z * se), min(1.0, kappa + z * se))
45
+
46
+
47
+ def majority(labels: Sequence[str]) -> tuple[str | None, float]:
48
+ """Most common label and its share. Label is None when the top count is tied."""
49
+ if not labels:
50
+ return None, 0.0
51
+ counts = Counter(labels).most_common()
52
+ top, cnt = counts[0]
53
+ if len(counts) > 1 and counts[1][1] == cnt:
54
+ return None, cnt / len(labels)
55
+ return top, cnt / len(labels)
56
+
57
+
58
+ def majority_vote_stability(counts: Mapping[str, int], k: int = 3) -> float | None:
59
+ """Chance that a majority vote over k calls (drawn without replacement from the
60
+ observed trials) returns the same label as the majority over all trials.
61
+
62
+ Exact hypergeometric probability that the full-majority label gets more than half
63
+ of the k draws. None when there are fewer than k + 2 trials (too few to say
64
+ anything beyond the full vote) or no strict majority.
65
+ """
66
+ n = sum(counts.values())
67
+ if n < k + 2 or not counts:
68
+ return None
69
+ ordered = sorted(counts.values(), reverse=True)
70
+ if len(ordered) > 1 and ordered[0] == ordered[1]:
71
+ return None
72
+ big = ordered[0]
73
+ need = k // 2 + 1
74
+ total = math.comb(n, k)
75
+ hits = sum(math.comb(big, x) * math.comb(n - big, k - x) for x in range(need, k + 1))
76
+ return hits / total
@@ -0,0 +1,175 @@
1
+ """MCP server (stdio). Every tool returns the same JSON the CLI prints with --json.
2
+
3
+ The opt-in judge runner is deliberately not exposed here: it executes a command,
4
+ which should stay an explicit CLI action by the user.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from . import draft as draft_mod
12
+ from .export import export as export_fn
13
+ from .ingest import ingest as ingest_fn
14
+ from .judge.check import Thresholds
15
+ from .judge.check import judge_check as judge_check_fn
16
+ from .judge.plan import judge_plan as judge_plan_fn
17
+ from .report import build_report
18
+ from .select import select as select_fn
19
+ from .status import status as status_fn
20
+
21
+ INSTRUCTIONS = (
22
+ "eval-builder turns an app's logs into an eval suite and checks LLM judges. Workflow: "
23
+ "ingest -> select -> draft (you fill expected_behavior and criteria with the user, via "
24
+ "update_case and set_rubric) -> validate -> judge_plan (run each request several times "
25
+ "with the user's judge, save judgments.jsonl) -> judge_check -> export -> report. "
26
+ "Never invent human labels; ask the user for them."
27
+ )
28
+
29
+
30
+ def _server() -> Any:
31
+ try:
32
+ from mcp.server.mcpserver import MCPServer
33
+
34
+ return MCPServer("eval-builder", instructions=INSTRUCTIONS)
35
+ except ImportError:
36
+ from mcp.server.fastmcp import FastMCP # type: ignore[attr-defined] # mcp 1.x
37
+
38
+ return FastMCP("eval-builder", instructions=INSTRUCTIONS)
39
+
40
+
41
+ def build_server() -> Any:
42
+ mcp = _server()
43
+
44
+ @mcp.tool()
45
+ def ingest(
46
+ paths: list[str], workspace: str = "evalset", format: str | None = None, redact: bool = True
47
+ ) -> dict[str, Any]:
48
+ """Read traces (OpenAI chat JSONL, Anthropic messages, Langfuse export, OpenTelemetry
49
+ GenAI spans, or generic input/output JSONL), normalize and redact them."""
50
+ return ingest_fn(paths, workspace, format, redact)
51
+
52
+ @mcp.tool()
53
+ def select(
54
+ workspace: str = "evalset",
55
+ n: int = 50,
56
+ seed: int = 0,
57
+ clusters: int | None = None,
58
+ failure_share: float = 0.3,
59
+ near_dup_threshold: float = 0.9,
60
+ dedupe_on: str = "input",
61
+ stratify: list[str] | None = None,
62
+ ) -> dict[str, Any]:
63
+ """Pick a diverse, representative set of traces and record why each was picked."""
64
+ r = select_fn(
65
+ workspace, n, seed, clusters, failure_share, near_dup_threshold, dedupe_on, stratify
66
+ )
67
+ return {k: v for k, v in r.items() if k != "selected"} | {
68
+ "selected": [
69
+ {"trace_id": s["trace_id"], "reasons": s["reasons"]} for s in r["selected"]
70
+ ]
71
+ }
72
+
73
+ @mcp.tool()
74
+ def draft(workspace: str = "evalset", suite: str = "eval-builder suite") -> dict[str, Any]:
75
+ """Write cases.yaml and rubric.yaml skeletons from the selection (keeps filled cases)."""
76
+ return draft_mod.draft(workspace, suite)
77
+
78
+ @mcp.tool()
79
+ def list_cases(workspace: str = "evalset", status: str | None = None) -> list[dict[str, Any]]:
80
+ """Return the cases in cases.yaml, optionally filtered by status."""
81
+ cases = draft_mod.load_cases(workspace).get("cases") or []
82
+ return [c for c in cases if status is None or c.get("status") == status]
83
+
84
+ @mcp.tool()
85
+ def update_case(
86
+ case_id: str,
87
+ workspace: str = "evalset",
88
+ expected_behavior: str | None = None,
89
+ criteria: list[str] | None = None,
90
+ reference_output: str | None = None,
91
+ compare_output: str | None = None,
92
+ status: str | None = None,
93
+ notes: str | None = None,
94
+ ) -> dict[str, Any]:
95
+ """Fill in or change one case. status is draft, ready or dropped."""
96
+ fields = {
97
+ "expected_behavior": expected_behavior,
98
+ "criteria": criteria,
99
+ "reference_output": reference_output,
100
+ "compare_output": compare_output,
101
+ "status": status,
102
+ "notes": notes,
103
+ }
104
+ return draft_mod.update_case(
105
+ workspace, case_id, **{k: v for k, v in fields.items() if v is not None}
106
+ )
107
+
108
+ @mcp.tool()
109
+ def set_rubric(
110
+ criteria: list[dict[str, Any]],
111
+ judges: list[dict[str, Any]] | None = None,
112
+ workspace: str = "evalset",
113
+ ) -> dict[str, Any]:
114
+ """Replace rubric.yaml. criteria: [{id, description, scale}]. judges: [{id, mode
115
+ (pointwise|pairwise), criteria, labels, prompt}]."""
116
+ return draft_mod.write_rubric(workspace, {"criteria": criteria, "judges": judges or []})
117
+
118
+ @mcp.tool()
119
+ def validate(workspace: str = "evalset") -> dict[str, Any]:
120
+ """Check cases.yaml and rubric.yaml; ready cases must have no TODO left."""
121
+ return draft_mod.validate(workspace)
122
+
123
+ @mcp.tool()
124
+ def judge_plan(
125
+ workspace: str = "evalset",
126
+ judges: list[str] | None = None,
127
+ trials: int = 5,
128
+ probes: list[str] | None = None,
129
+ probe_trials: int = 3,
130
+ ) -> dict[str, Any]:
131
+ """Write judge_requests.jsonl: every judge call to make, repeated `trials` times, plus
132
+ optional probes (swap: answer order, pairwise only; pad: irrelevant length)."""
133
+ return judge_plan_fn(workspace, judges, trials, probes, probe_trials)
134
+
135
+ @mcp.tool()
136
+ def judge_check(
137
+ workspace: str = "evalset",
138
+ judgments: str | None = None,
139
+ labels: str | None = None,
140
+ max_flip_rate: float = 0.2,
141
+ min_kappa: float = 0.4,
142
+ min_labeled: int = 20,
143
+ min_cases: int = 10,
144
+ ) -> dict[str, Any]:
145
+ """Flip rate, human agreement (accuracy, kappa), position and verbosity probes, and a
146
+ verdict per judge: trustworthy, unstable, biased, misaligned or not_enough_data."""
147
+ th = Thresholds(
148
+ max_flip_rate=max_flip_rate,
149
+ min_kappa=min_kappa,
150
+ min_labeled=min_labeled,
151
+ min_cases=min_cases,
152
+ )
153
+ r = judge_check_fn(workspace, judgments, labels, th)
154
+ return {k: v for k, v in r.items() if k != "judges"}
155
+
156
+ @mcp.tool()
157
+ def export(workspace: str = "evalset", formats: list[str] | None = None) -> dict[str, Any]:
158
+ """Export ready cases to promptfoo, deepeval, inspect and/or jsonl."""
159
+ return export_fn(workspace, formats)
160
+
161
+ @mcp.tool()
162
+ def report(workspace: str = "evalset", title: str | None = None) -> dict[str, Any]:
163
+ """Write report.md and report.json covering every step that has run."""
164
+ return build_report(workspace, title)
165
+
166
+ @mcp.tool()
167
+ def status(workspace: str = "evalset") -> dict[str, Any]:
168
+ """Which steps have run in this workspace and what to do next."""
169
+ return status_fn(workspace)
170
+
171
+ return mcp
172
+
173
+
174
+ def main() -> None:
175
+ build_server().run("stdio")
eval_builder/redact.py ADDED
@@ -0,0 +1,101 @@
1
+ """Default-on redaction of obvious secrets and PII.
2
+
3
+ Patterns are deliberately conservative: they catch common key formats, emails,
4
+ card numbers that pass a Luhn check, US-style phone numbers with separators, and
5
+ SSN-shaped numbers. They are not a DLP system; the report lists exactly what was
6
+ replaced so you can check.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections import Counter
13
+ from typing import Any
14
+
15
+ _PATTERNS: list[tuple[str, re.Pattern[str]]] = [
16
+ (
17
+ "private_key",
18
+ re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----"),
19
+ ),
20
+ ("anthropic_key", re.compile(r"\bsk-ant-[A-Za-z0-9_-]{20,}")),
21
+ ("openai_key", re.compile(r"\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}")),
22
+ ("aws_access_key", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
23
+ ("github_token", re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})")),
24
+ ("slack_token", re.compile(r"\bxox[abposr]-[A-Za-z0-9-]{10,}")),
25
+ ("google_api_key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b")),
26
+ ("stripe_key", re.compile(r"\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}")),
27
+ ("jwt", re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}")),
28
+ ("bearer_token", re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/-]{20,}=*")),
29
+ ("email", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")),
30
+ ("ssn", re.compile(r"\b\d{3}-\d{2}-\d{4}\b")),
31
+ ("phone", re.compile(r"(?<![\d-])(?:\+1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}(?![\d-])")),
32
+ ("credit_card", re.compile(r"\b\d(?:[ -]?\d){12,18}\b")),
33
+ ]
34
+
35
+
36
+ def _luhn_ok(digits: str) -> bool:
37
+ total = 0
38
+ for i, ch in enumerate(reversed(digits)):
39
+ d = int(ch)
40
+ if i % 2 == 1:
41
+ d *= 2
42
+ if d > 9:
43
+ d -= 9
44
+ total += d
45
+ return total % 10 == 0
46
+
47
+
48
+ class Redactor:
49
+ """Replaces matches with [REDACTED_<KIND>] and counts what it replaced."""
50
+
51
+ def __init__(self, enabled: bool = True) -> None:
52
+ self.enabled = enabled
53
+ self.counts: Counter[str] = Counter()
54
+ self.by_trace: dict[str, Counter[str]] = {}
55
+ self._current: str | None = None
56
+
57
+ def start_trace(self, trace_id: str) -> None:
58
+ self._current = trace_id
59
+
60
+ def text(self, s: str, count: bool = True) -> str:
61
+ """Redact one string. count=False redacts text already counted elsewhere."""
62
+ if not self.enabled or not s:
63
+ return s
64
+ for kind, pat in _PATTERNS:
65
+
66
+ def repl(m: re.Match[str], kind: str = kind) -> str:
67
+ return self._replace(kind, m.group(0), count)
68
+
69
+ s = pat.sub(repl, s)
70
+ return s
71
+
72
+ def _replace(self, kind: str, match: str, count: bool = True) -> str:
73
+ if kind == "credit_card":
74
+ digits = re.sub(r"\D", "", match)
75
+ if not (13 <= len(digits) <= 19 and _luhn_ok(digits)):
76
+ return match
77
+ if not count:
78
+ return f"[REDACTED_{kind.upper()}]"
79
+ self.counts[kind] += 1
80
+ if self._current is not None:
81
+ self.by_trace.setdefault(self._current, Counter())[kind] += 1
82
+ return f"[REDACTED_{kind.upper()}]"
83
+
84
+ def value(self, v: Any) -> Any:
85
+ if isinstance(v, str):
86
+ return self.text(v)
87
+ if isinstance(v, list):
88
+ return [self.value(x) for x in v]
89
+ if isinstance(v, dict):
90
+ return {k: self.value(x) for k, x in v.items()}
91
+ return v
92
+
93
+ def summary(self) -> dict[str, Any]:
94
+ return {
95
+ "enabled": self.enabled,
96
+ "total": sum(self.counts.values()),
97
+ "by_kind": dict(sorted(self.counts.items())),
98
+ "traces_affected": len(self.by_trace),
99
+ "by_trace": {t: dict(c) for t, c in sorted(self.by_trace.items())},
100
+ "note": "Values are replaced with [REDACTED_<KIND>]; originals are never written.",
101
+ }