Spaces:
Running on Zero
Running on Zero
| """ | |
| Export every evaluated call into the frontend's per-call format + a merged | |
| playable audio file, plus a summary index for the dashboard. | |
| For each call_id with a results/{call_id}_graph.json: | |
| - turns rebuilt from the per-channel word-level transcript, using the | |
| same word-grouping logic as gen_call_data.py::build_turns | |
| (assemble.py's assemble_turns() drops the per-turn `end` field | |
| the frontend needs, so it isn't reused here) | |
| - chapters from segment.segment() (one LLM call per call -- chapters were | |
| never persisted anywhere), converted from its start_turn/mm:ss | |
| shape into the numeric start/end-seconds shape the frontend | |
| player expects | |
| - evaluation the existing results/{call_id}_graph.json content, verbatim | |
| - audio agent+customer wav merged into one 2-channel mp3 via ffmpeg | |
| (ch0=agent, ch1=customer -- same convention as the original | |
| call_1 demo audio) | |
| Writes (served statically, fetched at runtime -- not bundled into the JS build): | |
| frontend/public/calls/{call_id}.json {call, model, duration, turns, chapters, evaluation} | |
| frontend/public/calls_index.json [{call_id, domain, ...summary}, ...] | |
| frontend/public/audio/{call_id}.mp3 | |
| The demo call (en_CA_Banking_1586889) is copied verbatim from the existing | |
| call_data.json instead of being regenerated -- it already carries the | |
| recheck-node bugfix's hand-verified evidence patch. | |
| Usage: python export_frontend.py | |
| """ | |
| import os | |
| import json | |
| import shutil | |
| import subprocess | |
| import paths | |
| from assemble import load_manifest, DATA | |
| from segment import segment | |
| EVAL_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| RESULTS_DIR_NAME = "results" | |
| PUBLIC = str(paths.FRONTEND_PUBLIC) | |
| CALLS_OUT = os.path.join(PUBLIC, "calls") | |
| AUDIO_OUT = os.path.join(PUBLIC, "audio") | |
| INDEX_OUT = os.path.join(PUBLIC, "calls_index.json") | |
| DEMO_CALL = "en_CA_Banking_1586889" | |
| DEMO_CALL_DATA = str(paths.FRONTEND_ROOT / "src" / "call_data.json") | |
| DEMO_AUDIO_SRC = os.path.join(PUBLIC, "call_1.mp3") | |
| def build_turns(result): | |
| """Merge agent+customer words into time-ordered speaker turns.""" | |
| merged = [] | |
| for speaker, key in (("AGENT", "agent"), ("CUSTOMER", "customer")): | |
| for w in result.get(key, []): | |
| if w.get("start") is None: | |
| continue | |
| merged.append((w["start"], w.get("end", w["start"]), speaker, w["word"])) | |
| merged.sort(key=lambda x: x[0]) | |
| turns = [] | |
| cur = None | |
| for start, end, sp, word in merged: | |
| if cur is None or sp != cur["speaker"]: | |
| if cur: | |
| turns.append(cur) | |
| cur = {"speaker": sp, "text": word, "start": start, "end": end} | |
| else: | |
| cur["text"] += " " + word | |
| cur["end"] = end | |
| if cur: | |
| turns.append(cur) | |
| for i, t in enumerate(turns): | |
| t["i"] = i | |
| t["start"] = round(t["start"], 2) | |
| t["end"] = round(t["end"], 2) | |
| return turns | |
| def numeric_chapters(cc_chapters, turns, duration): | |
| """Convert segment.py's start_turn/mm:ss chapters into numeric seconds.""" | |
| chapters = [] | |
| for i, ch in enumerate(cc_chapters): | |
| start = turns[ch.start_turn]["start"] | |
| end = (turns[cc_chapters[i + 1].start_turn]["start"] | |
| if i + 1 < len(cc_chapters) else duration) | |
| chapters.append({"index": ch.index, "label": ch.label, | |
| "summary": ch.summary, "start": round(start, 2), | |
| "end": round(end, 2), "start_turn": ch.start_turn}) | |
| return chapters | |
| def merge_audio(agent_wav, customer_wav, out_mp3): | |
| os.makedirs(os.path.dirname(out_mp3), exist_ok=True) | |
| proc = subprocess.run([ | |
| "ffmpeg", "-y", "-i", agent_wav, "-i", customer_wav, | |
| "-filter_complex", "[0:a][1:a]amerge=inputs=2[a]", | |
| "-map", "[a]", "-ac", "2", "-b:a", "128k", out_mp3, | |
| ], capture_output=True, text=True) | |
| if proc.returncode != 0: | |
| print(f" ffmpeg FAILED for {out_mp3}:\n{proc.stderr[-800:]}") | |
| return False | |
| return True | |
| def export_call_artifacts(call_id, meta, out_calls_dir, out_audio_dir): | |
| """Build one call's frontend artifacts into the given output dirs. | |
| Writes {call_id}.json (turns + chapters + evaluation) and the merged | |
| 2-channel mp3. Reads the graph evaluation and the per-channel transcript | |
| already on disk; runs one chapter LLM call. Channel WAVs are expected at | |
| the dataset convention DATA/{accent}/{call_id}_{agent,customer}.wav (the | |
| orchestrator places fresh-call channels there before calling this). | |
| Returns the call dict, which summarize() turns into a dashboard index row. | |
| """ | |
| eval_path = os.path.join(EVAL_DIR, "results", f"{call_id}_graph.json") | |
| with open(eval_path, encoding="utf-8") as f: | |
| evaluation = json.load(f) | |
| duration = evaluation["metadata"]["duration_seconds"] | |
| result_path = os.path.join(DATA, RESULTS_DIR_NAME, meta["accent"], call_id + ".json") | |
| with open(result_path, encoding="utf-8") as f: | |
| result = json.load(f) | |
| turns = build_turns(result) | |
| cc, dt, issues = segment(call_id, provider="router", results_dir=RESULTS_DIR_NAME) | |
| if issues: | |
| print(f" chapter issues: {issues}") | |
| chapters = numeric_chapters(cc.chapters, turns, duration) | |
| out = { | |
| "call": call_id, | |
| "model": result.get("model", ""), | |
| "duration": round(duration, 2), | |
| "turns": turns, | |
| "chapters": chapters, | |
| "evaluation": evaluation, | |
| } | |
| os.makedirs(out_calls_dir, exist_ok=True) | |
| with open(os.path.join(out_calls_dir, f"{call_id}.json"), "w", encoding="utf-8") as f: | |
| json.dump(out, f, indent=2) | |
| ok = merge_audio( | |
| os.path.join(DATA, meta["accent"], f"{call_id}_agent.wav"), | |
| os.path.join(DATA, meta["accent"], f"{call_id}_customer.wav"), | |
| os.path.join(out_audio_dir, f"{call_id}.mp3"), | |
| ) | |
| print(f" [{call_id}] {len(turns)} turns, {len(chapters)} chapters, " | |
| f"audio {'ok' if ok else 'FAILED'}") | |
| return out | |
| def export_call(call_id, manifest): | |
| return export_call_artifacts(call_id, manifest[call_id], CALLS_OUT, AUDIO_OUT) | |
| def summarize(call_id, out): | |
| ev = out["evaluation"] | |
| c = ev.get("compliance", {}) | |
| passed = sum(1 for v in c.values() if isinstance(v, dict) and v.get("passed") is True) | |
| failed = sum(1 for v in c.values() if isinstance(v, dict) and v.get("passed") is False) | |
| wf = ev.get("workflow") or {} | |
| steps = wf.get("expected_steps", []) | |
| esc = ev.get("escalation", {}) | |
| meta = ev.get("metadata", {}) | |
| return { | |
| "call_id": call_id, | |
| "domain": meta.get("domain"), | |
| "accent": meta.get("accent"), | |
| "duration": out["duration"], | |
| "compliance_passed": passed, | |
| "compliance_applicable": passed + failed, | |
| "workflow_met": sum(1 for s in steps if s.get("met") is True), | |
| "workflow_total": len(steps), | |
| "risk_level": esc.get("risk_level"), | |
| "subject": wf.get("subject"), | |
| } | |
| def main(): | |
| manifest = load_manifest() | |
| call_ids = sorted( | |
| f[:-len("_graph.json")] | |
| for f in os.listdir(os.path.join(EVAL_DIR, "results")) | |
| if f.endswith("_graph.json") | |
| ) | |
| index = [] | |
| with open(DEMO_CALL_DATA, encoding="utf-8") as f: | |
| demo = json.load(f) | |
| os.makedirs(CALLS_OUT, exist_ok=True) | |
| with open(os.path.join(CALLS_OUT, f"{DEMO_CALL}.json"), "w", encoding="utf-8") as f: | |
| json.dump(demo, f, indent=2) | |
| os.makedirs(AUDIO_OUT, exist_ok=True) | |
| shutil.copy(DEMO_AUDIO_SRC, os.path.join(AUDIO_OUT, f"{DEMO_CALL}.mp3")) | |
| index.append(summarize(DEMO_CALL, demo)) | |
| print(f"[{DEMO_CALL}] copied verbatim (demo call, already patched)") | |
| for call_id in call_ids: | |
| if call_id == DEMO_CALL: | |
| continue | |
| out = export_call(call_id, manifest) | |
| index.append(summarize(call_id, out)) | |
| with open(INDEX_OUT, "w", encoding="utf-8") as f: | |
| json.dump(index, f, indent=2) | |
| print(f"\nExported {len(index)} calls -> {CALLS_OUT}") | |
| print(f"Index -> {INDEX_OUT}") | |
| if __name__ == "__main__": | |
| main() | |