Files
troupe-standup/wizard-work/sol/classify.py
T
volt 4740166e9d sol: land Q119's repaired classifier, verifier and cells (md5s as its record pins them)
The Q119 DONE record shipped at 2615c32 without its artifacts: classify.py and
verify.sh were modified and test_classify_fallback.py untracked in the worktree,
so every path the record names read MISSING at the tree about to be committed
(path-claims leg: 2 new rows). The three files land here byte-for-byte as the
record pins them -- classify.py md5 89a5b9d3476265069a0bf2694af9510e (162 lines),
verify.sh md5 4747faef183017c79620d3daf5d918c3 (105 lines),
test_classify_fallback.py md5 4bc2b53d4bcde155616d4741c708bc4a (159 lines) --
and bash wizard-work/sol/verify.sh reads CHECK OK, Ran 10 tests OK, rc 0 in this
tree. Not claimed: that anything in Q119's grading changed; this is its landing.
2026-10-29 18:19:03 +00:00

163 lines
4.6 KiB
Python

#!/usr/bin/env python3
"""Triage classifier for the sol lane.
Classifies a seed of inbox pending items into (category, decision) pairs and
emits a JSON artifact with per-item decisions plus aggregate counts.
Categories: archive_fix | app_pr | general
Fallback: unclassified -- an item that matches no category rule is reported
under this label, never folded into a real category. The label is
counted in `categories` beside the three real ones, so the totals
still sum to the number of items (Q119: a silent default is not a
classification).
Decisions: approve | table | reject
Rules are keyword/phrase regexes, scored per category; highest score wins and
ties break by fixed priority. Decision is then chosen by a separate keyword
pass over the evidence text. Stdlib only, no third-party deps.
"""
import json
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
DEFAULT_SEED = HERE / "seed-2026-10-29.json"
DEFAULT_ART = HERE / "Triage-2026-10-29.json"
CATS = ["archive_fix", "app_pr", "general"]
UNCLASSIFIED = "unclassified"
COUNTED = CATS + [UNCLASSIFIED] # categories emitted in the aggregate
PRIORITY = {"archive_fix": 3, "app_pr": 2, "general": 1}
CATEGORY_RULES = {
"archive_fix": [
r"never shipped",
r"still renders",
r"lacks",
r"\black\b",
r"omit",
r"misattribute",
r"restats",
r"drops",
r"wrong",
r"fix",
],
"app_pr": [
r"publish",
r"release notes",
r"pull request",
r"endpoint",
r"submit",
r"healthz",
r"readme",
],
"general": [
r"standup",
r"digest",
r"handover",
r"under checksum",
r"independent QC",
],
}
DECISION_RULES = {
"table": [
r"standup",
r"digest",
r"hand over",
r"under checksum",
r"QC",
],
}
def _score(text, rules):
low = text.lower()
scores, matched = {}, {}
for cat, patterns in rules.items():
s = 0
ms = []
for p in patterns:
m = re.search(p, low)
if m:
s += 1
ms.append(m.group(0))
scores[cat] = s
matched[cat] = ms
return scores, matched
def classify(text):
"""Return (category, score, reason) from keyword rules."""
scores, matched = _score(text, CATEGORY_RULES)
best = max(scores.values())
if best == 0:
return (
UNCLASSIFIED,
0,
"no category rule matched; unclassified (no default category)",
)
winners = [c for c in CATS if scores[c] == best]
cat = max(winners, key=lambda c: PRIORITY[c])
parts = [f"{c}:{scores[c]}" for c in winners]
reason = "matched " + ", ".join(sorted(set(x for c in winners for x in matched[c])))
return cat, best, reason
def decide(text):
"""Return (decision, reason)."""
low = text.lower()
for cname, patterns in DECISION_RULES.items():
for p in patterns:
if re.search(p, low):
return "table", f"decision rule '{p}' matched"
return "approve", "no table keyword matched; approved"
def main(argv=None):
import argparse
ap = argparse.ArgumentParser(description="sol-lane triage classifier")
ap.add_argument("--seed", default=str(DEFAULT_SEED))
ap.add_argument("--output", default=str(DEFAULT_ART))
args = ap.parse_args()
seed = json.loads(Path(args.seed).read_text(encoding="utf-8"))
pending = seed.get("pending", [])
if not pending:
print("FAIL: seed has no pending items", file=sys.stderr)
return 1
items = []
for d in pending:
text = f"{d.get('evidence', '')} {d.get('proposed_action', '')}".lower()
cat, score, reason = classify(text)
dec, dec_reason = decide(text)
items.append({
"id": d["id"],
"category": cat,
"decision": dec,
"reason": f"{reason}; {dec_reason}",
})
cats = {c: 0 for c in COUNTED}
dec_counts = {"approve": 0, "table": 0, "reject": 0}
for it in items:
cats[it["category"]] += 1
dec_counts[it["decision"]] += 1
art = {
"seed": Path(args.seed).name,
"date": "2026-10-29",
"categories": cats,
"decision_counts": dec_counts,
"items": items,
}
Path(args.output).write_text(json.dumps(art, indent=2) + "\n", encoding="utf-8")
print(f"PASS: classified {len(items)} items -> {json.dumps(dec_counts)} / {json.dumps(cats)}")
return 0
if __name__ == "__main__":
sys.exit(main())