mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 05:49:50 +00:00
7512f5500b
* docs(decision): README decision-provider section, and the day-73 eval scripts with their recorded baseline (TASK-3123) Housekeeping for PLAN-3114 tranche 1; no behaviour change. - README: a Decision provider (optional) section covering what uses it, that answers are asynchronous, configuration precedence (config file, then the admin setting, then the environment; PAD_DECISION_PROVIDER=none as the operator off-switch), and exactly what leaves the box and where. - scripts/decision-eval/eval.py and eval2.py: the day-73 scripts, as run (standard library, raw systemone API, key read from TYPESAFE_API_KEY). A read and a pattern scan found no key or token literal. - scripts/decision-eval/README.md: what each script measures, how to run it, how to regenerate the corpora, and the day-73 baseline. A, B and D were recomputed from the saved result files with the scripts' own summary logic and match PLAN-3114 (D AUC 0.939; the B >=0.8 bucket 86.8%). C comes from PLAN-3114's trail. eval.py --dry-run over the saved corpora queues the same 360 items the day-73 run did. - scripts/decision-eval/.gitignore: the corpora are private workspace exports and the results embed their content, so neither can be committed; neither was. The CHANGELOG line is queued as DOC-3242 (draft highlights for the release after v0.15.0, per PLAYB-1160), since releases take their changelog from goreleaser and a highlights DOC. Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 * docs(decision): the README section states what off, the triggers, server info and disabling actually do (TASK-3123, codex round 1) Codex fact-checked every claim against the code, and four were too broad. Off is not "every surface unchanged": the decisions list is empty and playbook match answers 404. Jobs are also queued by restore and by comment edit and delete (verified at items.go:614/3207/3437/5459 and comments.go:133/196/416), and a closed item's job is dropped at the tick. pad server info cannot see the admin setting. Disabling stops new calls without a restart, but an in-flight request may finish. The scripts README now says eval2.py runs at the top level. Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
199 lines
10 KiB
Python
199 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Jev triage eval against docapp's human-labelled items.
|
|
|
|
Reads TYPESAFE_API_KEY from the environment (never printed). Input: the
|
|
bugs/tasks/ideas JSON dumps in this directory. Output: per-item JSONL and a
|
|
summary. Two tasks:
|
|
|
|
A. field recovery — for items where a human set the field, ask a Choice
|
|
over the schema's options and measure agreement vs confidence.
|
|
B. collection classification — hide the collection, ask bug/task/idea.
|
|
|
|
Usage: eval.py [--n-per-collection N] [--model jev-latest] [--dry-run]
|
|
"""
|
|
import argparse, json, os, random, sys, time, urllib.request, urllib.error
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from collections import Counter, defaultdict
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
API = "https://api.typesafe.ai/v1/systemone"
|
|
MAX_BODY = 6000 # chars of content; keep state focused (docs: irrelevant state degrades)
|
|
|
|
COMPONENTS = ["web", "server", "cli", "store", "mcp", "backend", "frontend", "docs", "ci", "other"]
|
|
|
|
FIELD_QUESTIONS = {
|
|
"bugs": {
|
|
"severity": ("How severe is this bug for users of a self-hosted project tracker used by developers and AI agents?",
|
|
{"low": "cosmetic, or a workaround is obvious and cheap",
|
|
"medium": "a real defect a user will hit in normal use; workaround exists or impact is contained",
|
|
"high": "breaks a core flow, data shown wrong, or no reasonable workaround",
|
|
"critical": "data loss or corruption, security exposure, or the product is unusable"}),
|
|
"component": ("Which part of the codebase does the fix for this bug primarily live in?",
|
|
{"web": "the SvelteKit web UI (components, pages, editor)",
|
|
"server": "the Go HTTP API handlers and server wiring",
|
|
"cli": "the `pad` command-line tool and its client",
|
|
"store": "the database layer, migrations, SQL",
|
|
"mcp": "the MCP tool catalog and dispatch for agents",
|
|
"backend": "Go code not clearly server, store or mcp",
|
|
"frontend": "web code described as frontend rather than a specific component",
|
|
"docs": "documentation, README, instructions text",
|
|
"ci": "GitHub Actions, build, release tooling",
|
|
"other": "none of the above"}),
|
|
},
|
|
"tasks": {
|
|
"priority": ("How urgent is this task relative to other work on the same product?",
|
|
{"low": "nice to have; no one is waiting on it",
|
|
"medium": "should be done in the normal course of work",
|
|
"high": "blocks other work, a user, or a release; do soon",
|
|
"critical": "drop everything; production or data is at risk"}),
|
|
"effort": ("How much implementation effort does this task take for an experienced engineer or agent?",
|
|
{"xs": "minutes: a one-line or one-file change",
|
|
"s": "an hour or two: a small contained change with a test",
|
|
"m": "half a day to a day: several files, tests, some design",
|
|
"l": "several days: cross-cutting, migrations or new surfaces",
|
|
"xl": "a week or more: a project, not a task"}),
|
|
},
|
|
"ideas": {
|
|
"impact": ("If built, how much would this idea change the product for its users?",
|
|
{"low": "marginal polish or a niche convenience",
|
|
"medium": "a clear improvement many users would notice",
|
|
"high": "changes what the product can do or who it is for"}),
|
|
},
|
|
}
|
|
|
|
COLLECTION_Q = ("Which kind of work item is this?",
|
|
{"bug": "a defect: something that exists and behaves wrongly",
|
|
"task": "a unit of work to do, with a defined outcome, not primarily a defect report",
|
|
"idea": "a proposal or possibility to explore; not yet committed work"})
|
|
|
|
|
|
def load(coll):
|
|
with open(os.path.join(HERE, f"{coll}.json")) as f:
|
|
items = json.load(f)
|
|
out = []
|
|
for it in items:
|
|
fields = it.get("fields") or {}
|
|
if isinstance(fields, str):
|
|
try:
|
|
fields = json.loads(fields)
|
|
except Exception:
|
|
fields = {}
|
|
out.append({"ref": it["ref"], "title": it["title"], "content": (it.get("content") or "")[:MAX_BODY],
|
|
"fields": fields, "collection": coll})
|
|
return out
|
|
|
|
|
|
def call(key, model, state, questions, retries=5):
|
|
body = json.dumps({"model": model, "state": state, "questions": questions}).encode()
|
|
req = urllib.request.Request(API, data=body, method="POST", headers={
|
|
"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
|
|
delay = 1.0
|
|
for attempt in range(retries):
|
|
t0 = time.time()
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=60) as r:
|
|
data = json.load(r)
|
|
data["_latency_ms"] = int((time.time() - t0) * 1000)
|
|
return data
|
|
except urllib.error.HTTPError as e:
|
|
if e.code in (429, 529) and attempt < retries - 1:
|
|
time.sleep(delay); delay *= 2; continue
|
|
detail = e.read().decode(errors="replace")[:300]
|
|
raise RuntimeError(f"HTTP {e.code}: {detail}")
|
|
raise RuntimeError("retries exhausted")
|
|
|
|
|
|
def choice_q(instructions, criteria):
|
|
return {"type": "choice", "instructions": instructions, "criteria": criteria}
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--n-per-collection", type=int, default=120)
|
|
ap.add_argument("--model", default="jev-latest")
|
|
ap.add_argument("--workers", type=int, default=8)
|
|
ap.add_argument("--seed", type=int, default=73)
|
|
ap.add_argument("--dry-run", action="store_true")
|
|
a = ap.parse_args()
|
|
|
|
key = os.environ.get("TYPESAFE_API_KEY")
|
|
if not key and not a.dry_run:
|
|
print("TYPESAFE_API_KEY not in environment", file=sys.stderr); sys.exit(2)
|
|
|
|
random.seed(a.seed)
|
|
jobs = []
|
|
for coll, fq in FIELD_QUESTIONS.items():
|
|
items = load(coll)
|
|
labelled = [it for it in items if any(it["fields"].get(k) for k in fq)]
|
|
random.shuffle(labelled)
|
|
for it in labelled[: a.n_per_collection]:
|
|
questions = {k: choice_q(*fq[k]) for k in fq}
|
|
questions["collection"] = choice_q(*COLLECTION_Q)
|
|
state = {"title": it["title"], "body": it["content"]}
|
|
jobs.append((it, questions, state))
|
|
|
|
print(f"{len(jobs)} items queued ({', '.join(f'{c}:{sum(1 for j in jobs if j[0]['collection']==c)}' for c in FIELD_QUESTIONS)})")
|
|
if a.dry_run:
|
|
print(json.dumps({"model": a.model, "state": jobs[0][2], "questions": jobs[0][1]}, indent=1)[:1500]); return
|
|
|
|
results, errors, usage_in, lat = [], [], 0, []
|
|
out_path = os.path.join(HERE, f"results-{a.model}.jsonl")
|
|
with open(out_path, "w") as out, ThreadPoolExecutor(a.workers) as ex:
|
|
futs = {ex.submit(call, key, a.model, st, q): it for it, q, st in jobs}
|
|
for n, f in enumerate(as_completed(futs), 1):
|
|
it = futs[f]
|
|
try:
|
|
r = f.result()
|
|
except Exception as e:
|
|
errors.append((it["ref"], str(e))); continue
|
|
usage_in += r.get("usage", {}).get("input_tokens", 0); lat.append(r["_latency_ms"])
|
|
rec = {"ref": it["ref"], "collection": it["collection"], "human": it["fields"], "answers": r["answers"],
|
|
"latency_ms": r["_latency_ms"], "usage": r.get("usage")}
|
|
results.append(rec); out.write(json.dumps(rec) + "\n")
|
|
if n % 50 == 0: print(f" {n}/{len(jobs)}", file=sys.stderr)
|
|
|
|
# ---- summary ----
|
|
print(f"\nmodel={results[0]['answers'] and results and (r.get('model') or a.model)} items={len(results)} errors={len(errors)} "
|
|
f"input_tokens={usage_in} est_cost=${usage_in/1e6*0.042:.4f} "
|
|
f"latency p50={sorted(lat)[len(lat)//2]}ms p90={sorted(lat)[int(len(lat)*0.9)]}ms")
|
|
for e in errors[:5]: print(" error", e)
|
|
|
|
print("\nTask B — collection classification (human collection is ground truth)")
|
|
cm = Counter(); conf_ok, conf_bad = [], []
|
|
for rec in results:
|
|
truth = {"bugs": "bug", "tasks": "task", "ideas": "idea"}[rec["collection"]]
|
|
ans = rec["answers"]["collection"]; cm[(truth, ans["choice"])] += 1
|
|
(conf_ok if ans["choice"] == truth else conf_bad).append(ans["confidence"])
|
|
total = sum(cm.values()); correct = sum(v for (t, p), v in cm.items() if t == p)
|
|
print(f" accuracy {correct}/{total} = {correct/total:.1%} mean conf when right {sum(conf_ok)/max(1,len(conf_ok)):.2f}, when wrong {sum(conf_bad)/max(1,len(conf_bad)):.2f}")
|
|
for t in ("bug", "task", "idea"):
|
|
row = {p: cm[(t, p)] for p in ("bug", "task", "idea")}
|
|
print(f" truth={t:5s} -> {row}")
|
|
|
|
print("\nTask A — field recovery (only items where a human set the field)")
|
|
for coll, fq in FIELD_QUESTIONS.items():
|
|
for k in fq:
|
|
rows = [(rec["human"].get(k), rec["answers"][k]) for rec in results if rec["collection"] == coll and rec["human"].get(k)]
|
|
if not rows: continue
|
|
exact = sum(1 for h, ans in rows if ans["choice"] == h)
|
|
# adjacent = off by one level on ordered scales
|
|
order = list(fq[k][1].keys())
|
|
adj = sum(1 for h, ans in rows if h in order and ans["choice"] in order and abs(order.index(h) - order.index(ans["choice"])) <= 1)
|
|
hi = [(h, ans) for h, ans in rows if ans["confidence"] >= 0.5]
|
|
hi_exact = sum(1 for h, ans in hi if ans["choice"] == h)
|
|
vhi = [(h, ans) for h, ans in rows if ans["confidence"] >= 0.8]
|
|
vhi_exact = sum(1 for h, ans in vhi if ans["choice"] == h)
|
|
base = Counter(h for h, _ in rows).most_common(1)[0]
|
|
print(f" {coll}.{k:10s} n={len(rows):3d} exact={exact/len(rows):.1%} adjacent={adj/len(rows):.1%} | "
|
|
f"conf>=0.5: {len(hi)} items, exact={hi_exact/max(1,len(hi)):.1%} | conf>=0.8: {len(vhi)} items, exact={vhi_exact/max(1,len(vhi)):.1%} | "
|
|
f"majority-baseline={base[1]/len(rows):.1%} ({base[0]})")
|
|
conf = Counter()
|
|
for h, ans in rows: conf[(h, ans["choice"])] += 1
|
|
top = sorted(((h, p, v) for (h, p), v in conf.items() if h != p), key=lambda x: -x[2])[:3]
|
|
if top: print(f" top confusions (human->jev): " + ", ".join(f"{h}->{p}:{v}" for h, p, v in top))
|
|
print(f"\nper-item results: {out_path}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|