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.
163 lines
4.6 KiB
Python
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())
|