sol: support-inbox triage classifier, gold set, machine-checkable verdict
This commit is contained in:
1 parent
f320dd342b
commit
fd47223eac
7 files changed
+476
No files matched your search
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Machine-checkable verdict for the sol support-triage artifact.
|
||||||
|
|
||||||
|
Exits 0 only if every check below passes. Each check is executed against the
|
||||||
|
real artifact: the classifier is run on the gold set, the self-check suite is
|
||||||
|
run to completion, and the generated report is parsed and compared. This is a
|
||||||
|
test of the artifact, not a restatement of it.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import py_compile
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
ARTIFACT = os.path.join(HERE, "sol-support-triage-2026-11-05")
|
||||||
|
FAILURES = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond, detail=""):
|
||||||
|
status = "ok" if cond else "FAIL"
|
||||||
|
print("[%s] %s %s" % (status, name, detail))
|
||||||
|
if not cond:
|
||||||
|
FAILURES.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
def find_repo_copy(start):
|
||||||
|
"""Climb from *start* and return the first ancestor that holds the
|
||||||
|
troupe repo copy of the soruce triage work, or None if not found."""
|
||||||
|
path = os.path.abspath(start)
|
||||||
|
for _ in range(20):
|
||||||
|
cand = os.path.join(path, "troupe-standup", "wizard-work", "sol")
|
||||||
|
if os.path.isdir(cand):
|
||||||
|
return cand
|
||||||
|
parent = os.path.dirname(path)
|
||||||
|
if parent == path:
|
||||||
|
break
|
||||||
|
path = parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# 1. Artifact files exist under the wizard-outputs deliverable dir.
|
||||||
|
for fname in ["classifier.py", "gold.json", "run_audit.py",
|
||||||
|
"test_support_triage.py", "support-triage-report.json",
|
||||||
|
"summary.md", "CHECK-support-triage-2026-11-05.py"]:
|
||||||
|
p = os.path.join(ARTIFACT, fname)
|
||||||
|
check("artifact." + fname, os.path.isfile(p), p)
|
||||||
|
|
||||||
|
# 2. Classifier compiles cleanly.
|
||||||
|
try:
|
||||||
|
py_compile.compile(os.path.join(ARTIFACT, "classifier.py"), doraise=True)
|
||||||
|
check("classifier.compiles", True)
|
||||||
|
except py_compile.PyCompileError as exc:
|
||||||
|
check("classifier.compiles", False, str(exc))
|
||||||
|
|
||||||
|
# 3. Self-check suite exits 0 (measured behaviour on the gold set).
|
||||||
|
runner = os.path.join(ARTIFACT, "test_support_triage.py")
|
||||||
|
proc = subprocess.run([sys.executable, runner], capture_output=True, text=True)
|
||||||
|
check("self-check.exit0", proc.returncode == 0, proc.stdout.strip())
|
||||||
|
if proc.returncode != 0:
|
||||||
|
print(proc.stderr)
|
||||||
|
|
||||||
|
# 4. Report is valid JSON, complete, and every gold item is correct.
|
||||||
|
rep_path = os.path.join(ARTIFACT, "support-triage-report.json")
|
||||||
|
try:
|
||||||
|
with open(rep_path) as fh:
|
||||||
|
rep = json.load(fh)
|
||||||
|
check("report.valid_json", True)
|
||||||
|
except Exception as exc:
|
||||||
|
check("report.valid_json", False, str(exc))
|
||||||
|
rep = {}
|
||||||
|
|
||||||
|
check("report.total==20", rep.get("total") == 20, "total=%r" % rep.get("total"))
|
||||||
|
items = rep.get("items", [])
|
||||||
|
check("report.items==20", len(items) == 20, "count=%d" % len(items))
|
||||||
|
all_correct = bool(items) and all(it.get("correct") for it in items)
|
||||||
|
check("report.all_correct", all_correct)
|
||||||
|
|
||||||
|
# 5. Repo copy of the artifact is present under the troupe repo.
|
||||||
|
repo_copy = find_repo_copy(HERE)
|
||||||
|
check("repo.dir_present", repo_copy is not None, str(repo_copy))
|
||||||
|
|
||||||
|
print("----")
|
||||||
|
if FAILURES:
|
||||||
|
print("VERDICT: FAIL (%d check(s) failed)" % len(FAILURES))
|
||||||
|
return 1
|
||||||
|
print("VERDICT: PASS")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Rule-based support-inquiry router (deterministic, zero external deps).
|
||||||
|
|
||||||
|
Each category carries a list of case-insensitive substring keywords (single
|
||||||
|
words and phrases). A query is scored by counting matches per category.
|
||||||
|
Ties and zero matches both resolve to a single category by the fixed priority
|
||||||
|
order, so routing is a pure function with no randomness.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
python3 classifier.py < input.txt # one inquiry per line
|
||||||
|
"""
|
||||||
|
|
||||||
|
CATEGORY_PRIORITY = [
|
||||||
|
"account",
|
||||||
|
"billing",
|
||||||
|
"technical_support",
|
||||||
|
"feature_request",
|
||||||
|
"other",
|
||||||
|
]
|
||||||
|
|
||||||
|
CATEGORIES = {
|
||||||
|
"account": {
|
||||||
|
"keywords": [
|
||||||
|
"password", "forgot", "login", "sign in", "sign-in",
|
||||||
|
"session", "authenticate", "two-factor", "2fa", "locked",
|
||||||
|
"unlock", "reset", "account access",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"billing": {
|
||||||
|
"keywords": [
|
||||||
|
"invoice", "charged", "charging", "payment", "billing",
|
||||||
|
"refund", "subscription", "bill", "credit card", "charge",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"technical_support": {
|
||||||
|
"keywords": [
|
||||||
|
"error", "crash", "bug", "broken", "not working",
|
||||||
|
"does not work", "cannot", "can not", "unable", "problem",
|
||||||
|
"issue", "install", "setup", "configuration", "config",
|
||||||
|
"upgrade", "unresponsive", "connect", "timeout", "failed",
|
||||||
|
"fail", "update", "restart", "server",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"feature_request": {
|
||||||
|
"keywords": [
|
||||||
|
"feature", "requested", "enhancement", "suggest", "propose",
|
||||||
|
"add", "please add", "would like", "would like to",
|
||||||
|
"api", "integration", "onboarding", "implement",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"keywords": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _score(text, keywords):
|
||||||
|
low = text.lower()
|
||||||
|
return sum(1 for kw in keywords if kw in low)
|
||||||
|
|
||||||
|
|
||||||
|
def classify(text):
|
||||||
|
"""Return the single assigned category for *text* (deterministic)."""
|
||||||
|
if not isinstance(text, str):
|
||||||
|
text = str(text)
|
||||||
|
hits = {}
|
||||||
|
for cat, meta in CATEGORIES.items():
|
||||||
|
if cat == "other":
|
||||||
|
continue
|
||||||
|
hits[cat] = _score(text, meta["keywords"])
|
||||||
|
if not hits:
|
||||||
|
return "other"
|
||||||
|
best = max(hits, key=hits.get)
|
||||||
|
if hits[best] == 0:
|
||||||
|
return "other"
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
for line in sys.stdin:
|
||||||
|
line = line.rstrip("\n")
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
print("%s\t%s" % (classify(line), line))
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[
|
||||||
|
{"id": 1, "inquiry": "My password was rejected and I cannot sign in to my account this morning.", "category": "account"},
|
||||||
|
{"id": 2, "inquiry": "The invoice for this month is wrong, please refund the charge.", "category": "billing"},
|
||||||
|
{"id": 3, "inquiry": "When I start the app it crashes immediately with an error.", "category": "technical_support"},
|
||||||
|
{"id": 4, "inquiry": "It would be nice if you added a dark mode.", "category": "feature_request"},
|
||||||
|
{"id": 5, "inquiry": "Please confirm the payment went through for the subscription.", "category": "billing"},
|
||||||
|
{"id": 6, "inquiry": "My login session keeps expiring whenever I leave the tab.", "category": "account"},
|
||||||
|
{"id": 7, "inquiry": "The settings page shows a configuration error when saving.", "category": "technical_support"},
|
||||||
|
{"id": 8, "inquiry": "I want a button to reset my two-factor authentication code.", "category": "account"},
|
||||||
|
{"id": 9, "inquiry": "Can you add a dark mode to the mobile app?", "category": "feature_request"},
|
||||||
|
{"id": 10, "inquiry": "My credit card was charged twice for the same invoice.", "category": "billing"},
|
||||||
|
{"id": 11, "inquiry": "The app is unresponsive after I updated to the latest version.", "category": "technical_support"},
|
||||||
|
{"id": 12, "inquiry": "Please implement the export to CSV feature you suggested.", "category": "feature_request"},
|
||||||
|
{"id": 13, "inquiry": "I cannot authenticate with the new two-factor setup.", "category": "account"},
|
||||||
|
{"id": 14, "inquiry": "Your billing system charged me twice for the same subscription.", "category": "billing"},
|
||||||
|
{"id": 15, "inquiry": "I would like an API endpoint for integrations.", "category": "feature_request"},
|
||||||
|
{"id": 16, "inquiry": "The password reset link expired before I could use it.", "category": "account"},
|
||||||
|
{"id": 17, "inquiry": "Please add a dark mode preference to the settings.", "category": "feature_request"},
|
||||||
|
{"id": 18, "inquiry": "I am unable to connect to the server and get a timeout.", "category": "technical_support"},
|
||||||
|
{"id": 19, "inquiry": "My subscription renewal invoice is still pending.", "category": "billing"},
|
||||||
|
{"id": 20, "inquiry": "I have a suggestion to improve the onboarding flow.", "category": "feature_request"}
|
||||||
|
]
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate the support-triage report from the gold set.
|
||||||
|
|
||||||
|
Reads gold.json, classifies every inquiry with classifier.classify(), and
|
||||||
|
writes support-triage-report.json. Exits 0 only when every gold item is
|
||||||
|
classified exactly as labelled.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, HERE)
|
||||||
|
|
||||||
|
from classifier import classify # noqa: E402
|
||||||
|
|
||||||
|
GOLD = os.path.join(HERE, "gold.json")
|
||||||
|
REPORT = os.path.join(HERE, "support-triage-report.json")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with open(GOLD) as f:
|
||||||
|
items = json.load(f)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
correct = 0
|
||||||
|
for item in items:
|
||||||
|
predicted = classify(item["inquiry"])
|
||||||
|
ok = predicted == item["category"]
|
||||||
|
if ok:
|
||||||
|
correct += 1
|
||||||
|
results.append({
|
||||||
|
"id": item["id"],
|
||||||
|
"inquiry": item["inquiry"],
|
||||||
|
"expect": item["category"],
|
||||||
|
"got": predicted,
|
||||||
|
"correct": ok,
|
||||||
|
})
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"generated_at": "2026-11-05T00:00:00+00:00",
|
||||||
|
"total": len(items),
|
||||||
|
"correct": correct,
|
||||||
|
"accuracy": round(correct / len(items), 4) if items else 0.0,
|
||||||
|
"items": results,
|
||||||
|
}
|
||||||
|
with open(REPORT, "w") as f:
|
||||||
|
json.dump(report, f, indent=2)
|
||||||
|
|
||||||
|
print("n=%d correct=%d accuracy=%.4f" % (len(items), correct, report["accuracy"]))
|
||||||
|
return 0 if correct == len(items) else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# sol-support-triage-2026-11-05
|
||||||
|
|
||||||
|
Triage-lane deliverable: a deterministic, dependency-free support-inbox router
|
||||||
|
plus a hand-labelled gold set and a verification suite.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `classifier.py` -- rule engine. Each category carries case-insensitive
|
||||||
|
substring keywords (single words + multi-word phrases). `classify(text)`
|
||||||
|
returns exactly one category by a fixed priority order.
|
||||||
|
- `gold.json` -- 20 hand-labelled inquiries covering account, billing,
|
||||||
|
technical_support and feature_request.
|
||||||
|
- `run_audit.py` -- classifies the gold set and writes
|
||||||
|
`support-triage-report.json`.
|
||||||
|
- `test_support_triage.py` -- self-check; exits 0 on 20/20.
|
||||||
|
- `support-triage-report.json` -- generated report (20 items, 20 correct).
|
||||||
|
- `summary.md` -- this file.
|
||||||
|
|
||||||
|
## How it is measured (not claimed)
|
||||||
|
|
||||||
|
- `python3 test_support_triage.py` -> `ALL SUPPORT TRIAGE CHECKS PASSED: 20/20`
|
||||||
|
with exit code 0.
|
||||||
|
- `python3 run_audit.py` -> `n=20 correct=20 accuracy=1.0`.
|
||||||
|
- `CHECK-support-triage-2026-11-05.py` collects the three above and exits 0
|
||||||
|
only if all hold.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
The classifier is a keyword router tuned to this corpus; it is a triage
|
||||||
|
classifier, not a claim of unconstrained real-world accuracy. The verification
|
||||||
|
suite, not the metric, is the finished deliverable.
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
{
|
||||||
|
"generated_at": "2026-11-05T00:00:00+00:00",
|
||||||
|
"total": 20,
|
||||||
|
"correct": 20,
|
||||||
|
"accuracy": 1.0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"inquiry": "My password was rejected and I cannot sign in to my account this morning.",
|
||||||
|
"expect": "account",
|
||||||
|
"got": "account",
|
||||||
|
"correct": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"inquiry": "The invoice for this month is wrong, please refund the charge.",
|
||||||
|
"expect": "billing",
|
||||||
|
"got": "billing",
|
||||||
|
"correct": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"inquiry": "When I start the app it crashes immediately with an error.",
|
||||||
|
"expect": "technical_support",
|
||||||
|
"got": "technical_support",
|
||||||
|
"correct": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"inquiry": "It would be nice if you added a dark mode.",
|
||||||
|
"expect": "feature_request",
|
||||||
|
"got": "feature_request",
|
||||||
|
"correct": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"inquiry": "Please confirm the payment went through for the subscription.",
|
||||||
|
"expect": "billing",
|
||||||
|
"got": "billing",
|
||||||
|
"correct": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"inquiry": "My login session keeps expiring whenever I leave the tab.",
|
||||||
|
"expect": "account",
|
||||||
|
"got": "account",
|
||||||
|
"correct": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"inquiry": "The settings page shows a configuration error when saving.",
|
||||||
|
"expect": "technical_support",
|
||||||
|
"got": "technical_support",
|
||||||
|
"correct": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"inquiry": "I want a button to reset my two-factor authentication code.",
|
||||||
|
"expect": "account",
|
||||||
|
"got": "account",
|
||||||
|
"correct": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"inquiry": "Can you add a dark mode to the mobile app?",
|
||||||
|
"expect": "feature_request",
|
||||||
|
"got": "feature_request",
|
||||||
|
"correct": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"inquiry": "My credit card was charged twice for the same invoice.",
|
||||||
|
"expect": "billing",
|
||||||
|
"got": "billing",
|
||||||
|
"correct": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"inquiry": "The app is unresponsive after I updated to the latest version.",
|
||||||
|
"expect": "technical_support",
|
||||||
|
"got": "technical_support",
|
||||||
|
"correct": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"inquiry": "Please implement the export to CSV feature you suggested.",
|
||||||
|
"expect": "feature_request",
|
||||||
|
"got": "feature_request",
|
||||||
|
"correct": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"inquiry": "I cannot authenticate with the new two-factor setup.",
|
||||||
|
"expect": "account",
|
||||||
|
"got": "account",
|
||||||
|
"correct": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"inquiry": "Your billing system charged me twice for the same subscription.",
|
||||||
|
"expect": "billing",
|
||||||
|
"got": "billing",
|
||||||
|
"correct": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"inquiry": "I would like an API endpoint for integrations.",
|
||||||
|
"expect": "feature_request",
|
||||||
|
"got": "feature_request",
|
||||||
|
"correct": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"inquiry": "The password reset link expired before I could use it.",
|
||||||
|
"expect": "account",
|
||||||
|
"got": "account",
|
||||||
|
"correct": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"inquiry": "Please add a dark mode preference to the settings.",
|
||||||
|
"expect": "feature_request",
|
||||||
|
"got": "feature_request",
|
||||||
|
"correct": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"inquiry": "I am unable to connect to the server and get a timeout.",
|
||||||
|
"expect": "technical_support",
|
||||||
|
"got": "technical_support",
|
||||||
|
"correct": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 19,
|
||||||
|
"inquiry": "My subscription renewal invoice is still pending.",
|
||||||
|
"expect": "billing",
|
||||||
|
"got": "billing",
|
||||||
|
"correct": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"inquiry": "I have a suggestion to improve the onboarding flow.",
|
||||||
|
"expect": "feature_request",
|
||||||
|
"got": "feature_request",
|
||||||
|
"correct": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Self-check for the support-inbox triage classifier.
|
||||||
|
|
||||||
|
Runs the classifier over the full gold set and pins the fallback path to
|
||||||
|
"other". Exits 0 only when every gold item maps to its labelled category.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, HERE)
|
||||||
|
|
||||||
|
from classifier import classify # noqa: E402
|
||||||
|
|
||||||
|
with open(os.path.join(HERE, "gold.json")) as f:
|
||||||
|
items = json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
failures = []
|
||||||
|
for item in items:
|
||||||
|
got = classify(item["inquiry"])
|
||||||
|
if got != item["category"]:
|
||||||
|
failures.append((item["id"], item["category"], got))
|
||||||
|
|
||||||
|
# Fallback to "other" for out-of-scope input (measured, not assumed).
|
||||||
|
assert classify("Hello, how are you today?") == "other"
|
||||||
|
assert classify("") == "other"
|
||||||
|
assert classify(None) == "other"
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
for fid, exp, got in failures:
|
||||||
|
print("MISMATCH id=%s expected=%s got=%s" % (fid, exp, got))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("ALL SUPPORT TRIAGE CHECKS PASSED: %d/%d correct" % (len(items), len(items)))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in new issue
Block a user