call-qa-processing / ml-services /evaluation /run_graph_batch.py
aniketqxp's picture
feat: add processing service runtime
f1ef7e2
Raw
History Blame Contribute Delete
5.62 kB
"""
Batch-run the graph evaluation over every call that has audio sentiment data.
Purpose (v0.4.0 validation):
1. Ground the fusion constants — collect text-vs-acoustic score pairs and
escalation trajectories across calls so thresholds can be fitted to the
observed distributions instead of asserted.
2. Probe the workflow track for leakage/leniency — if expected-step pass
rates are ~100% on every call including badly-handled ones, the checklist
generation or the audit is compromised.
Usage: python run_graph_batch.py [--results_dir results] [--only banking]
Output: results/{call_id}_graph.json per call + results/batch_summary.json
"""
import os
import json
import time
import argparse
import traceback
import paths
from graph import build_graph
from rubric import RUBRIC_VERSION_GRAPH
SENTIMENT_ROOT = str(paths.SENTIMENT_ROOT)
def calls_with_sentiment(only=None):
out = []
for domain in sorted(os.listdir(SENTIMENT_ROOT)):
if only and domain != only:
continue
d = os.path.join(SENTIMENT_ROOT, domain)
for f in sorted(os.listdir(d)):
if f.endswith(".json") and not f.endswith("_segments.json"):
out.append(f[:-5])
return out
def summarize(ev):
"""Extract the batch-level facts we want to analyze."""
q = ev.get("quality", {})
dims = {}
for name, d in q.items():
if isinstance(d, dict) and "score" in d:
h = d.get("hybrid") or {}
dims[name] = {"fused": d["score"],
"text": h.get("text_score", d["score"]),
"acoustic": h.get("acoustic_score"),
"coverage": h.get("coverage")}
e = ev.get("escalation", {})
eh = e.get("hybrid") or {}
wf = ev.get("workflow") or {}
steps = wf.get("expected_steps", [])
return {
"quality": dims,
"risk": e.get("risk_level"),
"risk_text": eh.get("text_risk"),
"risk_acoustic": eh.get("acoustic_risk"),
"esc_late_mean": eh.get("late_mean_escalation"),
"esc_peak": eh.get("peak_escalation"),
"esc_method": eh.get("method"),
"emotion": e.get("customer_emotion_text"),
"red_flags": e.get("red_flags", []),
"wf_subject": wf.get("subject"),
"wf_total": len(steps),
"wf_met": sum(1 for s in steps if s.get("met") is True),
"wf_missed": sum(1 for s in steps if s.get("met") is False),
"anchor": ev.get("_anchor_stats"),
"investigated": ev.get("investigation") is not None,
"wall_clock": (ev.get("_pipeline") or {}).get("wall_clock"),
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--results_dir", default="results",
help="transcription dir under na_testset (default: results "
"— the flattened backend format covers all 22 calls)")
ap.add_argument("--only", default=None, help="restrict to one domain")
ap.add_argument("--skip_existing", action="store_true")
args = ap.parse_args()
calls = calls_with_sentiment(args.only)
here = os.path.dirname(os.path.abspath(__file__))
out_dir = os.path.join(here, "results")
os.makedirs(out_dir, exist_ok=True)
graph = build_graph()
summary, failures = {}, {}
t_start = time.time()
print(f"Batch: {len(calls)} calls, rubric {RUBRIC_VERSION_GRAPH}, "
f"results_dir={args.results_dir}\n" + "=" * 68)
for i, call_id in enumerate(calls, 1):
out_path = os.path.join(out_dir, f"{call_id}_graph.json")
if args.skip_existing and os.path.exists(out_path):
with open(out_path, encoding="utf-8") as f:
prev = json.load(f)
if prev.get("rubric_version") == RUBRIC_VERSION_GRAPH:
summary[call_id] = summarize(prev)
print(f"[{i:2}/{len(calls)}] {call_id} — cached")
continue
print(f"[{i:2}/{len(calls)}] {call_id}")
t0 = time.time()
try:
final = graph.invoke({"call_id": call_id,
"results_dir": args.results_dir})
ev = final["evaluation"]
ev["_pipeline"] = {
"nodes": final.get("node_meta", {}),
"anchor_passes": final.get("anchor_attempts", 1),
"wall_clock": round(time.time() - t0, 1),
}
with open(out_path, "w", encoding="utf-8") as f:
json.dump(ev, f, indent=2)
summary[call_id] = summarize(ev)
s = summary[call_id]
print(f" ok in {s['wall_clock']}s | risk={s['risk']} "
f"(text={s['risk_text']}, ac={s['risk_acoustic']}) | "
f"wf {s['wf_met']}/{s['wf_total']} met, {s['wf_missed']} missed")
except Exception as e:
failures[call_id] = f"{type(e).__name__}: {e}"
print(f" FAILED: {failures[call_id]}")
traceback.print_exc(limit=2)
with open(os.path.join(out_dir, "batch_summary.json"), "w",
encoding="utf-8") as f:
json.dump({"rubric_version": RUBRIC_VERSION_GRAPH,
"results_dir": args.results_dir,
"wall_clock_total": round(time.time() - t_start, 1),
"calls": summary, "failures": failures}, f, indent=2)
print("=" * 68)
print(f"Done: {len(summary)} ok, {len(failures)} failed, "
f"{round(time.time() - t_start, 1)}s total")
print(f"Summary -> {os.path.join(out_dir, 'batch_summary.json')}")
if __name__ == "__main__":
main()