aniketqxp commited on
Commit
f1ef7e2
·
1 Parent(s): 6b34a6b

feat: add processing service runtime

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. app.py +42 -0
  2. backend/app/__init__.py +1 -0
  3. backend/app/agents/extraction.py +12 -0
  4. backend/app/agents/llm_client.py +18 -0
  5. backend/app/agents/pipeline.py +12 -0
  6. backend/app/agents/workflow_agent.py +11 -0
  7. backend/app/api/.gitkeep +1 -0
  8. backend/app/database.py +39 -0
  9. backend/app/main.py +83 -0
  10. backend/app/models.py +113 -0
  11. backend/app/pipeline_bridge.py +39 -0
  12. backend/app/routers/__init__.py +0 -0
  13. backend/app/routers/artifacts.py +61 -0
  14. backend/app/routers/calls.py +325 -0
  15. backend/app/routers/sentiment.py +138 -0
  16. backend/app/routing.py +32 -0
  17. backend/app/services/alignment.py +8 -0
  18. backend/app/services/batch_service.py +11 -0
  19. backend/app/space.py +55 -0
  20. backend/app/storage.py +125 -0
  21. backend/app/worker.py +246 -0
  22. ml-services/evaluation/assemble.py +148 -0
  23. ml-services/evaluation/batch_sentence_segments.py +207 -0
  24. ml-services/evaluation/benchmark.py +280 -0
  25. ml-services/evaluation/consistency.py +171 -0
  26. ml-services/evaluation/env_util.py +7 -0
  27. ml-services/evaluation/eval_call_data.py +140 -0
  28. ml-services/evaluation/export_frontend.py +215 -0
  29. ml-services/evaluation/extract.py +193 -0
  30. ml-services/evaluation/fusion.py +256 -0
  31. ml-services/evaluation/gen_call_data.py +115 -0
  32. ml-services/evaluation/graph.py +353 -0
  33. ml-services/evaluation/llm_client.py +243 -0
  34. ml-services/evaluation/nodes.py +517 -0
  35. ml-services/evaluation/paths.py +56 -0
  36. ml-services/evaluation/prompts.py +477 -0
  37. ml-services/evaluation/router.py +143 -0
  38. ml-services/evaluation/rubric.py +265 -0
  39. ml-services/evaluation/run_graph_batch.py +143 -0
  40. ml-services/evaluation/segment.py +234 -0
  41. ml-services/evaluation/sentence_segments.py +165 -0
  42. ml-services/pipeline/acoustic.py +139 -0
  43. ml-services/pipeline/audio_io.py +82 -0
  44. ml-services/pipeline/orchestrator.py +210 -0
  45. ml-services/pipeline/prompts_domain.py +34 -0
  46. ml-services/pipeline/run_one.py +66 -0
  47. ml-services/scripts/audio_preprocess.py +132 -0
  48. ml-services/scripts/build_na_testset.py +134 -0
  49. ml-services/scripts/compare_models.py +151 -0
  50. ml-services/scripts/compare_probe.py +137 -0
app.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import sys
4
+ from pathlib import Path
5
+
6
+
7
+ ROOT = Path(__file__).resolve().parent
8
+ RUNTIME_ROOT = Path(os.environ.get("CAPSTONE_RUNTIME_ROOT", "/tmp/call-qa"))
9
+ DATA_ROOT = RUNTIME_ROOT / "data"
10
+
11
+ os.environ.setdefault("CAPSTONE_DATA_ROOT", str(DATA_ROOT))
12
+ os.environ.setdefault("CAPSTONE_EVAL_RESULTS", str(RUNTIME_ROOT / "evaluation_results"))
13
+ os.environ.setdefault("CAPSTONE_FRONTEND_PUBLIC", str(RUNTIME_ROOT / "frontend_public"))
14
+ os.environ.setdefault("CAPSTONE_ENV_FILE", str(ROOT / ".env"))
15
+ os.environ.setdefault("ENABLE_ACOUSTIC", "0")
16
+ os.environ.setdefault("START_WORKER", "1")
17
+
18
+
19
+ def _prepare_runtime() -> None:
20
+ """Create the writable files expected by the existing pipeline modules."""
21
+ manifest = DATA_ROOT / "na_testset" / "manifest.json"
22
+ manifest.parent.mkdir(parents=True, exist_ok=True)
23
+ if not manifest.exists():
24
+ manifest.write_text(json.dumps([]), encoding="utf-8")
25
+
26
+ for path in (
27
+ RUNTIME_ROOT / "evaluation_results",
28
+ RUNTIME_ROOT / "frontend_public",
29
+ ):
30
+ path.mkdir(parents=True, exist_ok=True)
31
+
32
+
33
+ _prepare_runtime()
34
+ sys.path.insert(0, str(ROOT / "backend"))
35
+
36
+ from app.space import app # noqa: E402
37
+
38
+
39
+ if __name__ == "__main__":
40
+ import uvicorn
41
+
42
+ uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "7860")))
backend/app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Call QA processing API package."""
backend/app/agents/extraction.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
+ class StructuredEvidenceExtractor:
4
+ def __init__(self):
5
+ pass
6
+
7
+ def extract_evidence(self, text_payload: str):
8
+ # Enforces structured output JSON formats
9
+ return {
10
+ "evidence_found": False,
11
+ "segments": []
12
+ }
backend/app/agents/llm_client.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import urllib.request
2
+ import json
3
+
4
+ class OllamaLLMClient:
5
+ def __init__(self, base_url="http://localhost:11434"):
6
+ self.base_url = base_url
7
+
8
+ def query_llama(self, prompt: str, system_prompt: str = ""):
9
+ # Calls Ollama REST endpoint for Llama 3.2 3B model
10
+ payload = {
11
+ "model": "llama3.2:3b",
12
+ "prompt": prompt,
13
+ "system": system_prompt,
14
+ "stream": False
15
+ }
16
+
17
+ # Simple client request placeholder
18
+ return "LLM Compliance Inference Output"
backend/app/agents/pipeline.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class CentralPipelineOrchestrator:
2
+ def __init__(self):
3
+ pass
4
+
5
+ async def execute(self, audio_path: str):
6
+ # Coordinates ML model inference and multi-agent compliance evaluation
7
+ return {
8
+ "status": "success",
9
+ "audio": audio_path,
10
+ "transcripts": [],
11
+ "compliance_checks": []
12
+ }
backend/app/agents/workflow_agent.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class WorkflowComplianceAgent:
2
+ def __init__(self):
3
+ pass
4
+
5
+ def evaluate_rules(self, transcript_segments: list):
6
+ # Deterministic check for compliance rules matching
7
+ results = []
8
+ for segment in transcript_segments:
9
+ # Rule based matches
10
+ pass
11
+ return results
backend/app/api/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+ # Keep directory
backend/app/database.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from sqlalchemy import create_engine
3
+ from sqlalchemy.ext.declarative import declarative_base
4
+ from sqlalchemy.orm import sessionmaker
5
+
6
+ # On Hugging Face Spaces DATABASE_URL MUST be the Supabase SESSION-POOLER
7
+ # (IPv4) URI -- the direct db host is IPv6-only and silently times out.
8
+ DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/capstone")
9
+
10
+ # pool_pre_ping: the Space sleeps after ~48h idle; on wake every pooled
11
+ # connection is long dead. pre_ping issues SELECT 1 before checkout and
12
+ # reconnects transparently instead of 500-ing the first request.
13
+ # pool_recycle keeps connections under the pooler's server-side idle timeout.
14
+ engine = create_engine(
15
+ DATABASE_URL,
16
+ pool_pre_ping=True,
17
+ pool_recycle=1800,
18
+ )
19
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
20
+
21
+ Base = declarative_base()
22
+
23
+
24
+ def init_db():
25
+ """Create tables if the database is reachable.
26
+
27
+ Import-time table creation would hard-crash the whole app when the DB is
28
+ unreachable (e.g. a local smoke import with no Postgres). Callers invoke
29
+ this at startup and may tolerate failure in dev.
30
+ """
31
+ Base.metadata.create_all(bind=engine)
32
+
33
+
34
+ def get_db():
35
+ db = SessionLocal()
36
+ try:
37
+ yield db
38
+ finally:
39
+ db.close()
backend/app/main.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import threading
3
+ from contextlib import asynccontextmanager
4
+ from pathlib import Path
5
+
6
+ from dotenv import load_dotenv
7
+
8
+ # Load the repo-root .env for local dev BEFORE importing anything that reads
9
+ # env at import time (database.py builds the engine from DATABASE_URL). On the
10
+ # Space there is no .env; the real environment (Space secrets) is used and
11
+ # always takes precedence -- load_dotenv never overrides existing vars.
12
+ load_dotenv(Path(__file__).resolve().parents[2] / ".env")
13
+
14
+ from fastapi import FastAPI
15
+ from fastapi.middleware.cors import CORSMiddleware
16
+
17
+ from app.database import init_db
18
+ from app.routers import calls, sentiment, artifacts
19
+ from app import worker
20
+
21
+
22
+ def _boot_model_prewarm():
23
+ """Pre-download the acoustic weights so the first job doesn't pay for it.
24
+ Best-effort, off-thread; only when acoustic is enabled."""
25
+ if os.environ.get("ENABLE_ACOUSTIC", "0").strip().lower() in ("", "0", "false", "no"):
26
+ return
27
+ try:
28
+ from app.pipeline_bridge import install
29
+ install()
30
+ from acoustic import resolve_model_dir
31
+ resolve_model_dir()
32
+ print("[startup] acoustic weights ready")
33
+ except Exception as e:
34
+ print(f"[startup] acoustic pre-warm skipped: {e}")
35
+
36
+
37
+ @asynccontextmanager
38
+ async def lifespan(app):
39
+ try:
40
+ init_db()
41
+ except Exception as e:
42
+ print(f"[startup] init_db skipped (db unreachable?): {e}")
43
+ if os.environ.get("START_WORKER", "1").strip().lower() not in ("", "0", "false", "no"):
44
+ worker.start()
45
+ threading.Thread(target=_boot_model_prewarm, name="model-prewarm",
46
+ daemon=True).start()
47
+ yield
48
+
49
+
50
+ app = FastAPI(
51
+ title="AI/ML Capstone API",
52
+ description="Fresh-call ingest, pipeline worker, and artifact serving",
53
+ version="1.0.0",
54
+ lifespan=lifespan,
55
+ )
56
+
57
+ # CORS: the Vercel frontend calls this API cross-origin. FRONTEND_ORIGIN pins
58
+ # the allowed origin(s) in production (comma-separated); "*" for local dev.
59
+ # Credentials are disabled under wildcard (the spec forbids "*" + credentials);
60
+ # the frontend uses plain fetch with no cookies, so this is fine either way.
61
+ _origins = os.environ.get("FRONTEND_ORIGIN", "*")
62
+ _wild = _origins.strip() == "*"
63
+ app.add_middleware(
64
+ CORSMiddleware,
65
+ allow_origins=["*"] if _wild else [o.strip() for o in _origins.split(",")],
66
+ allow_credentials=not _wild,
67
+ allow_methods=["*"],
68
+ allow_headers=["*"],
69
+ )
70
+
71
+ app.include_router(calls.router)
72
+ app.include_router(sentiment.router)
73
+ app.include_router(artifacts.router)
74
+
75
+
76
+ @app.get("/")
77
+ def read_root():
78
+ return {"status": "online", "service": "backend-orchestration"}
79
+
80
+
81
+ @app.get("/health")
82
+ def health_check():
83
+ return {"status": "ok", "service": "capstone_api"}
backend/app/models.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from datetime import datetime
3
+ from sqlalchemy import Column, String, Integer, Float, Boolean, DateTime, Text, ForeignKey, SmallInteger, Numeric
4
+ from sqlalchemy.dialects.postgresql import UUID, JSONB
5
+ from sqlalchemy.orm import relationship
6
+ from .database import Base
7
+
8
+ class Agent(Base):
9
+ __tablename__ = "agents"
10
+
11
+ agent_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
12
+ name = Column(String(100), nullable=False)
13
+ team = Column(String(100))
14
+ created_at = Column(DateTime, default=datetime.utcnow)
15
+
16
+ calls = relationship("Call", back_populates="agent")
17
+
18
+ class Call(Base):
19
+ __tablename__ = "calls"
20
+
21
+ call_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
22
+ agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.agent_id"), nullable=False)
23
+ audio_path = Column(String(500))
24
+ call_date = Column(DateTime, nullable=False)
25
+ duration_seconds = Column(Integer)
26
+ call_metadata = Column(JSONB)
27
+ created_at = Column(DateTime, default=datetime.utcnow)
28
+
29
+ agent = relationship("Agent", back_populates="calls")
30
+ jobs = relationship("Job", back_populates="call")
31
+
32
+ class Job(Base):
33
+ __tablename__ = "jobs"
34
+
35
+ job_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
36
+ call_id = Column(UUID(as_uuid=True), ForeignKey("calls.call_id"), nullable=False)
37
+ status = Column(String(20), default="queued")
38
+ stage = Column(String(50))
39
+ error = Column(Text)
40
+ created_at = Column(DateTime, default=datetime.utcnow)
41
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
42
+
43
+ call = relationship("Call", back_populates="jobs")
44
+
45
+ class Transcript(Base):
46
+ __tablename__ = "transcripts"
47
+
48
+ transcript_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
49
+ call_id = Column(UUID(as_uuid=True), nullable=True)
50
+ source_call_id = Column(String(100))
51
+ turn_id = Column(Integer, nullable=False)
52
+ speaker = Column(String(20))
53
+ start_time = Column(Float)
54
+ end_time = Column(Float)
55
+ text = Column(Text)
56
+ avg_confidence = Column(Float)
57
+ low_confidence = Column(Boolean, default=False)
58
+
59
+ class Evaluation(Base):
60
+ __tablename__ = "evaluations"
61
+
62
+ evaluation_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
63
+ call_id = Column(UUID(as_uuid=True), nullable=True)
64
+ agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.agent_id"), nullable=False)
65
+ overall_grade = Column(String(1))
66
+ weighted_score = Column(Numeric(5, 2))
67
+ scorecard = Column(JSONB)
68
+ compliance_flags = Column(JSONB)
69
+ escalation_risk = Column(SmallInteger)
70
+ escalation_flag = Column(Boolean, default=False)
71
+ coaching_required = Column(Boolean, default=False)
72
+ manual_review_required = Column(Boolean, default=False)
73
+ llm_scored = Column(Boolean, default=True)
74
+ created_at = Column(DateTime, default=datetime.utcnow)
75
+
76
+ class SentimentSegment(Base):
77
+ __tablename__ = "sentiment_segments"
78
+
79
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
80
+ call_id = Column(String(100), nullable=False)
81
+ segment_index = Column(Integer, nullable=False)
82
+ segment_key = Column(String(200))
83
+ seq_id = Column(Integer)
84
+ speaker = Column(String(20))
85
+ start_time = Column(Float)
86
+ end_time = Column(Float)
87
+ text = Column(Text)
88
+ sentiment = Column(String(20))
89
+ dominant_emotion = Column(String(50))
90
+ escalation_score = Column(Float)
91
+ processing_status = Column(String(30))
92
+ audio_features = Column(JSONB)
93
+ explainability_flags = Column(JSONB)
94
+ escalation_explanation = Column(JSONB)
95
+ has_audio_features = Column(Boolean, default=False)
96
+ audio_feature_version = Column(String(100))
97
+ domain = Column(String(50))
98
+ model_version = Column(String(100))
99
+ created_at = Column(DateTime, default=datetime.utcnow)
100
+
101
+ class CallAudioSummary(Base):
102
+ __tablename__ = "call_audio_summaries"
103
+
104
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
105
+ call_id = Column(String(100), nullable=False)
106
+ domain = Column(String(50))
107
+ model_version = Column(String(100))
108
+ has_audio_features = Column(Boolean, default=False)
109
+ audio_feature_version = Column(String(100))
110
+ audio_feature_match_summary = Column(JSONB)
111
+ dashboard_audio_feature_series = Column(JSONB)
112
+ call_summary = Column(JSONB)
113
+ created_at = Column(DateTime, default=datetime.utcnow)
backend/app/pipeline_bridge.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ The one place the backend reaches into the ml-services pipeline.
3
+
4
+ The evaluation/ and pipeline/ modules import each other by bare top-level
5
+ names (`import paths`, `from graph import ...`) and were only ever run with
6
+ those dirs on sys.path. This shim inserts them exactly once and is imported
7
+ LAZILY from inside the worker thread -- never at app import time -- so a plain
8
+ `import app.main` (health checks, artifact routes) never drags in torch,
9
+ langgraph, or faster-whisper.
10
+ """
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ _REPO = Path(__file__).resolve().parents[2] # backend/app -> backend -> repo
15
+ _ML = _REPO / "ml-services"
16
+ # order matters: pipeline/evaluation/scripts hold the bare-name modules;
17
+ # _ML itself makes `import src.inference...` (the vendored acoustic pkg) resolve
18
+ _PATHS = [_ML / "pipeline", _ML / "evaluation", _ML / "scripts", _ML]
19
+
20
+ _installed = False
21
+
22
+
23
+ def install():
24
+ """Idempotently put the ml-services module dirs on sys.path."""
25
+ global _installed
26
+ if _installed:
27
+ return
28
+ for p in _PATHS:
29
+ sp = str(p)
30
+ if p.exists() and sp not in sys.path:
31
+ sys.path.insert(0, sp)
32
+ _installed = True
33
+
34
+
35
+ def get_process_call():
36
+ """Return orchestrator.process_call (heavy imports; worker-thread only)."""
37
+ install()
38
+ from orchestrator import process_call
39
+ return process_call
backend/app/routers/__init__.py ADDED
File without changes
backend/app/routers/artifacts.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Serves the frontend's static-fetch contract from Supabase Storage + the DB.
3
+
4
+ The React app fetches /calls_index.json, /calls/{id}.json,
5
+ /sentence_segments/{id}.json, /sentiment/{id}.json and /audio/{id}.mp3. These
6
+ were static files under frontend/public; here the same paths become API routes
7
+ so the Vercel-hosted SPA reads seeded and freshly-ingested calls identically.
8
+
9
+ JSON artifacts are proxied (small; keeps the API's CORS headers in front of the
10
+ Vercel origin); audio 307-redirects to the Supabase CDN, which owns Range +
11
+ bandwidth. calls_index is built live from the Call rows' index_summary.
12
+ """
13
+ from fastapi import APIRouter, Depends, HTTPException
14
+ from fastapi.responses import JSONResponse, RedirectResponse
15
+ from sqlalchemy.orm import Session
16
+
17
+ from app.database import get_db
18
+ from app.models import Call
19
+ from app import storage
20
+
21
+ router = APIRouter(tags=["artifacts"])
22
+
23
+
24
+ @router.get("/calls_index.json")
25
+ def calls_index(db: Session = Depends(get_db)):
26
+ """Dashboard list: every call's index_summary, newest first."""
27
+ rows = db.query(Call).order_by(Call.created_at.desc()).all()
28
+ index = [(c.call_metadata or {}).get("index_summary") for c in rows]
29
+ return JSONResponse([s for s in index if s])
30
+
31
+
32
+ def _json_or_404(key):
33
+ try:
34
+ return JSONResponse(storage.stream_json(key))
35
+ except FileNotFoundError:
36
+ raise HTTPException(status_code=404, detail="artifact not found")
37
+
38
+
39
+ @router.get("/calls/{call_id}.json")
40
+ def call_json(call_id: str):
41
+ return _json_or_404(f"calls/{call_id}.json")
42
+
43
+
44
+ @router.get("/sentence_segments/{call_id}.json")
45
+ def sentence_segments(call_id: str):
46
+ return _json_or_404(f"sentence_segments/{call_id}.json")
47
+
48
+
49
+ @router.get("/sentiment/{call_id}.json")
50
+ def sentiment(call_id: str):
51
+ # optional overlay -> empty (not 404) so CallDetail degrades to plain turns
52
+ try:
53
+ return JSONResponse(storage.stream_json(f"sentiment/{call_id}.json"))
54
+ except FileNotFoundError:
55
+ return JSONResponse({"segments": []})
56
+
57
+
58
+ @router.get("/audio/{call_id}.mp3")
59
+ def audio(call_id: str):
60
+ return RedirectResponse(
61
+ storage.public_url(f"audio/{call_id}.mp3"), status_code=307)
backend/app/routers/calls.py ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ import os
3
+ import shutil
4
+ from datetime import datetime
5
+ from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException, status
6
+ from pydantic import BaseModel, Field
7
+ from sqlalchemy.orm import Session
8
+ from app.database import get_db
9
+ from app.models import Call, Job, Transcript, Evaluation, SentimentSegment
10
+ from app import worker, storage
11
+ from app.routing import apply_action_routing
12
+
13
+ router = APIRouter(prefix="/calls", tags=["calls"])
14
+
15
+
16
+ class AnalyzeRequest(BaseModel):
17
+ call_ids: list[uuid.UUID] = Field(min_length=1, max_length=10)
18
+
19
+
20
+ def _latest_job(db, call_id):
21
+ return (
22
+ db.query(Job)
23
+ .filter(Job.call_id == call_id)
24
+ .order_by(Job.created_at.desc())
25
+ .first()
26
+ )
27
+
28
+
29
+ def _incoming_dir(public_id):
30
+ """Local scratch dir for this call's channel wavs (under CAPSTONE_DATA_ROOT)."""
31
+ from app.pipeline_bridge import install
32
+ install()
33
+ import paths
34
+ d = paths.DATA_ROOT / "_incoming" / public_id
35
+ d.mkdir(parents=True, exist_ok=True)
36
+ return d
37
+
38
+ @router.post("/ingest", status_code=201)
39
+ async def ingest_call(
40
+ agent_id: str = Form(...),
41
+ domain: str = Form(...),
42
+ accent: str = Form("en-US_General"),
43
+ call_date: str = Form(None),
44
+ duration_seconds: int = Form(None),
45
+ file: UploadFile = File(...),
46
+ file2: UploadFile = File(None),
47
+ db: Session = Depends(get_db)
48
+ ):
49
+ """
50
+ Ingest one fresh call. Accepts either a single stereo file (ch0=agent,
51
+ ch1=customer) or two per-channel mono files. Splits/normalizes to two mono
52
+ 16 kHz WAVs, stores the originals for restart recovery, and enqueues the
53
+ worker. A single mono file with no second channel is rejected (400).
54
+ """
55
+ from app.pipeline_bridge import install
56
+ install()
57
+ from audio_io import prepare_channels, AudioError
58
+
59
+ public_id = f"{domain.lower()}_{datetime.utcnow():%Y%m%d}_{uuid.uuid4().hex[:8]}"
60
+ inc = _incoming_dir(public_id)
61
+
62
+ raw1 = inc / ("upload1_" + os.path.basename(file.filename or "a.wav"))
63
+ with open(raw1, "wb") as buf:
64
+ shutil.copyfileobj(file.file, buf)
65
+ raw2 = None
66
+ if file2 is not None:
67
+ raw2 = inc / ("upload2_" + os.path.basename(file2.filename or "b.wav"))
68
+ with open(raw2, "wb") as buf:
69
+ shutil.copyfileobj(file2.file, buf)
70
+
71
+ agent_wav = inc / "agent.wav"
72
+ customer_wav = inc / "customer.wav"
73
+ try:
74
+ prepare_channels(str(raw1), str(agent_wav), str(customer_wav),
75
+ secondary=str(raw2) if raw2 else None)
76
+ except AudioError as e:
77
+ raise HTTPException(status_code=400, detail=str(e))
78
+
79
+ # originals to storage so a restart mid-job can re-download and resume
80
+ storage.upload_file(f"uploads/{public_id}/agent.wav", str(agent_wav), "audio/wav")
81
+ storage.upload_file(f"uploads/{public_id}/customer.wav", str(customer_wav), "audio/wav")
82
+
83
+ call = Call(
84
+ agent_id=uuid.UUID(agent_id),
85
+ audio_path=f"uploads/{public_id}/",
86
+ call_date=datetime.fromisoformat(call_date) if call_date else datetime.utcnow(),
87
+ duration_seconds=duration_seconds,
88
+ call_metadata={"public_call_id": public_id,
89
+ "domain": domain.lower(), "accent": accent},
90
+ )
91
+ db.add(call)
92
+ db.flush()
93
+
94
+ job = Job(call_id=call.call_id, status="queued", stage="uploaded")
95
+ db.add(job)
96
+ db.commit()
97
+ db.refresh(job)
98
+
99
+ worker.enqueue(job.job_id)
100
+
101
+ return {
102
+ "job_id": str(job.job_id),
103
+ "call_id": str(call.call_id),
104
+ "public_call_id": public_id,
105
+ "status": "queued",
106
+ "created_at": job.created_at.isoformat()
107
+ }
108
+
109
+
110
+ @router.get("/catalog")
111
+ def get_catalog(db: Session = Depends(get_db)):
112
+ """Return analyzed and staged calls in one dashboard-facing shape."""
113
+ rows = db.query(Call).order_by(Call.created_at.desc()).all()
114
+ catalog = []
115
+ for call in rows:
116
+ meta = dict(call.call_metadata or {})
117
+ public_id = meta.get("public_call_id")
118
+ if not public_id:
119
+ continue
120
+
121
+ summary = dict(meta.get("index_summary") or {})
122
+ job = _latest_job(db, call.call_id)
123
+ analyzed = bool(summary)
124
+ item = {
125
+ **summary,
126
+ "call_id": public_id,
127
+ "db_call_id": str(call.call_id),
128
+ "domain": summary.get("domain") or meta.get("domain"),
129
+ "accent": summary.get("accent") or meta.get("accent"),
130
+ "duration": summary.get("duration") or call.duration_seconds,
131
+ "analyzed": analyzed,
132
+ "status": job.status if job else ("analyzed" if analyzed else "available"),
133
+ "stage": job.stage if job else ("done" if analyzed else "uploaded"),
134
+ "error": job.error if job else None,
135
+ "job_id": str(job.job_id) if job else None,
136
+ }
137
+ catalog.append(item)
138
+ return catalog
139
+
140
+
141
+ @router.post("/analyze", status_code=status.HTTP_202_ACCEPTED)
142
+ def analyze_calls(payload: AnalyzeRequest, db: Session = Depends(get_db)):
143
+ """Queue staged calls, reusing an active job when a request is repeated."""
144
+ calls = {
145
+ call.call_id: call
146
+ for call in db.query(Call).filter(Call.call_id.in_(payload.call_ids)).all()
147
+ }
148
+ missing = [str(call_id) for call_id in payload.call_ids if call_id not in calls]
149
+ if missing:
150
+ raise HTTPException(
151
+ status_code=404,
152
+ detail={"message": "call not found", "call_ids": missing},
153
+ )
154
+
155
+ jobs = []
156
+ for call_id in payload.call_ids:
157
+ call = calls[call_id]
158
+ meta = dict(call.call_metadata or {})
159
+ public_id = meta.get("public_call_id")
160
+ if not public_id:
161
+ raise HTTPException(status_code=409, detail=f"call {call_id} is not pipeline-managed")
162
+
163
+ active = _latest_job(db, call_id)
164
+ if active and active.status in ("queued", "processing"):
165
+ jobs.append({
166
+ "call_id": str(call_id),
167
+ "public_call_id": public_id,
168
+ "job_id": str(active.job_id),
169
+ "status": active.status,
170
+ "created": False,
171
+ })
172
+ continue
173
+
174
+ if meta.get("index_summary"):
175
+ jobs.append({
176
+ "call_id": str(call_id),
177
+ "public_call_id": public_id,
178
+ "job_id": str(active.job_id) if active else None,
179
+ "status": "already_analyzed",
180
+ "created": False,
181
+ })
182
+ continue
183
+
184
+ job = Job(call_id=call_id, status="queued", stage="uploaded")
185
+ db.add(job)
186
+ db.flush()
187
+ jobs.append({
188
+ "call_id": str(call_id),
189
+ "public_call_id": public_id,
190
+ "job_id": str(job.job_id),
191
+ "status": job.status,
192
+ "created": True,
193
+ })
194
+
195
+ db.commit()
196
+ for item in jobs:
197
+ if item["created"]:
198
+ worker.enqueue(item["job_id"])
199
+ return {"jobs": jobs}
200
+
201
+ @router.get("/{call_id}/status")
202
+ def get_call_status(call_id: str, db: Session = Depends(get_db)):
203
+ try:
204
+ db_call_id = uuid.UUID(call_id)
205
+ except ValueError:
206
+ raise HTTPException(status_code=400, detail="invalid call id")
207
+ job = _latest_job(db, db_call_id)
208
+ if not job:
209
+ raise HTTPException(status_code=404, detail="call not found")
210
+ call = db.query(Call).filter(Call.call_id == db_call_id).first()
211
+ public_id = (call.call_metadata or {}).get("public_call_id") if call else None
212
+ return {
213
+ "call_id": call_id,
214
+ "job_id": str(job.job_id),
215
+ "public_call_id": public_id,
216
+ "status": job.status,
217
+ "stage": job.stage,
218
+ "error": job.error,
219
+ "updated_at": job.updated_at.isoformat() if job.updated_at else None
220
+ }
221
+
222
+ @router.post("/transcripts/ingest", status_code=201)
223
+ async def ingest_transcript(
224
+ payload: dict,
225
+ db: Session = Depends(get_db)
226
+ ):
227
+ call_id_str = payload.get("call_id")
228
+ sentences = payload.get("sentences", [])
229
+
230
+ inserted = 0
231
+ for sentence in sentences:
232
+ transcript = Transcript(
233
+ call_id=uuid.UUID(call_id_str) if len(call_id_str) == 36 else None,
234
+ source_call_id=call_id_str,
235
+ turn_id=sentence.get("seq_id", 0),
236
+ speaker=sentence.get("speaker", "").lower(),
237
+ start_time=sentence.get("start"),
238
+ end_time=sentence.get("end"),
239
+ text=sentence.get("text"),
240
+ avg_confidence=1.0,
241
+ low_confidence=False
242
+ )
243
+ db.add(transcript)
244
+ inserted += 1
245
+
246
+ db.commit()
247
+
248
+ return {
249
+ "call_id": call_id_str,
250
+ "inserted": inserted,
251
+ "status": "complete"
252
+ }
253
+
254
+ @router.post("/evaluate", status_code=201)
255
+ async def ingest_evaluation(
256
+ payload: dict,
257
+ db: Session = Depends(get_db)
258
+ ):
259
+ """
260
+ Receives Llama/Aniket evaluation output and applies action routing logic.
261
+ """
262
+ call_id_str = payload.get("call_id")
263
+ agent_id_str = payload.get("agent_id")
264
+
265
+ # Get max escalation score from sentiment table
266
+ sentiment_segments = db.query(SentimentSegment).filter(
267
+ SentimentSegment.call_id == call_id_str
268
+ ).all()
269
+
270
+ scores = [s.escalation_score for s in sentiment_segments if s.escalation_score is not None]
271
+ max_escalation = max(scores) if scores else None
272
+
273
+ # Create evaluation record
274
+ evaluation = Evaluation(
275
+ call_id=uuid.UUID(call_id_str) if len(call_id_str) == 36 else None,
276
+ agent_id=uuid.UUID(agent_id_str) if agent_id_str else None,
277
+ overall_grade=payload.get("overall_grade"),
278
+ weighted_score=payload.get("weighted_score"),
279
+ scorecard=payload.get("scorecard"),
280
+ compliance_flags=payload.get("compliance_flags"),
281
+ escalation_risk=payload.get("escalation_risk"),
282
+ llm_scored=payload.get("llm_scored", True)
283
+ )
284
+
285
+ # Apply action routing
286
+ evaluation = apply_action_routing(evaluation, max_escalation)
287
+
288
+ db.add(evaluation)
289
+ db.commit()
290
+ db.refresh(evaluation)
291
+
292
+ return {
293
+ "call_id": call_id_str,
294
+ "evaluation_id": str(evaluation.evaluation_id),
295
+ "overall_grade": evaluation.overall_grade,
296
+ "escalation_flag": evaluation.escalation_flag,
297
+ "coaching_required": evaluation.coaching_required,
298
+ "manual_review_required": evaluation.manual_review_required,
299
+ "status": "complete"
300
+ }
301
+
302
+ @router.get("/{call_id}/flags")
303
+ def get_call_flags(call_id: str, db: Session = Depends(get_db)):
304
+ """
305
+ Returns the action flags for a call — useful for dashboard alerts.
306
+ """
307
+ try:
308
+ evaluation = db.query(Evaluation).filter(
309
+ Evaluation.call_id == uuid.UUID(call_id)
310
+ ).first()
311
+ except Exception:
312
+ return {"error": "invalid call_id format"}
313
+
314
+ if not evaluation:
315
+ return {"error": "no evaluation found for this call"}
316
+
317
+ return {
318
+ "call_id": call_id,
319
+ "overall_grade": evaluation.overall_grade,
320
+ "escalation_flag": evaluation.escalation_flag,
321
+ "coaching_required": evaluation.coaching_required,
322
+ "manual_review_required": evaluation.manual_review_required,
323
+ "escalation_risk": evaluation.escalation_risk,
324
+ "weighted_score": float(evaluation.weighted_score) if evaluation.weighted_score else None
325
+ }
backend/app/routers/sentiment.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends
2
+ from sqlalchemy.orm import Session
3
+ from app.database import get_db
4
+ from app.models import SentimentSegment, CallAudioSummary
5
+
6
+ router = APIRouter(prefix="/sentiment", tags=["sentiment"])
7
+
8
+ @router.post("/ingest", status_code=201)
9
+ async def ingest_sentiment(
10
+ payload: dict,
11
+ db: Session = Depends(get_db)
12
+ ):
13
+ call_id_str = payload.get("call_id")
14
+ domain = payload.get("domain")
15
+ model_version = payload.get("model_version")
16
+ has_audio_features = payload.get("has_audio_features", False)
17
+ audio_feature_version = payload.get("audio_feature_version")
18
+ segments = payload.get("segments", [])
19
+ call_summary = payload.get("call_summary")
20
+ audio_feature_match_summary = payload.get("audio_feature_match_summary")
21
+ dashboard_audio_feature_series = payload.get("dashboard_audio_feature_series")
22
+
23
+ # Save or update call-level audio summary
24
+ existing_summary = db.query(CallAudioSummary).filter(
25
+ CallAudioSummary.call_id == call_id_str
26
+ ).first()
27
+
28
+ if not existing_summary:
29
+ summary_row = CallAudioSummary(
30
+ call_id=call_id_str,
31
+ domain=domain,
32
+ model_version=model_version,
33
+ has_audio_features=has_audio_features,
34
+ audio_feature_version=audio_feature_version,
35
+ audio_feature_match_summary=audio_feature_match_summary,
36
+ dashboard_audio_feature_series=dashboard_audio_feature_series,
37
+ call_summary=call_summary
38
+ )
39
+ db.add(summary_row)
40
+
41
+ # Save segment-level data
42
+ inserted = 0
43
+ skipped = 0
44
+
45
+ for segment in segments:
46
+ existing = db.query(SentimentSegment).filter(
47
+ SentimentSegment.segment_key == segment.get("segment_key")
48
+ ).first()
49
+
50
+ if existing:
51
+ skipped += 1
52
+ continue
53
+
54
+ audio_features = segment.get("audio_features")
55
+
56
+ sentiment_row = SentimentSegment(
57
+ call_id=call_id_str,
58
+ segment_index=segment.get("segment_index"),
59
+ segment_key=segment.get("segment_key"),
60
+ seq_id=segment.get("seq_id"),
61
+ speaker=segment.get("speaker"),
62
+ start_time=segment.get("start_time"),
63
+ end_time=segment.get("end_time"),
64
+ text=segment.get("text"),
65
+ sentiment=segment.get("sentiment"),
66
+ dominant_emotion=segment.get("dominant_emotion"),
67
+ escalation_score=segment.get("escalation_score"),
68
+ processing_status=segment.get("processing_status"),
69
+ audio_features=audio_features,
70
+ explainability_flags=segment.get("explainability_flags"),
71
+ escalation_explanation=segment.get("escalation_explanation"),
72
+ has_audio_features=audio_features is not None,
73
+ audio_feature_version=audio_feature_version,
74
+ domain=domain,
75
+ model_version=model_version
76
+ )
77
+ db.add(sentiment_row)
78
+ inserted += 1
79
+
80
+ db.commit()
81
+
82
+ return {
83
+ "call_id": call_id_str,
84
+ "inserted": inserted,
85
+ "skipped_duplicates": skipped,
86
+ "has_audio_features": has_audio_features,
87
+ "status": "complete"
88
+ }
89
+
90
+ @router.get("/{call_id}/summary")
91
+ def get_sentiment_summary(call_id: str, db: Session = Depends(get_db)):
92
+ segments = db.query(SentimentSegment).filter(
93
+ SentimentSegment.call_id == call_id
94
+ ).all()
95
+
96
+ if not segments:
97
+ return {"error": "no sentiment data found for this call"}
98
+
99
+ successful = [s for s in segments if s.processing_status == "success"]
100
+ skipped = [s for s in segments if s.processing_status == "skipped_too_short"]
101
+
102
+ sentiments = [s.sentiment for s in successful if s.sentiment]
103
+ dominant_sentiment = max(set(sentiments), key=sentiments.count) if sentiments else None
104
+
105
+ emotions = [s.dominant_emotion for s in successful if s.dominant_emotion]
106
+ dominant_emotion = max(set(emotions), key=emotions.count) if emotions else None
107
+
108
+ scores = [s.escalation_score for s in successful if s.escalation_score is not None]
109
+ avg_escalation = round(sum(scores) / len(scores), 4) if scores else None
110
+ max_escalation = round(max(scores), 4) if scores else None
111
+
112
+ return {
113
+ "call_id": call_id,
114
+ "total_segments": len(segments),
115
+ "successful_segments": len(successful),
116
+ "skipped_segments": len(skipped),
117
+ "dominant_sentiment": dominant_sentiment,
118
+ "dominant_emotion": dominant_emotion,
119
+ "average_escalation_score": avg_escalation,
120
+ "max_escalation_score": max_escalation
121
+ }
122
+
123
+ @router.get("/{call_id}/dashboard")
124
+ def get_dashboard_series(call_id: str, db: Session = Depends(get_db)):
125
+ summary = db.query(CallAudioSummary).filter(
126
+ CallAudioSummary.call_id == call_id
127
+ ).first()
128
+
129
+ if not summary:
130
+ return {"error": "no audio summary found for this call"}
131
+
132
+ return {
133
+ "call_id": call_id,
134
+ "domain": summary.domain,
135
+ "has_audio_features": summary.has_audio_features,
136
+ "call_summary": summary.call_summary,
137
+ "dashboard_audio_feature_series": summary.dashboard_audio_feature_series
138
+ }
backend/app/routing.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Deterministic action-routing: read an Evaluation's scores, set its flags.
3
+
4
+ Lives here (not in the FastAPI router) so both the request handler and the
5
+ background worker can call it without the worker importing the web layer.
6
+ """
7
+
8
+
9
+ def apply_action_routing(evaluation, max_escalation_score=None):
10
+ """Set escalation/coaching/manual-review flags from the scores in place."""
11
+ # text escalation risk from the LLM (0-10 scale)
12
+ if evaluation.escalation_risk is not None:
13
+ if evaluation.escalation_risk >= 7:
14
+ evaluation.escalation_flag = True
15
+ evaluation.coaching_required = True
16
+ elif evaluation.escalation_risk >= 4:
17
+ evaluation.coaching_required = True
18
+
19
+ # grade-based routing
20
+ if evaluation.overall_grade == "F":
21
+ evaluation.manual_review_required = True
22
+ evaluation.coaching_required = True
23
+
24
+ # acoustic escalation (wav2vec2 max segment score, 0.0-1.0)
25
+ if max_escalation_score is not None:
26
+ if max_escalation_score >= 0.7:
27
+ evaluation.escalation_flag = True
28
+ evaluation.coaching_required = True
29
+ elif max_escalation_score >= 0.4:
30
+ evaluation.coaching_required = True
31
+
32
+ return evaluation
backend/app/services/alignment.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ class TranscriptSpeakerAligner:
2
+ def __init__(self):
3
+ pass
4
+
5
+ def align(self, diarization_turns: list, transcription_words: list):
6
+ # Aligns word boundaries with speaker diarization turns
7
+ aligned_segments = []
8
+ return aligned_segments
backend/app/services/batch_service.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ class BatchFileService:
4
+ def __init__(self, watch_dir: str = "/data"):
5
+ self.watch_dir = watch_dir
6
+
7
+ def scan_records(self):
8
+ # Reads local AppTek records and maps metadata keys from disk
9
+ if not os.path.exists(self.watch_dir):
10
+ return []
11
+ return os.listdir(self.watch_dir)
backend/app/space.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ import gradio as gr
4
+ from fastapi.responses import RedirectResponse
5
+ from sqlalchemy import func
6
+
7
+ from app.database import SessionLocal
8
+ from app.main import app as api
9
+ from app.models import Call, Job
10
+ from app.storage import is_configured
11
+
12
+
13
+ def runtime_snapshot():
14
+ snapshot = {
15
+ "service": "call-qa-processing",
16
+ "database_configured": bool(os.environ.get("DATABASE_URL")),
17
+ "storage_configured": is_configured(),
18
+ "acoustic_enabled": os.environ.get("ENABLE_ACOUSTIC", "0") == "1",
19
+ }
20
+ db = SessionLocal()
21
+ try:
22
+ snapshot["calls"] = db.query(func.count(Call.call_id)).scalar() or 0
23
+ snapshot["jobs"] = {
24
+ status: db.query(func.count(Job.job_id)).filter(Job.status == status).scalar() or 0
25
+ for status in ("queued", "processing", "succeeded", "failed")
26
+ }
27
+ except Exception as exc:
28
+ snapshot["database_error"] = str(exc)[:240]
29
+ finally:
30
+ db.close()
31
+ return snapshot
32
+
33
+
34
+ with gr.Blocks(title="Call QA Processing") as demo:
35
+ gr.Markdown("# Call QA Processing")
36
+ state = gr.JSON(label="Runtime status")
37
+ refresh = gr.Button("Refresh", variant="primary")
38
+ refresh.click(runtime_snapshot, outputs=state, api_name="runtime_status")
39
+ demo.load(runtime_snapshot, outputs=state)
40
+
41
+
42
+ # Replace the JSON root with the Space UI while preserving every API route.
43
+ api.router.routes = [
44
+ route
45
+ for route in api.router.routes
46
+ if not (getattr(route, "path", None) == "/" and "GET" in getattr(route, "methods", set()))
47
+ ]
48
+
49
+
50
+ @api.get("/", include_in_schema=False)
51
+ def space_root():
52
+ return RedirectResponse(url="/gradio/")
53
+
54
+
55
+ app = gr.mount_gradio_app(api, demo, path="/gradio")
backend/app/storage.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Supabase Storage access over plain REST (no SDK dependency).
3
+
4
+ Artifacts (call JSON, mp3, sentence_segments, sentiment, raw transcript, and
5
+ the original upload for restart recovery) live in one PUBLIC bucket so reads
6
+ need no auth and the Supabase CDN serves audio Range requests directly. Writes
7
+ use the service-role key.
8
+
9
+ Layout in the bucket (keys):
10
+ calls/{id}.json audio/{id}.mp3 sentence_segments/{id}.json
11
+ sentiment/{id}.json transcripts/{id}.json uploads/{id}/<name>
12
+ """
13
+ import os
14
+ import json
15
+ import mimetypes
16
+ from pathlib import Path
17
+
18
+ import requests
19
+
20
+ BUCKET = os.environ.get("SUPABASE_BUCKET", "call-artifacts")
21
+ _TIMEOUT = 30
22
+
23
+
24
+ class StorageError(RuntimeError):
25
+ pass
26
+
27
+
28
+ def _base():
29
+ url = os.environ.get("SUPABASE_URL", "").rstrip("/")
30
+ if not url:
31
+ raise StorageError("SUPABASE_URL is not set")
32
+ return url
33
+
34
+
35
+ def _service_key():
36
+ # secret/service-role key preferred (storage writes bypass RLS); falls back
37
+ # to SUPABASE_KEY, which only authorizes writes if the bucket grants anon
38
+ # insert. Reads from a public bucket work with either.
39
+ key = os.environ.get("SUPABASE_SERVICE_KEY") or os.environ.get("SUPABASE_KEY", "")
40
+ if not key:
41
+ raise StorageError("neither SUPABASE_SERVICE_KEY nor SUPABASE_KEY is set")
42
+ return key
43
+
44
+
45
+ def is_configured():
46
+ return bool(os.environ.get("SUPABASE_URL")
47
+ and (os.environ.get("SUPABASE_SERVICE_KEY")
48
+ or os.environ.get("SUPABASE_KEY")))
49
+
50
+
51
+ def object_url(key):
52
+ """Authenticated object endpoint (used for writes)."""
53
+ return f"{_base()}/storage/v1/object/{BUCKET}/{key}"
54
+
55
+
56
+ def public_url(key):
57
+ """Public read URL served by the Supabase CDN (Range-capable)."""
58
+ return f"{_base()}/storage/v1/object/public/{BUCKET}/{key}"
59
+
60
+
61
+ # ── writes ──────────────────────────────────────────────────────────────────
62
+
63
+ def upload_bytes(key, data, content_type="application/octet-stream"):
64
+ # `apikey` header (not just Authorization: Bearer) is required for the new
65
+ # sb_secret_ key format -- the object endpoint parses Bearer tokens as JWTs
66
+ # and rejects the non-JWT key with "Invalid Compact JWS". Sending both is
67
+ # compatible with legacy JWT service_role keys too.
68
+ k = _service_key()
69
+ headers = {
70
+ "apikey": k,
71
+ "Authorization": f"Bearer {k}",
72
+ "Content-Type": content_type,
73
+ "x-upsert": "true", # overwrite -> idempotent re-runs
74
+ }
75
+ r = requests.post(object_url(key), headers=headers, data=data, timeout=_TIMEOUT)
76
+ if r.status_code not in (200, 201):
77
+ raise StorageError(f"upload {key} failed: {r.status_code} {r.text[:200]}")
78
+ return key
79
+
80
+
81
+ def upload_file(key, path, content_type=None):
82
+ if content_type is None:
83
+ content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
84
+ with open(path, "rb") as f:
85
+ return upload_bytes(key, f.read(), content_type)
86
+
87
+
88
+ def upload_json(key, obj):
89
+ return upload_bytes(key, json.dumps(obj).encode("utf-8"), "application/json")
90
+
91
+
92
+ # ── reads (public bucket; small local cache since artifacts are immutable) ──
93
+
94
+ _CACHE_DIR = Path(os.environ.get("CAPSTONE_DATA_ROOT", "/tmp")) / "_storage_cache"
95
+
96
+
97
+ def _cache_path(key):
98
+ return _CACHE_DIR / key
99
+
100
+
101
+ def exists(key):
102
+ try:
103
+ r = requests.head(public_url(key), timeout=_TIMEOUT)
104
+ return r.status_code == 200
105
+ except requests.RequestException:
106
+ return False
107
+
108
+
109
+ def download_bytes(key, use_cache=True):
110
+ cp = _cache_path(key)
111
+ if use_cache and cp.exists():
112
+ return cp.read_bytes()
113
+ r = requests.get(public_url(key), timeout=_TIMEOUT)
114
+ if r.status_code == 404:
115
+ raise FileNotFoundError(key)
116
+ if r.status_code != 200:
117
+ raise StorageError(f"download {key} failed: {r.status_code}")
118
+ if use_cache:
119
+ cp.parent.mkdir(parents=True, exist_ok=True)
120
+ cp.write_bytes(r.content)
121
+ return r.content
122
+
123
+
124
+ def stream_json(key, use_cache=True):
125
+ return json.loads(download_bytes(key, use_cache=use_cache).decode("utf-8"))
backend/app/worker.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Single background worker: one daemon thread draining a job queue.
3
+
4
+ Why one thread (not a pool): the evaluation graph fires many rate-limited LLM
5
+ calls per call and CPU whisper/torch already saturate the 2-vCPU Space.
6
+ Sequential processing keeps us under provider limits and off the CPU cliff.
7
+ Jobs are cheap to enqueue, slow to run (~5-15 min), so queue + one worker is
8
+ the right shape.
9
+
10
+ Restart recovery: the Space's disk is ephemeral and it sleeps/restarts freely.
11
+ On startup we re-enqueue every job still marked queued/processing and
12
+ re-download its original channels from storage; the orchestrator is idempotent
13
+ (an existing transcript is reused), so a re-run resumes rather than restarts.
14
+ """
15
+ import os
16
+ import uuid
17
+ import queue
18
+ import threading
19
+ import traceback
20
+ from datetime import datetime
21
+
22
+ from app.database import SessionLocal
23
+ from app import storage
24
+ from app.models import (Call, Job, Transcript, Evaluation,
25
+ SentimentSegment, CallAudioSummary)
26
+ from app.routing import apply_action_routing
27
+
28
+ _q = queue.Queue()
29
+ _thread = None
30
+ _started = False
31
+
32
+ # text escalation tier -> Asma's 0-10 escalation_risk scale
33
+ ESCALATION_MAP = {"none": 0, "review": 5, "escalate": 8}
34
+
35
+
36
+ def _uuid(v):
37
+ return v if isinstance(v, uuid.UUID) else uuid.UUID(str(v))
38
+
39
+
40
+ def enqueue(job_id):
41
+ _q.put(str(job_id))
42
+
43
+
44
+ def start():
45
+ """Launch the worker thread and recover interrupted jobs (idempotent)."""
46
+ global _thread, _started
47
+ if _started:
48
+ return
49
+ _started = True
50
+ _thread = threading.Thread(target=_loop, name="pipeline-worker", daemon=True)
51
+ _thread.start()
52
+ _recover()
53
+
54
+
55
+ def _recover():
56
+ """Re-enqueue jobs left queued/processing by a previous (crashed) run --
57
+ but ONLY jobs from this pipeline's ingest path (call_metadata carries a
58
+ public_call_id). Legacy/foreign jobs are left untouched."""
59
+ db = SessionLocal()
60
+ try:
61
+ stuck = db.query(Job).filter(Job.status.in_(["queued", "processing"])).all()
62
+ recovered = 0
63
+ for j in stuck:
64
+ call = db.query(Call).filter(Call.call_id == j.call_id).first()
65
+ meta = (call.call_metadata or {}) if call else {}
66
+ if meta.get("public_call_id"):
67
+ enqueue(j.job_id)
68
+ recovered += 1
69
+ if recovered:
70
+ print(f"[worker] recovered {recovered} interrupted job(s)")
71
+ except Exception as e:
72
+ print(f"[worker] recovery skipped (db unreachable?): {e}")
73
+ finally:
74
+ db.close()
75
+
76
+
77
+ def _loop():
78
+ while True:
79
+ job_id = _q.get()
80
+ try:
81
+ _run_job(job_id)
82
+ except Exception:
83
+ traceback.print_exc()
84
+ finally:
85
+ _q.task_done()
86
+
87
+
88
+ def _set(db, job, status=None, stage=None, error=None):
89
+ if status is not None:
90
+ job.status = status
91
+ if stage is not None:
92
+ job.stage = stage
93
+ if error is not None:
94
+ job.error = error[:2000]
95
+ job.updated_at = datetime.utcnow()
96
+ db.commit()
97
+
98
+
99
+ def _ensure_local_wavs(public_id, meta):
100
+ """Return local (agent_wav, customer_wav), re-downloading from storage if
101
+ the ephemeral disk was wiped (restart recovery)."""
102
+ from app.pipeline_bridge import install
103
+ install()
104
+ import paths
105
+ dest = paths.DATA_ROOT / "_incoming" / public_id
106
+ dest.mkdir(parents=True, exist_ok=True)
107
+ out = {}
108
+ for role in ("agent", "customer"):
109
+ local = dest / f"{role}.wav"
110
+ if not local.exists():
111
+ data = storage.download_bytes(f"uploads/{public_id}/{role}.wav",
112
+ use_cache=False)
113
+ local.write_bytes(data)
114
+ out[role] = str(local)
115
+ return out["agent"], out["customer"]
116
+
117
+
118
+ def _upload_artifacts(public_id, artifacts):
119
+ """Push every produced artifact to storage; return the key map."""
120
+ p = artifacts.get("paths", {})
121
+ keys = {}
122
+ plan = [
123
+ ("call_json", f"calls/{public_id}.json", "application/json"),
124
+ ("audio", f"audio/{public_id}.mp3", "audio/mpeg"),
125
+ ("sentence_segments", f"sentence_segments/{public_id}.json", "application/json"),
126
+ ("transcript", f"transcripts/{public_id}.json", "application/json"),
127
+ ]
128
+ if artifacts.get("acoustic") and p.get("sentiment"):
129
+ plan.append(("sentiment", f"sentiment/{public_id}.json", "application/json"))
130
+ for local_key, obj_key, ctype in plan:
131
+ local = p.get(local_key)
132
+ if local and os.path.exists(local):
133
+ storage.upload_file(obj_key, local, ctype)
134
+ keys[local_key] = obj_key
135
+ return keys
136
+
137
+
138
+ def _write_db_rows(db, call, public_id, artifacts):
139
+ """Populate the relational tables from the produced artifacts. The frontend
140
+ reads artifacts from storage; these rows back the flags/summary endpoints."""
141
+ import json
142
+ p = artifacts["paths"]
143
+
144
+ # transcripts: one row per sentence segment
145
+ with open(p["sentence_segments"], encoding="utf-8") as f:
146
+ seg = json.load(f)
147
+ db.query(Transcript).filter(Transcript.source_call_id == public_id).delete()
148
+ for s in seg.get("sentences", []):
149
+ db.add(Transcript(
150
+ call_id=call.call_id, source_call_id=public_id,
151
+ turn_id=s.get("seq_id", 0), speaker=(s.get("speaker") or "").lower(),
152
+ start_time=s.get("start"), end_time=s.get("end"),
153
+ text=s.get("text"), avg_confidence=1.0, low_confidence=False))
154
+
155
+ # sentiment segments (acoustic only) + max escalation for routing
156
+ max_escalation = None
157
+ if artifacts.get("acoustic") and p.get("sentiment"):
158
+ with open(p["sentiment"], encoding="utf-8") as f:
159
+ sent = json.load(f)
160
+ db.query(SentimentSegment).filter(
161
+ SentimentSegment.call_id == public_id).delete()
162
+ # model_version column is varchar(100); guard against long values
163
+ model_version = (sent.get("model_version") or "")[:100]
164
+ scores = []
165
+ for i, s in enumerate(sent.get("segments", [])):
166
+ db.add(SentimentSegment(
167
+ call_id=public_id, segment_index=i,
168
+ segment_key=f"{public_id}_{s.get('seq_id')}",
169
+ seq_id=s.get("seq_id"), sentiment=s.get("sentiment"),
170
+ dominant_emotion=s.get("dominant_emotion"),
171
+ escalation_score=s.get("escalation_score"),
172
+ processing_status=s.get("processing_status"),
173
+ domain=artifacts.get("domain"),
174
+ model_version=model_version))
175
+ if s.get("escalation_score") is not None:
176
+ scores.append(s["escalation_score"])
177
+ max_escalation = max(scores) if scores else None
178
+
179
+ # evaluation: full graph.json as the scorecard, tier -> 0-10 risk
180
+ with open(p["evaluation"], encoding="utf-8") as f:
181
+ ev = json.load(f)
182
+ risk_level = (ev.get("escalation") or {}).get("risk_level", "none")
183
+ db.query(Evaluation).filter(Evaluation.call_id == call.call_id).delete()
184
+ evaluation = Evaluation(
185
+ call_id=call.call_id, agent_id=call.agent_id,
186
+ scorecard=ev, compliance_flags=ev.get("compliance"),
187
+ escalation_risk=ESCALATION_MAP.get(risk_level, 0), llm_scored=True)
188
+ apply_action_routing(evaluation, max_escalation)
189
+ db.add(evaluation)
190
+
191
+
192
+ def _run_job(job_id):
193
+ db = SessionLocal()
194
+ try:
195
+ job = db.query(Job).filter(Job.job_id == _uuid(job_id)).first()
196
+ if not job:
197
+ return
198
+ call = db.query(Call).filter(Call.call_id == job.call_id).first()
199
+ meta = dict(call.call_metadata or {}) if call else {}
200
+ public_id = meta.get("public_call_id")
201
+ if not public_id:
202
+ # not one of ours (e.g. a legacy job) -- refuse rather than crash
203
+ _set(db, job, status="failed",
204
+ error="call has no public_call_id; not an ingest-pipeline call")
205
+ return
206
+
207
+ _set(db, job, status="processing", stage="starting", error="")
208
+
209
+ agent_wav, customer_wav = _ensure_local_wavs(public_id, meta)
210
+ spec = {"call_id": public_id, "domain": meta["domain"],
211
+ "accent": meta["accent"], "agent_wav": agent_wav,
212
+ "customer_wav": customer_wav}
213
+
214
+ def progress(stage):
215
+ _set(db, job, stage=stage)
216
+
217
+ from app.pipeline_bridge import get_process_call
218
+ process_call = get_process_call()
219
+ artifacts = process_call(spec, progress=progress)
220
+
221
+ _set(db, job, stage="uploading")
222
+ keys = _upload_artifacts(public_id, artifacts)
223
+
224
+ _set(db, job, stage="persisting")
225
+ _write_db_rows(db, call, public_id, artifacts)
226
+
227
+ summary = artifacts.get("index_summary") or {}
228
+ meta["index_summary"] = summary
229
+ meta["artifact_keys"] = keys
230
+ call.call_metadata = meta
231
+ if summary.get("duration"):
232
+ call.duration_seconds = int(summary["duration"])
233
+ db.commit()
234
+
235
+ _set(db, job, status="succeeded", stage="done", error="")
236
+ print(f"[worker] job {job_id} succeeded ({public_id})")
237
+ except Exception as e:
238
+ traceback.print_exc()
239
+ try:
240
+ job = db.query(Job).filter(Job.job_id == _uuid(job_id)).first()
241
+ if job:
242
+ _set(db, job, status="failed", error=str(e))
243
+ except Exception:
244
+ pass
245
+ finally:
246
+ db.close()
ml-services/evaluation/assemble.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Phase 0 context-engineering: turn our per-channel transcript into the
3
+ evaluation INPUT PACKET the LLM will consume.
4
+
5
+ Steps (deterministic, no LLM):
6
+ 1. Role mapping -- channel identity IS the speaker (AGENT=ch1, CUSTOMER=ch2).
7
+ No diarization, no "who spoke first" heuristic -> 100% correct.
8
+ 2. Turn ordering -- merge both channels' segments by start time into a single
9
+ chronological [AGENT]/[CUSTOMER] turn list with mm:ss stamps.
10
+ 3. Metadata injection -- prepend call metadata (domain as "Call Type" lens,
11
+ duration, etc.) so the LLM judges with the right context.
12
+
13
+ Output: a single text packet (+ saved .txt) ready to drop into the Phase 1 prompt.
14
+
15
+ Usage:
16
+ python assemble.py --result ../../data/na_testset/results_channels/en-CA/en_CA_Banking_1592237.json
17
+ python assemble.py --call_id en_CA_Banking_1592237 # auto-locates result + manifest
18
+ """
19
+ import os, json, argparse
20
+
21
+ import paths
22
+
23
+ DATA = str(paths.NA_TESTSET)
24
+ MANIFEST = str(paths.MANIFEST)
25
+
26
+
27
+ def mmss(seconds):
28
+ seconds = int(round(seconds))
29
+ return f"{seconds // 60:02d}:{seconds % 60:02d}"
30
+
31
+
32
+ def load_manifest():
33
+ """Dataset manifest merged with the runtime registry of ingested calls.
34
+
35
+ Runtime entries win on call_id collision so a re-ingested call can carry
36
+ corrected metadata without touching the dataset manifest.
37
+ """
38
+ with open(MANIFEST, encoding="utf-8") as f:
39
+ manifest = {m["call_id"]: m for m in json.load(f)}
40
+ if paths.RUNTIME_MANIFEST.exists():
41
+ with open(paths.RUNTIME_MANIFEST, encoding="utf-8") as f:
42
+ manifest.update({m["call_id"]: m for m in json.load(f)})
43
+ return manifest
44
+
45
+
46
+ def assemble_turns(result):
47
+ """Merge agent+customer segments into one time-ordered turn list."""
48
+ turns = []
49
+ segs = result.get("segments", {})
50
+ if segs and segs.get("agent") is not None:
51
+ for speaker, key in (("AGENT", "agent"), ("CUSTOMER", "customer")):
52
+ for s in segs.get(key, []):
53
+ text = s.get("text", "").strip()
54
+ if text:
55
+ turns.append({"start": s["start"], "speaker": speaker, "text": text})
56
+ else:
57
+ # fallback: no segments -> group words by speaker flips
58
+ merged = []
59
+ for speaker, key in (("AGENT", "agent"), ("CUSTOMER", "customer")):
60
+ for w in result.get(key, []):
61
+ merged.append((w["start"], speaker, w["word"]))
62
+ merged.sort()
63
+ cur_sp, cur_words, cur_start = None, [], None
64
+ for start, sp, word in merged:
65
+ if sp != cur_sp and cur_words:
66
+ turns.append({"start": cur_start, "speaker": cur_sp, "text": " ".join(cur_words)})
67
+ cur_words = []
68
+ if sp != cur_sp:
69
+ cur_sp, cur_start = sp, start
70
+ cur_words.append(word)
71
+ if cur_words:
72
+ turns.append({"start": cur_start, "speaker": cur_sp, "text": " ".join(cur_words)})
73
+
74
+ turns.sort(key=lambda t: t["start"])
75
+ return turns
76
+
77
+
78
+ def estimate_duration(result):
79
+ last = 0.0
80
+ for key in ("agent", "customer"):
81
+ for w in result.get(key, []):
82
+ last = max(last, w.get("end", w.get("start", 0)))
83
+ return round(last, 1)
84
+
85
+
86
+ def build_packet(result, meta):
87
+ turns = assemble_turns(result)
88
+ dur = estimate_duration(result)
89
+
90
+ # ── metadata header (the "lens") ──────────────────────────────────────────
91
+ header = [
92
+ "=" * 70,
93
+ "CALL METADATA",
94
+ "=" * 70,
95
+ f"Call ID : {result.get('call_id', meta.get('call_id', '?'))}",
96
+ f"Call Type : {result.get('domain', meta.get('domain', 'unknown'))}",
97
+ f"Duration : {mmss(dur)} ({dur:.0f}s)",
98
+ f"Transcript : {result.get('model', 'unknown')} (per-channel; AGENT=ch1, CUSTOMER=ch2)",
99
+ f"Turns : {len(turns)}",
100
+ "",
101
+ "=" * 70,
102
+ "ROLE-MAPPED TRANSCRIPT (chronological; [SPEAKER mm:ss])",
103
+ "=" * 70,
104
+ ]
105
+
106
+ body = [f"[{t['speaker']} {mmss(t['start'])}] {t['text']}" for t in turns]
107
+ return "\n".join(header) + "\n" + "\n".join(body), {"turns": len(turns), "duration_s": dur}
108
+
109
+
110
+ def main():
111
+ ap = argparse.ArgumentParser()
112
+ ap.add_argument("--result", help="path to a per-channel result JSON")
113
+ ap.add_argument("--call_id", help="call id (auto-locates result via manifest)")
114
+ ap.add_argument("--results_dir", default="results_channels",
115
+ help="result dir under na_testset (default: results_channels)")
116
+ ap.add_argument("--out", default=None, help="output .txt path (default: evaluation/packets/<id>.txt)")
117
+ args = ap.parse_args()
118
+
119
+ manifest = load_manifest()
120
+
121
+ if args.result:
122
+ path = args.result
123
+ elif args.call_id:
124
+ meta = manifest[args.call_id]
125
+ path = os.path.join(DATA, args.results_dir, meta["accent"], args.call_id + ".json")
126
+ else:
127
+ raise SystemExit("Provide --result or --call_id")
128
+
129
+ with open(path, encoding="utf-8") as f:
130
+ result = json.load(f)
131
+
132
+ cid = result.get("call_id")
133
+ meta = manifest.get(cid, {})
134
+ packet, stats = build_packet(result, meta)
135
+
136
+ here = os.path.dirname(os.path.abspath(__file__))
137
+ out = args.out or os.path.join(here, "packets", f"{cid}.txt")
138
+ os.makedirs(os.path.dirname(out), exist_ok=True)
139
+ with open(out, "w", encoding="utf-8") as f:
140
+ f.write(packet)
141
+
142
+ print(packet)
143
+ print("\n" + "-" * 70)
144
+ print(f"Saved packet -> {out} ({stats['turns']} turns, {mmss(stats['duration_s'])})")
145
+
146
+
147
+ if __name__ == "__main__":
148
+ main()
ml-services/evaluation/batch_sentence_segments.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Batch sentence segmentation for banking, health, and telecom calls.
3
+
4
+ Reads every word-level transcription JSON from data/na_testset/results/
5
+ for the target domains and produces sentence_segments.json per call under:
6
+
7
+ data/sentence_segments/
8
+ banking/
9
+ en_CA_Banking_1586889.json
10
+ ...
11
+ health/
12
+ en_CA_Health_1587315.json
13
+ ...
14
+ telecom/
15
+ en_CA_Telecom_1590675.json
16
+ ...
17
+
18
+ Each output file has the same schema as frontend/src/sentence_segments.json
19
+ so the frontend and sentiment pipeline can consume any call directly.
20
+
21
+ Usage: python batch_sentence_segments.py [--domains banking health telecom]
22
+ """
23
+ import os
24
+ import json
25
+ import re
26
+ import argparse
27
+ from pathlib import Path
28
+
29
+ import paths
30
+
31
+ # ── config ────────────────────────────────────────────────────────────────────
32
+ RESULTS_ROOT = paths.NA_TESTSET / "results"
33
+ OUTPUT_ROOT = paths.SENTENCE_SEG_ROOT
34
+
35
+ DEFAULT_DOMAINS = {"banking", "health", "telecom"}
36
+
37
+ MIN_DURATION_S = 1.5 # merge non-terminated fragments shorter than this
38
+ GAP_SPLIT_S = 2.0 # silence gap that always starts a new sentence
39
+ MAX_MERGE_GAP = 1.0 # only merge a short fragment if gap from prev ≤ this
40
+
41
+ ABBREVIATIONS = {
42
+ "mr", "mrs", "ms", "dr", "prof", "sr", "jr", "vs", "st",
43
+ "ave", "blvd", "dept", "est", "approx", "etc", "e.g", "i.e",
44
+ "jan", "feb", "mar", "apr", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
45
+ }
46
+ SENTENCE_END = re.compile(r"[.?!]$")
47
+
48
+
49
+ def is_sentence_end(word: str) -> bool:
50
+ if not SENTENCE_END.search(word):
51
+ return False
52
+ base = re.sub(r"[.?!,;:]+$", "", word).lower()
53
+ if base in ABBREVIATIONS:
54
+ return False
55
+ if re.match(r"^[a-z]$", base):
56
+ return False
57
+ return True
58
+
59
+
60
+ def words_to_sentences(words: list, speaker: str) -> list:
61
+ if not words:
62
+ return []
63
+
64
+ raw = []
65
+ bucket = [words[0]]
66
+ for prev_w, curr_w in zip(words, words[1:]):
67
+ gap = curr_w["start"] - prev_w["end"]
68
+ if is_sentence_end(prev_w["word"]) or gap >= GAP_SPLIT_S:
69
+ raw.append(bucket)
70
+ bucket = [curr_w]
71
+ else:
72
+ bucket.append(curr_w)
73
+ if bucket:
74
+ raw.append(bucket)
75
+
76
+ merged = []
77
+ for sent_words in raw:
78
+ start = sent_words[0]["start"]
79
+ end = sent_words[-1]["end"]
80
+ dur = end - start
81
+ text = " ".join(w["word"] for w in sent_words)
82
+ last_word_terminal = SENTENCE_END.search(sent_words[-1]["word"])
83
+
84
+ if merged and dur < MIN_DURATION_S and not last_word_terminal:
85
+ gap_from_prev = start - merged[-1]["end"]
86
+ if gap_from_prev <= MAX_MERGE_GAP:
87
+ prev = merged[-1]
88
+ prev["text"] = prev["text"] + " " + text
89
+ prev["end"] = end
90
+ prev["duration"] = round(prev["end"] - prev["start"], 3)
91
+ continue
92
+
93
+ merged.append({
94
+ "speaker": speaker,
95
+ "start": round(start, 3),
96
+ "end": round(end, 3),
97
+ "duration": round(dur, 3),
98
+ "text": text,
99
+ })
100
+
101
+ for idx, s in enumerate(merged):
102
+ s["id"] = f"{speaker}_{idx + 1:03d}"
103
+
104
+ return merged
105
+
106
+
107
+ def interleave(agent_sents: list, customer_sents: list) -> list:
108
+ combined = sorted(agent_sents + customer_sents, key=lambda s: s["start"])
109
+ for i, s in enumerate(combined):
110
+ s["seq_id"] = i + 1
111
+ return combined
112
+
113
+
114
+ def segment_transcript(data: dict) -> dict:
115
+ """Sentence-segment a loaded per-channel transcript dict (pure, no I/O).
116
+
117
+ Shared with the single-call orchestrator so batch and live paths produce
118
+ byte-identical segmentation from the same word-level transcript.
119
+ """
120
+ agent_sents = words_to_sentences(data.get("agent", []), "AGENT")
121
+ customer_sents = words_to_sentences(data.get("customer", []), "CUSTOMER")
122
+ all_sents = interleave(agent_sents, customer_sents)
123
+
124
+ return {
125
+ "call_id": data["call_id"],
126
+ "accent": data.get("accent"),
127
+ "domain": data.get("domain"),
128
+ "model": data.get("model"),
129
+ "total": len(all_sents),
130
+ "agent_sentences": len(agent_sents),
131
+ "customer_sentences": len(customer_sents),
132
+ "sentences": all_sents,
133
+ }
134
+
135
+
136
+ def segment_call(src_path: Path) -> dict:
137
+ with open(src_path, encoding="utf-8") as f:
138
+ data = json.load(f)
139
+ return segment_transcript(data)
140
+
141
+
142
+ def collect_sources(domains: set) -> list[tuple[str, Path]]:
143
+ """Return (domain_label, src_path) for every matching call."""
144
+ sources = []
145
+ for accent_dir in sorted(RESULTS_ROOT.iterdir()):
146
+ if not accent_dir.is_dir():
147
+ continue
148
+ for json_file in sorted(accent_dir.glob("*.json")):
149
+ stem = json_file.stem # e.g. en_CA_Banking_1586889
150
+ # domain is the part between accent and numeric id
151
+ parts = stem.split("_")
152
+ # find the domain token — the first purely alpha segment after the accent
153
+ # accent = en_CA or en_US_General (variable length)
154
+ # try matching known domains
155
+ stem_lower = stem.lower()
156
+ matched = None
157
+ for d in domains:
158
+ if f"_{d}_" in stem_lower or stem_lower.endswith(f"_{d}"):
159
+ matched = d
160
+ break
161
+ if matched:
162
+ sources.append((matched, json_file))
163
+ return sources
164
+
165
+
166
+ def main():
167
+ ap = argparse.ArgumentParser()
168
+ ap.add_argument("--domains", nargs="+",
169
+ default=sorted(DEFAULT_DOMAINS),
170
+ help="domains to segment (any manifest domain; "
171
+ "default: banking health telecom)")
172
+ args = ap.parse_args()
173
+ domains = set(args.domains)
174
+
175
+ sources = collect_sources(domains)
176
+ if not sources:
177
+ print("No matching calls found. Check RESULTS_ROOT path.")
178
+ return
179
+
180
+ counts = {d: 0 for d in domains}
181
+ total_sentences = 0
182
+
183
+ for domain, src in sources:
184
+ out_dir = OUTPUT_ROOT / domain
185
+ out_dir.mkdir(parents=True, exist_ok=True)
186
+ out_path = out_dir / src.name
187
+
188
+ result = segment_call(src)
189
+ with open(out_path, "w", encoding="utf-8") as f:
190
+ json.dump(result, f, indent=2)
191
+
192
+ counts[domain] += 1
193
+ total_sentences += result["total"]
194
+ print(f" [{domain:8}] {src.stem:<40} "
195
+ f"{result['agent_sentences']:>3}A + {result['customer_sentences']:>3}C "
196
+ f"= {result['total']:>4} sentences -> {out_path.relative_to(OUTPUT_ROOT.parent.parent)}")
197
+
198
+ print()
199
+ print("-" * 72)
200
+ for d in sorted(counts):
201
+ print(f" {d:10} {counts[d]} calls")
202
+ print(f" {'TOTAL':10} {sum(counts.values())} calls · {total_sentences} sentences")
203
+ print(f"\nOutput root: {OUTPUT_ROOT}")
204
+
205
+
206
+ if __name__ == "__main__":
207
+ main()
ml-services/evaluation/benchmark.py ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Model benchmark for call QA extraction.
3
+
4
+ Runs the same structured extraction task across candidate models on two
5
+ contrasting calls, then auto-scores each output on four criteria:
6
+
7
+ 1. Schema validity -- does Pydantic accept it?
8
+ 2. Evidence fidelity -- are quoted strings verbatim substrings of the transcript?
9
+ 3. Score spread -- does the model discriminate between the two calls?
10
+ 4. Conservative bias -- does it avoid PASS without explicit evidence?
11
+
12
+ Produces a ranked comparison table so you can make a principled model
13
+ selection before wiring up routing.
14
+
15
+ Usage:
16
+ python benchmark.py
17
+ python benchmark.py --calls en_CA_Banking_1592237 en_US_General_Health_1587175
18
+ python benchmark.py --providers github mistral cohere
19
+ """
20
+
21
+ import os, json, time, argparse, re
22
+ from typing import Optional
23
+ from pydantic import ValidationError
24
+
25
+ import paths
26
+ from env_util import load_env
27
+ from assemble import load_manifest, build_packet
28
+ from rubric import CallEvaluation, RUBRIC_VERSION
29
+ from llm_client import chat_json
30
+ from extract import SYSTEM, SKELETON, strip_fences
31
+
32
+ load_env()
33
+
34
+ DATA = str(paths.NA_TESTSET)
35
+
36
+ # ── Candidate models (provider, model_override or None for default) ───────────
37
+ CANDIDATES = [
38
+ ("github", None), # GPT-4o-mini
39
+ ("gemini", "gemini-2.0-flash"),
40
+ ("mistral", "mistral-small-latest"),
41
+ ("sambanova", "Meta-Llama-3.3-70B-Instruct"),
42
+ ("sambanova", "DeepSeek-V3.1"),
43
+ ("cohere", "command-r7b-12-2024"),
44
+ ]
45
+
46
+ DEFAULT_CALLS = [
47
+ "en_CA_Banking_1592237", # clean, high-scoring banking call
48
+ "en_CA_Banking_1588683", # weaker banking call
49
+ ]
50
+
51
+
52
+ # ── Auto-scoring helpers ──────────────────────────────────────────────────────
53
+
54
+ def check_schema(raw: str) -> tuple[bool, Optional[str]]:
55
+ """Try to parse + validate. Returns (valid, error_snippet)."""
56
+ try:
57
+ data = json.loads(raw)
58
+ data["rubric_version"] = RUBRIC_VERSION
59
+ data["metadata"] = {"call_id": "x", "domain": "x"}
60
+ CallEvaluation.model_validate(data)
61
+ return True, None
62
+ except (json.JSONDecodeError, ValidationError, Exception) as e:
63
+ return False, str(e)[:120]
64
+
65
+
66
+ def check_evidence_fidelity(raw: str, packet: str) -> tuple[int, int]:
67
+ """Count evidence quotes that are verbatim substrings of the transcript.
68
+ Returns (n_verbatim, n_total)."""
69
+ try:
70
+ data = json.loads(raw)
71
+ except Exception:
72
+ return 0, 0
73
+
74
+ # collect all quote fields recursively
75
+ quotes = []
76
+ def harvest(obj):
77
+ if isinstance(obj, dict):
78
+ if "quote" in obj and isinstance(obj["quote"], str):
79
+ quotes.append(obj["quote"])
80
+ for v in obj.values():
81
+ harvest(v)
82
+ elif isinstance(obj, list):
83
+ for item in obj:
84
+ harvest(item)
85
+ harvest(data)
86
+
87
+ if not quotes:
88
+ return 0, 0
89
+
90
+ # normalise packet to lowercase for matching (avoid case mismatches)
91
+ packet_lower = packet.lower()
92
+ n_verbatim = sum(1 for q in quotes if q.lower().strip() in packet_lower)
93
+ return n_verbatim, len(quotes)
94
+
95
+
96
+ def check_conservative_bias(raw: str) -> tuple[int, int]:
97
+ """Count compliance items marked PASS and how many have evidence attached.
98
+ Returns (n_pass_with_evidence, n_pass_total)."""
99
+ try:
100
+ data = json.loads(raw)
101
+ except Exception:
102
+ return 0, 0
103
+
104
+ compliance = data.get("compliance", {})
105
+ n_pass = n_with_ev = 0
106
+ for key, item in compliance.items():
107
+ if not isinstance(item, dict):
108
+ continue
109
+ if item.get("passed") is True:
110
+ n_pass += 1
111
+ if item.get("evidence") and item["evidence"].get("quote"):
112
+ n_with_ev += 1
113
+ return n_with_ev, n_pass
114
+
115
+
116
+ def quality_scores(raw: str) -> list[int]:
117
+ """Extract the 5 quality scores as a list."""
118
+ try:
119
+ data = json.loads(raw)
120
+ q = data.get("quality", {})
121
+ return [q.get(k, {}).get("score", 0)
122
+ for k in ("efficiency", "problem_resolution", "clarity",
123
+ "professionalism", "empathy")]
124
+ except Exception:
125
+ return []
126
+
127
+
128
+ # ── Main ──────────────────────────────────────────────────────────────────────
129
+
130
+ def run_benchmark(calls: list[str], providers: Optional[list[str]]):
131
+ manifest = load_manifest()
132
+
133
+ candidates = CANDIDATES
134
+ if providers:
135
+ candidates = [(p, m) for p, m in CANDIDATES if p in providers]
136
+
137
+ # pre-build packets
138
+ packets = {}
139
+ for cid in calls:
140
+ meta = manifest[cid]
141
+ result_path = os.path.join(
142
+ DATA, "results_channels", meta["accent"], cid + ".json")
143
+ with open(result_path, encoding="utf-8") as f:
144
+ result = json.load(f)
145
+ packet, _ = build_packet(result, meta)
146
+ packets[cid] = packet
147
+
148
+ # results[provider_model][call_id] = {raw, scores_dict, timing}
149
+ rows = []
150
+
151
+ print(f"\nBenchmarking {len(candidates)} models × {len(calls)} calls\n")
152
+
153
+ for provider, model_override in candidates:
154
+ label = f"{provider}/{model_override or 'default'}"
155
+ row = {"label": label, "provider": provider,
156
+ "model": model_override, "calls": {}}
157
+
158
+ for cid in calls:
159
+ packet = packets[cid]
160
+ user = f"{packet}\n\n{SKELETON}"
161
+ t0 = time.time()
162
+ try:
163
+ raw = chat_json(provider, SYSTEM, user,
164
+ model=model_override, max_tokens=4000)
165
+ elapsed = time.time() - t0
166
+
167
+ valid, err = check_schema(raw)
168
+ n_verb, n_total = check_evidence_fidelity(raw, packet)
169
+ n_pass_ev, n_pass = check_conservative_bias(raw)
170
+ scores = quality_scores(raw)
171
+
172
+ row["calls"][cid] = {
173
+ "ok": valid,
174
+ "elapsed": elapsed,
175
+ "schema": valid,
176
+ "fidelity": (n_verb, n_total),
177
+ "conserv": (n_pass_ev, n_pass),
178
+ "scores": scores,
179
+ "err": err,
180
+ "raw": raw,
181
+ }
182
+ status = "OK " if valid else "ERR"
183
+ fid = f"{n_verb}/{n_total}" if n_total else "--"
184
+ con = f"{n_pass_ev}/{n_pass}" if n_pass else "--"
185
+ sc = ",".join(str(s) for s in scores) if scores else "--"
186
+ print(f" [{status}] {label:<38} {cid[-10:]:<14} "
187
+ f"{elapsed:5.1f}s fidelity={fid} conserv={con} "
188
+ f"scores=[{sc}]")
189
+
190
+ except Exception as e:
191
+ elapsed = time.time() - t0
192
+ row["calls"][cid] = {
193
+ "ok": False, "elapsed": elapsed,
194
+ "err": str(e)[:120], "raw": ""}
195
+ print(f" [FAIL] {label:<38} {cid[-10:]:<14} "
196
+ f"{elapsed:5.1f}s {str(e)[:80]}")
197
+
198
+ rows.append(row)
199
+
200
+ # ── Summary table ─────────────────────────────────────────────────────────
201
+ print(f"\n{'='*90}")
202
+ print(f" BENCHMARK SUMMARY ({len(calls)} calls)")
203
+ print(f"{'='*90}")
204
+ print(f" {'Model':<38} {'Valid':>5} {'Fidelity':>9} {'Conserv':>8} "
205
+ f"{'Spread':>7} {'Avg ms':>8}")
206
+ print(f" {'-'*85}")
207
+
208
+ scored = []
209
+ for row in rows:
210
+ valid_calls = [v for v in row["calls"].values() if v.get("ok")]
211
+ n_valid = len(valid_calls)
212
+
213
+ # fidelity: avg across calls
214
+ fid_nums = [(v["fidelity"][0], v["fidelity"][1])
215
+ for v in valid_calls if v.get("fidelity", (0,0))[1] > 0]
216
+ fid_str = (f"{sum(x[0] for x in fid_nums)}/{sum(x[1] for x in fid_nums)}"
217
+ if fid_nums else "--")
218
+
219
+ # conservatism: evidence on PASS items
220
+ con_nums = [(v["conserv"][0], v["conserv"][1])
221
+ for v in valid_calls if v.get("conserv", (0,0))[1] > 0]
222
+ con_str = (f"{sum(x[0] for x in con_nums)}/{sum(x[1] for x in con_nums)}"
223
+ if con_nums else "--")
224
+
225
+ # score spread: std-dev of scores across calls (higher = more discriminating)
226
+ all_scores = [s for v in valid_calls for s in v.get("scores", [])]
227
+ if len(all_scores) >= 2:
228
+ mean = sum(all_scores) / len(all_scores)
229
+ spread = (sum((s - mean)**2 for s in all_scores) / len(all_scores)) ** 0.5
230
+ spread_str = f"{spread:.2f}"
231
+ else:
232
+ spread = 0.0
233
+ spread_str = "--"
234
+
235
+ avg_ms = (sum(v["elapsed"] for v in row["calls"].values()) /
236
+ len(row["calls"]) * 1000) if row["calls"] else 0
237
+
238
+ print(f" {row['label']:<38} {n_valid}/{len(calls):>3} "
239
+ f"{fid_str:>9} {con_str:>8} {spread_str:>7} {avg_ms:>7.0f}ms")
240
+
241
+ scored.append((row["label"], n_valid, fid_nums, con_nums, spread, avg_ms))
242
+
243
+ # ── Recommendation ────────────────────────────────────────────────────────
244
+ print(f"\n COLUMN GUIDE:")
245
+ print(f" Valid = schema-valid outputs / total calls")
246
+ print(f" Fidelity = verbatim quotes found in transcript / total quotes cited")
247
+ print(f" Conserv = PASS items with supporting evidence / total PASS items")
248
+ print(f" Spread = score std-dev across calls (higher = more discriminating)")
249
+ print(f" Avg ms = average latency per call")
250
+ print(f"\n Best fidelity = quotes least likely to be hallucinated")
251
+ print(f" Best conserv = least likely to grant unearned compliance passes")
252
+ print(f" Best spread = actually differentiates call quality")
253
+ print(f"{'='*90}")
254
+
255
+ # save raw results
256
+ out = os.path.join(os.path.dirname(os.path.abspath(__file__)),
257
+ "benchmark_results.json")
258
+ with open(out, "w", encoding="utf-8") as f:
259
+ # strip raw transcripts from saved output to keep file small
260
+ clean = []
261
+ for row in rows:
262
+ r = dict(row)
263
+ r["calls"] = {cid: {k: v for k, v in cv.items() if k != "raw"}
264
+ for cid, cv in row["calls"].items()}
265
+ clean.append(r)
266
+ json.dump(clean, f, indent=2)
267
+ print(f"\n Full results saved -> {out}")
268
+
269
+
270
+ def main():
271
+ ap = argparse.ArgumentParser()
272
+ ap.add_argument("--calls", nargs="+", default=DEFAULT_CALLS)
273
+ ap.add_argument("--providers", nargs="+", default=None,
274
+ help="filter to these providers only")
275
+ args = ap.parse_args()
276
+ run_benchmark(args.calls, args.providers)
277
+
278
+
279
+ if __name__ == "__main__":
280
+ main()
ml-services/evaluation/consistency.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Consistency harness for the graph evaluation pipeline.
3
+
4
+ A scoring system that produces different results on the same input across
5
+ runs is not dependable. This harness runs the SAME call through the graph N
6
+ times and measures agreement:
7
+
8
+ - compliance : per-check agreement rate (fraction of runs matching the
9
+ modal pass/fail/null verdict)
10
+ - quality : per-dimension score min / max / range / mean / stdev
11
+ - escalation : risk_level + emotion agreement rate
12
+ - anchoring : evidence anchor rate per run
13
+
14
+ Usage:
15
+ python consistency.py --call_id en_CA_Banking_1586889 --runs 5
16
+ """
17
+ import os
18
+ import json
19
+ import time
20
+ import argparse
21
+ import statistics
22
+ from collections import Counter
23
+
24
+ from graph import build_graph
25
+
26
+ COMPLIANCE_KEYS = [
27
+ "name_announced", "company_announced", "recording_disclosure",
28
+ "identity_verified", "resolution_provided", "transfer_next_steps",
29
+ ]
30
+ QUALITY_KEYS = [
31
+ "efficiency", "problem_resolution", "clarity",
32
+ "professionalism", "empathy", "customer_satisfaction",
33
+ ]
34
+
35
+
36
+ def agreement(values):
37
+ """Fraction of values matching the mode. 1.0 = perfect agreement."""
38
+ if not values:
39
+ return None
40
+ most_common = Counter(values).most_common(1)[0][1]
41
+ return most_common / len(values)
42
+
43
+
44
+ def run_sweep(call_id, runs, results_dir):
45
+ graph = build_graph()
46
+ evaluations = []
47
+
48
+ for i in range(1, runs + 1):
49
+ print(f"\n--- run {i}/{runs} " + "-" * 50)
50
+ t0 = time.time()
51
+ final = graph.invoke({"call_id": call_id, "results_dir": results_dir})
52
+ dt = time.time() - t0
53
+ ev = final["evaluation"]
54
+ evaluations.append(ev)
55
+ stats = ev.get("_anchor_stats", {})
56
+ print(f"--- run {i} done in {dt:.1f}s "
57
+ f"(anchored {stats.get('anchored')}/{stats.get('total')})")
58
+
59
+ return evaluations
60
+
61
+
62
+ def analyze(evaluations):
63
+ report = {"n_runs": len(evaluations), "compliance": {}, "quality": {},
64
+ "escalation": {}, "anchoring": {}}
65
+
66
+ # compliance agreement per check
67
+ for key in COMPLIANCE_KEYS:
68
+ verdicts = [str(ev["compliance"][key]["passed"]) for ev in evaluations]
69
+ report["compliance"][key] = {
70
+ "verdicts": verdicts,
71
+ "agreement": agreement(verdicts),
72
+ }
73
+
74
+ # quality score variance per dimension
75
+ for key in QUALITY_KEYS:
76
+ scores = [ev["quality"][key]["score"] for ev in evaluations
77
+ if ev["quality"].get(key)]
78
+ if not scores:
79
+ continue
80
+ report["quality"][key] = {
81
+ "scores": scores,
82
+ "min": min(scores),
83
+ "max": max(scores),
84
+ "range": max(scores) - min(scores),
85
+ "mean": round(statistics.mean(scores), 2),
86
+ "stdev": round(statistics.stdev(scores), 3) if len(scores) > 1 else 0.0,
87
+ }
88
+
89
+ # escalation agreement
90
+ risks = [ev["escalation"]["risk_level"] for ev in evaluations]
91
+ emotions = [ev["escalation"]["customer_emotion_text"] for ev in evaluations]
92
+ report["escalation"] = {
93
+ "risk_levels": risks,
94
+ "risk_agreement": agreement(risks),
95
+ "emotions": emotions,
96
+ "emotion_agreement": agreement(emotions),
97
+ }
98
+
99
+ # anchor rates
100
+ rates = []
101
+ for ev in evaluations:
102
+ s = ev.get("_anchor_stats", {})
103
+ if s.get("total"):
104
+ rates.append(round(s["anchored"] / s["total"], 3))
105
+ report["anchoring"] = {"rates": rates}
106
+
107
+ return report
108
+
109
+
110
+ def print_report(report, call_id):
111
+ n = report["n_runs"]
112
+ print("\n" + "=" * 68)
113
+ print(f" CONSISTENCY REPORT {call_id} ({n} runs)")
114
+ print("=" * 68)
115
+
116
+ print("\n COMPLIANCE (agreement with modal verdict)")
117
+ worst_c = 1.0
118
+ for key, r in report["compliance"].items():
119
+ a = r["agreement"]
120
+ worst_c = min(worst_c, a)
121
+ flag = "" if a == 1.0 else f" <-- UNSTABLE {r['verdicts']}"
122
+ print(f" {a * 100:5.1f}% {key}{flag}")
123
+
124
+ print("\n QUALITY (score spread across runs)")
125
+ worst_range = 0
126
+ for key, r in report["quality"].items():
127
+ worst_range = max(worst_range, r["range"])
128
+ flag = "" if r["range"] <= 1 else " <-- UNSTABLE"
129
+ print(f" {key:<24} scores={r['scores']} "
130
+ f"range={r['range']} stdev={r['stdev']}{flag}")
131
+
132
+ e = report["escalation"]
133
+ print(f"\n ESCALATION risk agreement: {e['risk_agreement'] * 100:.1f}% "
134
+ f"{e['risk_levels']}")
135
+ print(f" emotion agreement: {e['emotion_agreement'] * 100:.1f}% "
136
+ f"{e['emotions']}")
137
+
138
+ rates = report["anchoring"]["rates"]
139
+ if rates:
140
+ print(f"\n ANCHOR RATES per run: {rates}")
141
+
142
+ print("\n VERDICT: ", end="")
143
+ if worst_c == 1.0 and worst_range <= 1 and e["risk_agreement"] == 1.0:
144
+ print("stable (all compliance unanimous, quality range <= 1, "
145
+ "risk unanimous)")
146
+ else:
147
+ print("instability detected -- see UNSTABLE flags above")
148
+ print("=" * 68)
149
+
150
+
151
+ def main():
152
+ ap = argparse.ArgumentParser(
153
+ description="Run the graph N times on one call, measure agreement")
154
+ ap.add_argument("--call_id", required=True)
155
+ ap.add_argument("--runs", type=int, default=5)
156
+ ap.add_argument("--results_dir", default="results_channels")
157
+ args = ap.parse_args()
158
+
159
+ evaluations = run_sweep(args.call_id, args.runs, args.results_dir)
160
+ report = analyze(evaluations)
161
+ print_report(report, args.call_id)
162
+
163
+ here = os.path.dirname(os.path.abspath(__file__))
164
+ out = os.path.join(here, "results", f"{args.call_id}_consistency.json")
165
+ with open(out, "w", encoding="utf-8") as f:
166
+ json.dump(report, f, indent=2)
167
+ print(f"\nSaved -> {out}")
168
+
169
+
170
+ if __name__ == "__main__":
171
+ main()
ml-services/evaluation/env_util.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """Minimal .env loader (no external dependency).
2
+
3
+ The actual loading lives in paths.py (which also runs it at import time so
4
+ CAPSTONE_* path overrides in .env take effect before any path constant is
5
+ read). This re-export keeps the existing call sites working unchanged.
6
+ """
7
+ from paths import load_env # noqa: F401
ml-services/evaluation/eval_call_data.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Run the BASELINE evaluator (extract.py's rubric + router) on the frontend's
3
+ call_data.json, then ANCHOR every evidence quote to a real timestamp by locating
4
+ it in the transcript turns (don't trust the LLM's timestamp -- same robustness
5
+ principle as chaptering).
6
+
7
+ Merges an "evaluation" block into frontend/src/call_data.json so the React
8
+ compliance panel can light up each check at the moment its evidence occurs.
9
+
10
+ Usage: python eval_call_data.py
11
+ """
12
+ import os, json, time
13
+ import paths
14
+ from env_util import load_env
15
+ from extract import SYSTEM, SKELETON, strip_fences
16
+ from router import chat_json_routed
17
+ from rubric import CallEvaluation, RUBRIC_VERSION
18
+
19
+ load_env()
20
+ CALL_DATA = str(paths.FRONTEND_ROOT / "src" / "call_data.json")
21
+
22
+
23
+ def mmss(s):
24
+ return f"{int(s // 60):02d}:{int(s % 60):02d}"
25
+
26
+
27
+ def build_packet(turns, duration, domain="banking"):
28
+ header = (f"CALL METADATA: domain={domain} | duration={mmss(duration)} "
29
+ f"| turns={len(turns)}\n\nROLE-MAPPED TRANSCRIPT:\n")
30
+ body = "\n".join(f"[{t['speaker']} {mmss(t['start'])}] {t['text']}" for t in turns)
31
+ return header + body
32
+
33
+
34
+ def _norm(s):
35
+ return " ".join(s.lower().split())
36
+
37
+
38
+ def anchor(quote, turns):
39
+ """Locate a quote in the transcript -> (start_sec, turn_index). None if unfound."""
40
+ if not quote:
41
+ return None, None
42
+ q = _norm(quote)
43
+ for t in turns:
44
+ if q and q in _norm(t["text"]):
45
+ return t["start"], t["i"]
46
+ # fuzzy fallback: first 6 words of the quote
47
+ frag = " ".join(q.split()[:6])
48
+ if frag:
49
+ for t in turns:
50
+ if frag in _norm(t["text"]):
51
+ return t["start"], t["i"]
52
+ return None, None
53
+
54
+
55
+ def anchor_evidence(obj, turns, stats):
56
+ """Recursively attach 'sec' + 'turn' to every evidence object with a quote."""
57
+ if isinstance(obj, dict):
58
+ if isinstance(obj.get("quote"), str) and obj["quote"]:
59
+ sec, ti = anchor(obj["quote"], turns)
60
+ obj["sec"] = sec
61
+ obj["turn"] = ti
62
+ stats["total"] += 1
63
+ if sec is not None:
64
+ stats["anchored"] += 1
65
+ for v in obj.values():
66
+ anchor_evidence(v, turns, stats)
67
+ elif isinstance(obj, list):
68
+ for item in obj:
69
+ anchor_evidence(item, turns, stats)
70
+
71
+
72
+ def main():
73
+ with open(CALL_DATA, encoding="utf-8") as f:
74
+ cd = json.load(f)
75
+ turns = cd["turns"]
76
+ duration = cd["duration"]
77
+
78
+ packet = build_packet(turns, duration)
79
+ user = f"{packet}\n\n{SKELETON}"
80
+
81
+ t0 = time.time()
82
+ raw, served = chat_json_routed(SYSTEM, user, max_tokens=4000, return_meta=True)
83
+ dt = time.time() - t0
84
+
85
+ data = json.loads(strip_fences(raw))
86
+ data["rubric_version"] = RUBRIC_VERSION
87
+ data["metadata"] = {"call_id": cd.get("call", "call_1"), "domain": "banking",
88
+ "duration_seconds": duration,
89
+ "transcript_model": cd.get("model")}
90
+ ev = CallEvaluation.model_validate(data)
91
+ ev_dict = ev.model_dump()
92
+
93
+ stats = {"total": 0, "anchored": 0}
94
+ anchor_evidence(ev_dict, turns, stats)
95
+ ev_dict["served_by"] = served
96
+
97
+ cd["evaluation"] = ev_dict
98
+ with open(CALL_DATA, "w", encoding="utf-8") as f:
99
+ json.dump(cd, f, indent=2)
100
+
101
+ # ── report ────────────────────────────────────────────────────────────────
102
+ print(f"\nevaluated by {served} in {dt:.1f}s | "
103
+ f"evidence anchored {stats['anchored']}/{stats['total']}")
104
+ print("=" * 64)
105
+ print(" COMPLIANCE")
106
+ c = ev_dict["compliance"]
107
+ for key, name in [("name_announced", "Name announced"),
108
+ ("company_announced", "Company announced"),
109
+ ("recording_disclosure", "Recording disclosure"),
110
+ ("identity_verified", "Identity verified"),
111
+ ("resolution_provided", "Resolution provided"),
112
+ ("transfer_next_steps", "Transfer next-steps")]:
113
+ item = c[key]
114
+ mark = {True: "PASS", False: "FAIL", None: "N/A "}[item["passed"]]
115
+ evd = item.get("evidence")
116
+ when = f" @{mmss(evd['sec'])}" if evd and evd.get("sec") is not None else ""
117
+ quote = f' "{evd["quote"][:48]}"' if evd else (f" ({item.get('note')})" if item.get("note") else "")
118
+ print(f" [{mark}]{when:>7} {name:<22}{quote}")
119
+ if c.get("identity_method"):
120
+ print(f" method: {c['identity_method']}")
121
+
122
+ print("\n QUALITY")
123
+ q = ev_dict["quality"]
124
+ for key, name in [("efficiency", "Efficiency"), ("problem_resolution", "Problem resolution"),
125
+ ("clarity", "Clarity"), ("professionalism", "Professionalism"),
126
+ ("empathy", "Empathy")]:
127
+ d = q[key]
128
+ aud = " (+audio)" if d["requires_audio"] else ""
129
+ print(f" {d['score']}/5 {name:<20}{aud}")
130
+
131
+ e = ev_dict["escalation"]
132
+ print(f"\n ESCALATION: {e['risk_level'].upper()} | emotion: {e['customer_emotion_text']}"
133
+ f" | flags: {', '.join(e['red_flags']) if e['red_flags'] else 'none'}")
134
+ print(f"\n SUMMARY: {ev_dict.get('overall_summary')}")
135
+ print("=" * 64)
136
+ print(f"\nMerged 'evaluation' into {CALL_DATA}")
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()
ml-services/evaluation/export_frontend.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Export every evaluated call into the frontend's per-call format + a merged
3
+ playable audio file, plus a summary index for the dashboard.
4
+
5
+ For each call_id with a results/{call_id}_graph.json:
6
+ - turns rebuilt from the per-channel word-level transcript, using the
7
+ same word-grouping logic as gen_call_data.py::build_turns
8
+ (assemble.py's assemble_turns() drops the per-turn `end` field
9
+ the frontend needs, so it isn't reused here)
10
+ - chapters from segment.segment() (one LLM call per call -- chapters were
11
+ never persisted anywhere), converted from its start_turn/mm:ss
12
+ shape into the numeric start/end-seconds shape the frontend
13
+ player expects
14
+ - evaluation the existing results/{call_id}_graph.json content, verbatim
15
+ - audio agent+customer wav merged into one 2-channel mp3 via ffmpeg
16
+ (ch0=agent, ch1=customer -- same convention as the original
17
+ call_1 demo audio)
18
+
19
+ Writes (served statically, fetched at runtime -- not bundled into the JS build):
20
+ frontend/public/calls/{call_id}.json {call, model, duration, turns, chapters, evaluation}
21
+ frontend/public/calls_index.json [{call_id, domain, ...summary}, ...]
22
+ frontend/public/audio/{call_id}.mp3
23
+
24
+ The demo call (en_CA_Banking_1586889) is copied verbatim from the existing
25
+ call_data.json instead of being regenerated -- it already carries the
26
+ recheck-node bugfix's hand-verified evidence patch.
27
+
28
+ Usage: python export_frontend.py
29
+ """
30
+ import os
31
+ import json
32
+ import shutil
33
+ import subprocess
34
+
35
+ import paths
36
+ from assemble import load_manifest, DATA
37
+ from segment import segment
38
+
39
+ EVAL_DIR = os.path.dirname(os.path.abspath(__file__))
40
+ RESULTS_DIR_NAME = "results"
41
+
42
+ PUBLIC = str(paths.FRONTEND_PUBLIC)
43
+ CALLS_OUT = os.path.join(PUBLIC, "calls")
44
+ AUDIO_OUT = os.path.join(PUBLIC, "audio")
45
+ INDEX_OUT = os.path.join(PUBLIC, "calls_index.json")
46
+
47
+ DEMO_CALL = "en_CA_Banking_1586889"
48
+ DEMO_CALL_DATA = str(paths.FRONTEND_ROOT / "src" / "call_data.json")
49
+ DEMO_AUDIO_SRC = os.path.join(PUBLIC, "call_1.mp3")
50
+
51
+
52
+ def build_turns(result):
53
+ """Merge agent+customer words into time-ordered speaker turns."""
54
+ merged = []
55
+ for speaker, key in (("AGENT", "agent"), ("CUSTOMER", "customer")):
56
+ for w in result.get(key, []):
57
+ if w.get("start") is None:
58
+ continue
59
+ merged.append((w["start"], w.get("end", w["start"]), speaker, w["word"]))
60
+ merged.sort(key=lambda x: x[0])
61
+
62
+ turns = []
63
+ cur = None
64
+ for start, end, sp, word in merged:
65
+ if cur is None or sp != cur["speaker"]:
66
+ if cur:
67
+ turns.append(cur)
68
+ cur = {"speaker": sp, "text": word, "start": start, "end": end}
69
+ else:
70
+ cur["text"] += " " + word
71
+ cur["end"] = end
72
+ if cur:
73
+ turns.append(cur)
74
+
75
+ for i, t in enumerate(turns):
76
+ t["i"] = i
77
+ t["start"] = round(t["start"], 2)
78
+ t["end"] = round(t["end"], 2)
79
+ return turns
80
+
81
+
82
+ def numeric_chapters(cc_chapters, turns, duration):
83
+ """Convert segment.py's start_turn/mm:ss chapters into numeric seconds."""
84
+ chapters = []
85
+ for i, ch in enumerate(cc_chapters):
86
+ start = turns[ch.start_turn]["start"]
87
+ end = (turns[cc_chapters[i + 1].start_turn]["start"]
88
+ if i + 1 < len(cc_chapters) else duration)
89
+ chapters.append({"index": ch.index, "label": ch.label,
90
+ "summary": ch.summary, "start": round(start, 2),
91
+ "end": round(end, 2), "start_turn": ch.start_turn})
92
+ return chapters
93
+
94
+
95
+ def merge_audio(agent_wav, customer_wav, out_mp3):
96
+ os.makedirs(os.path.dirname(out_mp3), exist_ok=True)
97
+ proc = subprocess.run([
98
+ "ffmpeg", "-y", "-i", agent_wav, "-i", customer_wav,
99
+ "-filter_complex", "[0:a][1:a]amerge=inputs=2[a]",
100
+ "-map", "[a]", "-ac", "2", "-b:a", "128k", out_mp3,
101
+ ], capture_output=True, text=True)
102
+ if proc.returncode != 0:
103
+ print(f" ffmpeg FAILED for {out_mp3}:\n{proc.stderr[-800:]}")
104
+ return False
105
+ return True
106
+
107
+
108
+ def export_call_artifacts(call_id, meta, out_calls_dir, out_audio_dir):
109
+ """Build one call's frontend artifacts into the given output dirs.
110
+
111
+ Writes {call_id}.json (turns + chapters + evaluation) and the merged
112
+ 2-channel mp3. Reads the graph evaluation and the per-channel transcript
113
+ already on disk; runs one chapter LLM call. Channel WAVs are expected at
114
+ the dataset convention DATA/{accent}/{call_id}_{agent,customer}.wav (the
115
+ orchestrator places fresh-call channels there before calling this).
116
+ Returns the call dict, which summarize() turns into a dashboard index row.
117
+ """
118
+ eval_path = os.path.join(EVAL_DIR, "results", f"{call_id}_graph.json")
119
+ with open(eval_path, encoding="utf-8") as f:
120
+ evaluation = json.load(f)
121
+ duration = evaluation["metadata"]["duration_seconds"]
122
+
123
+ result_path = os.path.join(DATA, RESULTS_DIR_NAME, meta["accent"], call_id + ".json")
124
+ with open(result_path, encoding="utf-8") as f:
125
+ result = json.load(f)
126
+ turns = build_turns(result)
127
+
128
+ cc, dt, issues = segment(call_id, provider="router", results_dir=RESULTS_DIR_NAME)
129
+ if issues:
130
+ print(f" chapter issues: {issues}")
131
+ chapters = numeric_chapters(cc.chapters, turns, duration)
132
+
133
+ out = {
134
+ "call": call_id,
135
+ "model": result.get("model", ""),
136
+ "duration": round(duration, 2),
137
+ "turns": turns,
138
+ "chapters": chapters,
139
+ "evaluation": evaluation,
140
+ }
141
+ os.makedirs(out_calls_dir, exist_ok=True)
142
+ with open(os.path.join(out_calls_dir, f"{call_id}.json"), "w", encoding="utf-8") as f:
143
+ json.dump(out, f, indent=2)
144
+
145
+ ok = merge_audio(
146
+ os.path.join(DATA, meta["accent"], f"{call_id}_agent.wav"),
147
+ os.path.join(DATA, meta["accent"], f"{call_id}_customer.wav"),
148
+ os.path.join(out_audio_dir, f"{call_id}.mp3"),
149
+ )
150
+ print(f" [{call_id}] {len(turns)} turns, {len(chapters)} chapters, "
151
+ f"audio {'ok' if ok else 'FAILED'}")
152
+ return out
153
+
154
+
155
+ def export_call(call_id, manifest):
156
+ return export_call_artifacts(call_id, manifest[call_id], CALLS_OUT, AUDIO_OUT)
157
+
158
+
159
+ def summarize(call_id, out):
160
+ ev = out["evaluation"]
161
+ c = ev.get("compliance", {})
162
+ passed = sum(1 for v in c.values() if isinstance(v, dict) and v.get("passed") is True)
163
+ failed = sum(1 for v in c.values() if isinstance(v, dict) and v.get("passed") is False)
164
+ wf = ev.get("workflow") or {}
165
+ steps = wf.get("expected_steps", [])
166
+ esc = ev.get("escalation", {})
167
+ meta = ev.get("metadata", {})
168
+ return {
169
+ "call_id": call_id,
170
+ "domain": meta.get("domain"),
171
+ "accent": meta.get("accent"),
172
+ "duration": out["duration"],
173
+ "compliance_passed": passed,
174
+ "compliance_applicable": passed + failed,
175
+ "workflow_met": sum(1 for s in steps if s.get("met") is True),
176
+ "workflow_total": len(steps),
177
+ "risk_level": esc.get("risk_level"),
178
+ "subject": wf.get("subject"),
179
+ }
180
+
181
+
182
+ def main():
183
+ manifest = load_manifest()
184
+ call_ids = sorted(
185
+ f[:-len("_graph.json")]
186
+ for f in os.listdir(os.path.join(EVAL_DIR, "results"))
187
+ if f.endswith("_graph.json")
188
+ )
189
+
190
+ index = []
191
+
192
+ with open(DEMO_CALL_DATA, encoding="utf-8") as f:
193
+ demo = json.load(f)
194
+ os.makedirs(CALLS_OUT, exist_ok=True)
195
+ with open(os.path.join(CALLS_OUT, f"{DEMO_CALL}.json"), "w", encoding="utf-8") as f:
196
+ json.dump(demo, f, indent=2)
197
+ os.makedirs(AUDIO_OUT, exist_ok=True)
198
+ shutil.copy(DEMO_AUDIO_SRC, os.path.join(AUDIO_OUT, f"{DEMO_CALL}.mp3"))
199
+ index.append(summarize(DEMO_CALL, demo))
200
+ print(f"[{DEMO_CALL}] copied verbatim (demo call, already patched)")
201
+
202
+ for call_id in call_ids:
203
+ if call_id == DEMO_CALL:
204
+ continue
205
+ out = export_call(call_id, manifest)
206
+ index.append(summarize(call_id, out))
207
+
208
+ with open(INDEX_OUT, "w", encoding="utf-8") as f:
209
+ json.dump(index, f, indent=2)
210
+ print(f"\nExported {len(index)} calls -> {CALLS_OUT}")
211
+ print(f"Index -> {INDEX_OUT}")
212
+
213
+
214
+ if __name__ == "__main__":
215
+ main()
ml-services/evaluation/extract.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Phase 1 -- Monolithic Structured Extraction.
3
+
4
+ One LLM call turns the role-mapped transcript packet into a validated
5
+ CallEvaluation JSON (compliance booleans + quality scores + escalation), every
6
+ judgment backed by a verbatim quote. Pydantic validates; one retry on failure.
7
+
8
+ MODALITY DISCIPLINE is enforced in the prompt: the text model judges only what
9
+ text can prove, and flags acoustic items requires_audio rather than guessing.
10
+
11
+ Usage:
12
+ python extract.py --call_id en_CA_Banking_1592237 [--provider github] [--model openai/gpt-4o]
13
+ """
14
+ import os, json, argparse, time
15
+ from pydantic import ValidationError
16
+
17
+ import paths
18
+ from env_util import load_env
19
+ from assemble import load_manifest, build_packet
20
+ from rubric import CallEvaluation, CallMetadata, RUBRIC_VERSION
21
+ from llm_client import chat_json
22
+
23
+ load_env()
24
+ DATA = str(paths.NA_TESTSET)
25
+
26
+ SYSTEM = f"""You are a meticulous call-center QA analyst. You evaluate a single customer-service call against a fixed rubric and output ONLY a JSON object. Rubric version {RUBRIC_VERSION}.
27
+
28
+ You are given a ROLE-MAPPED transcript: every line is tagged [AGENT mm:ss] or [CUSTOMER mm:ss]. The channel identity is authoritative -- never attribute the agent's words to the customer or vice versa.
29
+
30
+ EVALUATE THESE ITEMS:
31
+
32
+ COMPLIANCE (passed = true / false / null). null ONLY when the item does not apply to this call.
33
+ - name_announced: agent stated their own name.
34
+ - company_announced: agent stated the company/bank name.
35
+ - recording_disclosure: agent said the call may be recorded/monitored. If never stated, passed=false (do NOT assume). Use null only if clearly not required.
36
+ - identity_verified: agent verified the customer BEFORE discussing the account (DOB, last 4 digits, account number, customer ID). Set identity_method to the method used.
37
+ - resolution_provided: agent gave a resolution or clear next steps before closing.
38
+ - transfer_next_steps: if the call was transferred, agent explained next steps. passed=null if there was no transfer.
39
+
40
+ QUALITY (score 1-5 from TEXT signals; list signals_present / signals_absent):
41
+ - efficiency: first-call resolution, no unnecessary holds/transfers.
42
+ - problem_resolution: offered options, explained pros/cons and WHY, respected customer choice, solved the actual issue.
43
+ - clarity: open-ended questions, confirms understanding ("does that make sense?"), recap at end.
44
+ - professionalism: grammar, complete sentences, no slang. (Tone/pace are ACOUSTIC -> set requires_audio=true.)
45
+ - empathy: validation phrases ("I understand your frustration"), uses customer name, acknowledges concern. (Warmth/flat tone are ACOUSTIC -> set requires_audio=true.)
46
+
47
+ ESCALATION:
48
+ - red_flags: any of manager_requested, competitor_switch, repeat_attempts, issue_too_complex, explicit_dissatisfaction.
49
+ - customer_emotion_text: infer from WORDS only (calm/mild_frustration/frustrated/angry/distressed).
50
+ - risk_level: none / review / escalate.
51
+
52
+ HARD RULES:
53
+ 1. EVIDENCE: every compliance true/false and every quality score must cite a verbatim quote copied EXACTLY from the transcript, with speaker and timestamp. Never invent quotes.
54
+ 2. MODALITY: judge ONLY from text. For acoustic qualities (tone, pace, energy, whether the customer "sounds" satisfied) do not guess -- set requires_audio=true and score from text signals alone.
55
+ 3. CONSERVATISM: do not mark a compliance item true unless the transcript explicitly shows it.
56
+ 4. OUTPUT: a single JSON object, no markdown, no commentary."""
57
+
58
+ SKELETON = """Return EXACTLY this JSON shape (fill values; keep keys):
59
+ {
60
+ "compliance": {
61
+ "name_announced": {"passed": true, "evidence": {"quote": "...", "speaker": "AGENT", "timestamp": "00:12"}, "note": null},
62
+ "company_announced": {"passed": true, "evidence": {"quote": "...", "speaker": "AGENT", "timestamp": "00:12"}, "note": null},
63
+ "recording_disclosure":{"passed": false, "evidence": null, "note": "never stated"},
64
+ "identity_verified": {"passed": true, "evidence": {"quote": "...", "speaker": "AGENT", "timestamp": "00:42"}, "note": null},
65
+ "identity_method": "date of birth",
66
+ "resolution_provided": {"passed": true, "evidence": {"quote": "...", "speaker": "AGENT", "timestamp": "10:07"}, "note": null},
67
+ "transfer_next_steps": {"passed": null, "evidence": null, "note": "no transfer"}
68
+ },
69
+ "quality": {
70
+ "efficiency": {"score": 4, "signals_present": ["first-call resolution"], "signals_absent": [], "evidence": [{"quote":"...","speaker":"AGENT","timestamp":"08:38"}], "requires_audio": false},
71
+ "problem_resolution": {"score": 4, "signals_present": ["explained why"], "signals_absent": ["no explicit options A/B/C"], "evidence": [], "requires_audio": false},
72
+ "clarity": {"score": 3, "signals_present": [], "signals_absent": ["no recap at end"], "evidence": [], "requires_audio": false},
73
+ "professionalism": {"score": 4, "signals_present": ["complete sentences"], "signals_absent": [], "evidence": [], "requires_audio": true},
74
+ "empathy": {"score": 2, "signals_present": [], "signals_absent": ["no validation phrase"], "evidence": [], "requires_audio": true}
75
+ },
76
+ "escalation": {
77
+ "red_flags": [],
78
+ "customer_emotion_text": "calm",
79
+ "risk_level": "none",
80
+ "evidence": [],
81
+ "requires_audio": true
82
+ },
83
+ "overall_summary": "2-3 sentence plain-language summary."
84
+ }"""
85
+
86
+
87
+ def strip_fences(s):
88
+ s = s.strip()
89
+ if s.startswith("```"):
90
+ s = s.split("```", 2)[1] if "```" in s[3:] else s[3:]
91
+ if s.startswith("json"):
92
+ s = s[4:]
93
+ s = s.rsplit("```", 1)[0]
94
+ return s.strip()
95
+
96
+
97
+ def extract(call_id, provider="github", model=None, results_dir="results_channels"):
98
+ manifest = load_manifest()
99
+ meta = manifest[call_id]
100
+ result_path = os.path.join(DATA, results_dir, meta["accent"], call_id + ".json")
101
+ with open(result_path, encoding="utf-8") as f:
102
+ result = json.load(f)
103
+
104
+ packet, stats = build_packet(result, meta)
105
+
106
+ authoritative_meta = CallMetadata(
107
+ call_id=call_id, domain=result.get("domain", meta.get("domain", "unknown")),
108
+ accent=meta.get("accent"), duration_seconds=stats["duration_s"],
109
+ transcript_model=result.get("model"))
110
+
111
+ user = f"{packet}\n\n{SKELETON}"
112
+ system = SYSTEM
113
+ last_err = None
114
+ served = provider
115
+
116
+ for attempt in (1, 2):
117
+ t0 = time.time()
118
+ if provider == "router":
119
+ from router import chat_json_routed
120
+ raw, served = chat_json_routed(system, user, return_meta=True)
121
+ else:
122
+ raw = chat_json(provider, system, user, model=model)
123
+ served = model or provider
124
+ dt = time.time() - t0
125
+ try:
126
+ data = json.loads(strip_fences(raw))
127
+ data["rubric_version"] = RUBRIC_VERSION
128
+ data["metadata"] = authoritative_meta.model_dump()
129
+ evaluation = CallEvaluation.model_validate(data)
130
+ return evaluation, dt, served
131
+ except (json.JSONDecodeError, ValidationError) as e:
132
+ last_err = e
133
+ print(f" [attempt {attempt}] validation failed ({type(e).__name__}); retrying...")
134
+ user = (f"{packet}\n\n{SKELETON}\n\nYour previous output was invalid: "
135
+ f"{str(e)[:400]}. Return corrected JSON only.")
136
+ raise RuntimeError(f"Extraction failed after 2 attempts: {last_err}")
137
+
138
+
139
+ def print_report(ev: CallEvaluation, dt, served=None):
140
+ print("\n" + "=" * 68)
141
+ tag = f" [{dt:.1f}s | {served}]" if served else f" [{dt:.1f}s]"
142
+ print(f" EVALUATION {ev.metadata.call_id} ({ev.metadata.domain}){tag}")
143
+ print("=" * 68)
144
+ print("\n COMPLIANCE")
145
+ c = ev.compliance
146
+ for name, item in [("Name announced", c.name_announced), ("Company announced", c.company_announced),
147
+ ("Recording disclosure", c.recording_disclosure), ("Identity verified", c.identity_verified),
148
+ ("Resolution provided", c.resolution_provided), ("Transfer next-steps", c.transfer_next_steps)]:
149
+ mark = {True: "PASS", False: "FAIL", None: "N/A "}[item.passed]
150
+ ev_str = f' "{item.evidence.quote[:60]}"' if item.evidence else (f" ({item.note})" if item.note else "")
151
+ print(f" [{mark}] {name:<22}{ev_str}")
152
+ if c.identity_method:
153
+ print(f" method: {c.identity_method}")
154
+ print("\n QUALITY (1-5, text-derived)")
155
+ for name, q in [("Efficiency", ev.quality.efficiency), ("Problem resolution", ev.quality.problem_resolution),
156
+ ("Clarity", ev.quality.clarity), ("Professionalism", ev.quality.professionalism),
157
+ ("Empathy", ev.quality.empathy)]:
158
+ aud = " (+audio needed)" if q.requires_audio else ""
159
+ print(f" {q.score}/5 {name:<20}{aud}")
160
+ if q.signals_absent:
161
+ print(f" missing: {', '.join(q.signals_absent[:3])}")
162
+ e = ev.escalation
163
+ print(f"\n ESCALATION: {e.risk_level.upper()} | emotion(text): {e.customer_emotion_text}"
164
+ f" | flags: {', '.join(e.red_flags) if e.red_flags else 'none'}")
165
+ print(f"\n SUMMARY: {ev.overall_summary}")
166
+ print("=" * 68)
167
+
168
+
169
+ def main():
170
+ ap = argparse.ArgumentParser()
171
+ from llm_client import list_providers
172
+ ap.add_argument("--call_id", required=True)
173
+ ap.add_argument("--provider", default="router",
174
+ choices=["router"] + list_providers())
175
+ ap.add_argument("--model", default=None)
176
+ ap.add_argument("--results_dir", default="results_channels")
177
+ args = ap.parse_args()
178
+
179
+ ev, dt, served = extract(args.call_id, args.provider, args.model, args.results_dir)
180
+
181
+ here = os.path.dirname(os.path.abspath(__file__))
182
+ out_dir = os.path.join(here, "results")
183
+ os.makedirs(out_dir, exist_ok=True)
184
+ out = os.path.join(out_dir, f"{args.call_id}.json")
185
+ with open(out, "w", encoding="utf-8") as f:
186
+ json.dump(ev.model_dump(), f, indent=2)
187
+
188
+ print_report(ev, dt, served)
189
+ print(f"\nSaved -> {out}")
190
+
191
+
192
+ if __name__ == "__main__":
193
+ main()
ml-services/evaluation/fusion.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Acoustic-text fusion (v0.4.0) -- combines the audio sentiment model's
3
+ per-sentence output with the text LLM's rubric scores.
4
+
5
+ Design: DETERMINISTIC POST-HOC FUSION, not prompt injection.
6
+ The LLM judges what text can prove; the acoustic model contributes what text
7
+ cannot see (tone, emotion, escalation intensity); a documented formula
8
+ combines them. This keeps the LLM's variance out of the acoustic signal and
9
+ makes every fused score reproducible and explainable.
10
+
11
+ Math
12
+ ----
13
+ Valence map: Positive=+1, Neutral/Mixed=0, Negative=-1.
14
+
15
+ Quality dimension fusion (empathy, professionalism <- AGENT channel;
16
+ customer_satisfaction <- CUSTOMER channel, final third weighted 2x):
17
+ acoustic_score a = 3 + 2 * weighted_mean(valence) # maps [-1,1] -> [1,5]
18
+ w_a = W_ACOUSTIC_CAP * coverage # coverage = processed/total
19
+ fused = clamp(round((1 - w_a) * text + w_a * a), 1, 5)
20
+
21
+ Text stays the primary signal (cap 0.4) because the rubric's behaviours are
22
+ text-defined; acoustics modulate. Zero acoustic data -> w_a = 0 (text only).
23
+
24
+ Escalation fusion (CUSTOMER channel escalation_score trajectory):
25
+ late_mean = mean(score over final third), peak = max(score)
26
+ acoustic tier (v0.4.1, fitted on the 22-call batch -- see threshold
27
+ comments): escalate if late_mean >= 0.50, review if late_mean >= 0.30,
28
+ none otherwise. peak is recorded as context but does NOT set a tier.
29
+ Merge (v0.4.1): tiers that agree merge deterministically ("agreement");
30
+ tiers that disagree are left to the arbitrate LLM node ("disputed" ->
31
+ resolved as "llm_arbitration"), because disagreement is where a fixed
32
+ formula has no information to prefer one channel over the other.
33
+
34
+ Data layout: data/sentiment/{domain}/{call_id}.json (model output)
35
+ data/sentiment/{domain}/{call_id}_segments.json (the EXACT
36
+ sentence segmentation the model ran against -- seq_ids are only
37
+ meaningful against their own segmentation)
38
+ """
39
+ import os
40
+ import json
41
+
42
+ import paths
43
+
44
+ SENTIMENT_ROOT = str(paths.SENTIMENT_ROOT)
45
+
46
+ W_ACOUSTIC_CAP = 0.4 # max acoustic weight at 100% coverage
47
+ LATE_FRACTION = 1 / 3 # "end of call" window for trajectory metrics
48
+ LATE_WEIGHT = 2.0 # extra weight on final-third customer valence
49
+
50
+ # Acoustic escalation tiers -- fitted on the 22-call batch (2026-07,
51
+ # batch_summary.json, n=21 calls with paired acoustic data):
52
+ # late_mean: min 0.166, p25 0.219, median 0.227, p75 0.257, p90 0.281,
53
+ # max 0.406 (single outlier; next highest 0.281).
54
+ # -> review at 0.30 sits above the calm cluster's p90 with margin; only the
55
+ # outlier call crosses it. escalate at 0.50 is deliberately outside the
56
+ # observed range: nothing in this batch warrants an acoustic-only
57
+ # escalate, and the bar should stay high until data demands lowering it.
58
+ # peak: median 0.558, p90 0.696 -- even calm calls routinely spike past the
59
+ # old 0.60 trigger, so peak has no discriminating power as a threshold.
60
+ # It is kept as recorded context (arbitrator/investigator input) only.
61
+ ESC_THRESHOLDS = {
62
+ "escalate": {"late_mean": 0.50},
63
+ "review": {"late_mean": 0.30},
64
+ }
65
+
66
+ VALENCE = {"Positive": 1.0, "Negative": -1.0, "Neutral": 0.0, "Mixed": 0.0}
67
+ RISK_ORDER = {"none": 0, "review": 1, "escalate": 2}
68
+
69
+ # which quality dimension is informed by which channel
70
+ FUSED_DIMS = {
71
+ "professionalism": {"channel": "AGENT", "late_weighted": False},
72
+ "empathy": {"channel": "AGENT", "late_weighted": False},
73
+ "customer_satisfaction": {"channel": "CUSTOMER", "late_weighted": True},
74
+ }
75
+
76
+
77
+ def load_acoustic(call_id, domain):
78
+ """
79
+ Join the sentiment model output with the segmentation it ran against.
80
+ Returns list of rows {seq_id, pos, speaker, valence, emotion,
81
+ escalation_score, ok} or None if no acoustic data exists for this call.
82
+ """
83
+ d = os.path.join(SENTIMENT_ROOT, domain.lower())
84
+ sent_path = os.path.join(d, f"{call_id}.json")
85
+ seg_path = os.path.join(d, f"{call_id}_segments.json")
86
+ if not (os.path.exists(sent_path) and os.path.exists(seg_path)):
87
+ return None
88
+
89
+ with open(sent_path, encoding="utf-8") as f:
90
+ sentiment = json.load(f)
91
+ with open(seg_path, encoding="utf-8") as f:
92
+ segments = json.load(f)
93
+
94
+ speakers = {s["seq_id"]: s["speaker"] for s in segments["sentences"]}
95
+ n = max(speakers) or 1
96
+
97
+ rows = []
98
+ for s in sentiment["segments"]:
99
+ sid = s["seq_id"]
100
+ ok = s.get("processing_status") == "success" and s.get("sentiment")
101
+ rows.append({
102
+ "seq_id": sid,
103
+ "pos": sid / n, # 0..1 position in call
104
+ "speaker": speakers.get(sid),
105
+ "valence": VALENCE.get(s.get("sentiment"), 0.0) if ok else None,
106
+ "emotion": s.get("dominant_emotion"),
107
+ "escalation_score": s.get("escalation_score"),
108
+ "ok": bool(ok),
109
+ })
110
+ return rows
111
+
112
+
113
+ def _channel_valence(rows, channel, late_weighted):
114
+ """(weighted mean valence, coverage) for one speaker channel."""
115
+ chan = [r for r in rows if r["speaker"] == channel]
116
+ if not chan:
117
+ return None, 0.0
118
+ ok = [r for r in chan if r["ok"]]
119
+ coverage = len(ok) / len(chan)
120
+ if not ok:
121
+ return None, 0.0
122
+ wsum = vsum = 0.0
123
+ for r in ok:
124
+ w = LATE_WEIGHT if (late_weighted and r["pos"] >= 1 - LATE_FRACTION) else 1.0
125
+ wsum += w
126
+ vsum += w * r["valence"]
127
+ return vsum / wsum, coverage
128
+
129
+
130
+ def _acoustic_escalation(rows):
131
+ """Acoustic risk tier from the customer escalation_score trajectory."""
132
+ cust = [r for r in rows
133
+ if r["speaker"] == "CUSTOMER" and r["ok"]
134
+ and r["escalation_score"] is not None]
135
+ if not cust:
136
+ return None, None, None
137
+ late = [r["escalation_score"] for r in cust if r["pos"] >= 1 - LATE_FRACTION]
138
+ late_mean = sum(late) / len(late) if late else 0.0
139
+ peak = max(r["escalation_score"] for r in cust)
140
+
141
+ for tier in ("escalate", "review"):
142
+ if late_mean >= ESC_THRESHOLDS[tier]["late_mean"]:
143
+ return tier, round(late_mean, 3), round(peak, 3)
144
+ return "none", round(late_mean, 3), round(peak, 3)
145
+
146
+
147
+ def trajectory(rows, buckets=48):
148
+ """
149
+ Downsample the per-sentence acoustic signal into fixed position buckets
150
+ for the frontend timeline: [{p, esc, cust_val, agent_val}, ...].
151
+ p = bucket midpoint (0..1); esc = mean customer escalation_score;
152
+ *_val = mean valence per channel. Fields are null where a bucket has no
153
+ processed sentences for that channel.
154
+ """
155
+ out = []
156
+ for b in range(buckets):
157
+ lo, hi = b / buckets, (b + 1) / buckets
158
+ cell = [r for r in rows if r["ok"] and lo <= r["pos"] < hi]
159
+ esc = [r["escalation_score"] for r in cell
160
+ if r["speaker"] == "CUSTOMER" and r["escalation_score"] is not None]
161
+ cv = [r["valence"] for r in cell
162
+ if r["speaker"] == "CUSTOMER" and r["valence"] is not None]
163
+ av = [r["valence"] for r in cell
164
+ if r["speaker"] == "AGENT" and r["valence"] is not None]
165
+ out.append({
166
+ "p": round((lo + hi) / 2, 4),
167
+ "esc": round(sum(esc) / len(esc), 3) if esc else None,
168
+ "cust_val": round(sum(cv) / len(cv), 3) if cv else None,
169
+ "agent_val": round(sum(av) / len(av), 3) if av else None,
170
+ })
171
+ return out
172
+
173
+
174
+ def fuse_evaluation(ev, rows):
175
+ """
176
+ Mutate an evaluation dict in place: fuse acoustic signal into the three
177
+ audio-informed quality dimensions and the escalation risk level.
178
+ Every dimension gets a `hybrid` provenance block (method "text_only"
179
+ when acoustics did not contribute) so the UI can explain each score.
180
+ Returns a short summary dict for node_meta.
181
+ """
182
+ fused_dims = []
183
+
184
+ for name, dim in (ev.get("quality") or {}).items():
185
+ if not isinstance(dim, dict) or "score" not in dim:
186
+ continue
187
+ text_score = dim["score"]
188
+ cfg = FUSED_DIMS.get(name)
189
+ vbar, coverage = (_channel_valence(rows, cfg["channel"], cfg["late_weighted"])
190
+ if (cfg and rows) else (None, 0.0))
191
+ if cfg and vbar is not None:
192
+ a = 3 + 2 * vbar
193
+ w_a = W_ACOUSTIC_CAP * coverage
194
+ fused = max(1, min(5, round((1 - w_a) * text_score + w_a * a)))
195
+ dim["score"] = fused
196
+ dim["hybrid"] = {
197
+ "text_score": text_score,
198
+ "acoustic_score": round(a, 2),
199
+ "text_weight": round(1 - w_a, 2),
200
+ "acoustic_weight": round(w_a, 2),
201
+ "coverage": round(coverage, 2),
202
+ "channel": cfg["channel"],
203
+ "method": "weighted_mean",
204
+ }
205
+ fused_dims.append(name)
206
+ else:
207
+ dim["hybrid"] = {
208
+ "text_score": text_score,
209
+ "acoustic_score": None,
210
+ "text_weight": 1.0,
211
+ "acoustic_weight": 0.0,
212
+ "coverage": None,
213
+ "channel": cfg["channel"] if cfg else None,
214
+ "method": "text_only",
215
+ }
216
+
217
+ # Escalation merge (v0.4.1): agreement is trivially deterministic;
218
+ # disagreement carries no information about which channel to trust, so it
219
+ # is NOT resolved here -- the graph routes disputed calls to the arbitrate
220
+ # LLM node, which weighs both signals against the transcript. Until (or
221
+ # unless) arbitration runs, the text tier stands.
222
+ disputed = False
223
+ esc = ev.get("escalation") or {}
224
+ text_risk = esc.get("risk_level")
225
+ a_risk, late_mean, peak = _acoustic_escalation(rows) if rows else (None, None, None)
226
+ if text_risk and a_risk is not None:
227
+ disputed = a_risk != text_risk
228
+ esc["hybrid"] = {
229
+ "text_risk": text_risk,
230
+ "acoustic_risk": a_risk,
231
+ "late_mean_escalation": late_mean,
232
+ "peak_escalation": peak,
233
+ "method": "agreement" if not disputed else "text_only",
234
+ "arbitration_rationale": None,
235
+ }
236
+ elif text_risk:
237
+ esc["hybrid"] = {
238
+ "text_risk": text_risk,
239
+ "acoustic_risk": None,
240
+ "late_mean_escalation": None,
241
+ "peak_escalation": None,
242
+ "method": "text_only",
243
+ "arbitration_rationale": None,
244
+ }
245
+
246
+ # downsampled acoustic timeline for the frontend Agent-findings lanes
247
+ if rows:
248
+ ev["_acoustic"] = {"trajectory": trajectory(rows),
249
+ "n_sentences": len(rows)}
250
+
251
+ return {
252
+ "acoustic_available": bool(rows),
253
+ "fused_dims": fused_dims,
254
+ "acoustic_risk": a_risk,
255
+ "disputed": disputed,
256
+ }
ml-services/evaluation/gen_call_data.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Build the frontend's call_data.json from transcript_data.json:
3
+ - groups words into speaker turns (agent/customer)
4
+ - chapters the call via the LiteLLM router
5
+ - emits turns + chapters with numeric seconds, ready for the React player
6
+
7
+ Output: frontend/src/call_data.json
8
+ { call, duration, turns:[{i,speaker,text,start,end}],
9
+ chapters:[{index,label,summary,start,end,start_turn}] }
10
+ """
11
+ import os, json, time
12
+ import paths
13
+ from env_util import load_env
14
+ from segment import SYSTEM, SKELETON, numbered_transcript
15
+ from router import chat_json_routed
16
+ from extract import strip_fences
17
+
18
+ load_env()
19
+
20
+ FRONTEND = str(paths.FRONTEND_ROOT)
21
+ TRANSCRIPT = os.path.join(FRONTEND, "transcript_data.json")
22
+ OUT = os.path.join(FRONTEND, "src", "call_data.json")
23
+
24
+
25
+ def build_turns(data):
26
+ """Merge agent+customer words into time-ordered speaker turns."""
27
+ merged = []
28
+ for speaker, key in (("AGENT", "agent"), ("CUSTOMER", "customer")):
29
+ for w in data.get(key, []):
30
+ if w.get("start") is None:
31
+ continue
32
+ merged.append((w["start"], w.get("end", w["start"]),
33
+ speaker, w["word"]))
34
+ merged.sort(key=lambda x: x[0])
35
+
36
+ turns = []
37
+ cur = None
38
+ for start, end, sp, word in merged:
39
+ if cur is None or sp != cur["speaker"]:
40
+ if cur:
41
+ turns.append(cur)
42
+ cur = {"speaker": sp, "text": word, "start": start, "end": end}
43
+ else:
44
+ cur["text"] += " " + word
45
+ cur["end"] = end
46
+ if cur:
47
+ turns.append(cur)
48
+
49
+ for i, t in enumerate(turns):
50
+ t["i"] = i
51
+ t["start"] = round(t["start"], 2)
52
+ t["end"] = round(t["end"], 2)
53
+ return turns
54
+
55
+
56
+ def repair_chapters(seeds, turns, duration):
57
+ n = len(turns)
58
+ cleaned, seen = [], set()
59
+ for s in seeds:
60
+ try:
61
+ st = max(0, min(int(s["start_turn"]), n - 1))
62
+ except (KeyError, ValueError, TypeError):
63
+ continue
64
+ if st in seen:
65
+ continue
66
+ seen.add(st)
67
+ cleaned.append({"start_turn": st,
68
+ "label": str(s.get("label", "Untitled")).strip(),
69
+ "summary": str(s.get("summary", "")).strip()})
70
+ cleaned.sort(key=lambda x: x["start_turn"])
71
+ if not cleaned:
72
+ cleaned = [{"start_turn": 0, "label": "Full Call", "summary": ""}]
73
+ if cleaned[0]["start_turn"] != 0:
74
+ cleaned[0]["start_turn"] = 0
75
+
76
+ chapters = []
77
+ for i, c in enumerate(cleaned):
78
+ st = c["start_turn"]
79
+ start = turns[st]["start"]
80
+ end = (turns[cleaned[i + 1]["start_turn"]]["start"]
81
+ if i + 1 < len(cleaned) else duration)
82
+ chapters.append({"index": i + 1, "label": c["label"],
83
+ "summary": c["summary"], "start": round(start, 2),
84
+ "end": round(end, 2), "start_turn": st})
85
+ return chapters
86
+
87
+
88
+ def main():
89
+ with open(TRANSCRIPT, encoding="utf-8") as f:
90
+ data = json.load(f)
91
+
92
+ turns = build_turns(data)
93
+ duration = round(max(t["end"] for t in turns), 2)
94
+ print(f"call={data.get('call')} | {len(turns)} turns | {duration:.0f}s")
95
+
96
+ user = f"{numbered_transcript(turns)}\n\n{SKELETON}"
97
+ t0 = time.time()
98
+ raw, served = chat_json_routed(SYSTEM, user, max_tokens=1500, return_meta=True)
99
+ seeds = json.loads(strip_fences(raw)).get("chapters", [])
100
+ chapters = repair_chapters(seeds, turns, duration)
101
+ print(f"chaptered by {served} in {time.time()-t0:.1f}s -> {len(chapters)} chapters")
102
+ for c in chapters:
103
+ print(f" [{c['index']}] {c['start']:6.1f}-{c['end']:6.1f} {c['label']}")
104
+
105
+ out = {"call": data.get("call", "call_1"),
106
+ "model": data.get("model", ""),
107
+ "duration": duration, "turns": turns, "chapters": chapters}
108
+ os.makedirs(os.path.dirname(OUT), exist_ok=True)
109
+ with open(OUT, "w", encoding="utf-8") as f:
110
+ json.dump(out, f, indent=2)
111
+ print(f"\nSaved -> {OUT}")
112
+
113
+
114
+ if __name__ == "__main__":
115
+ main()
ml-services/evaluation/graph.py ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Graph-based call evaluation pipeline (v0.4.1).
3
+
4
+ Decomposes the monolithic extract.py into five parallel LLM nodes
5
+ (compliance, quality, escalation, chapters, workflow) orchestrated by
6
+ LangGraph. Each node gets a focused prompt, validates its own output
7
+ fragment, and retries independently on failure. After aggregation, a
8
+ deterministic fuse step blends the audio sentiment model's output into the
9
+ audio-informed scores; when the text and acoustic escalation tiers DISAGREE
10
+ the graph summons an arbitration LLM node (v0.4.1) instead of merging by
11
+ rule. Anchoring + runtime routing (evidence re-runs, conditional
12
+ investigation) close the loop.
13
+
14
+ Graph shape:
15
+ assemble → [compliance, quality, escalation, chapters, workflow]
16
+ → aggregate → fuse → (arbitrate?) → anchor
17
+ → (re-run | investigate | END)
18
+
19
+ Usage:
20
+ python graph.py --call_id en_CA_Banking_1592237
21
+ python graph.py --call_id en_CA_Banking_1592237 --results_dir results_channels
22
+ """
23
+ import os
24
+ import json
25
+ import time
26
+ import argparse
27
+ from typing import TypedDict, Annotated
28
+
29
+ from langgraph.graph import StateGraph, START, END
30
+
31
+
32
+ def _merge_meta(a, b):
33
+ """Reducer: parallel nodes each contribute their own node_meta entry."""
34
+ return {**(a or {}), **(b or {})}
35
+
36
+ from nodes import (
37
+ assemble_node,
38
+ compliance_node,
39
+ quality_node,
40
+ escalation_node,
41
+ chapter_node,
42
+ workflow_node,
43
+ aggregate_node,
44
+ fuse_node,
45
+ arbitrate_node,
46
+ anchor_node,
47
+ investigate_node,
48
+ )
49
+ from rubric import RUBRIC_VERSION_GRAPH
50
+
51
+
52
+ # ─────────────────────────────────────────────────────────────────────────────
53
+ # State schema — keys are populated incrementally by each node
54
+ # ─────────────────────────────────────────────────────────────────────────────
55
+
56
+ class EvalState(TypedDict, total=False):
57
+ # inputs
58
+ call_id: str
59
+ results_dir: str
60
+
61
+ # set by assemble
62
+ packet: str
63
+ meta: dict
64
+ turns: list
65
+
66
+ # set by parallel LLM nodes
67
+ compliance: object
68
+ quality: object
69
+ escalation: object
70
+ chapters: dict
71
+ workflow: dict
72
+
73
+ # set by aggregate + anchor
74
+ evaluation: dict
75
+
76
+ # v0.3.0 control fields
77
+ anchor_attempts: int # anchor passes so far (loop guard)
78
+ anchor_feedback: dict # {section: [unanchored quotes]} from last pass
79
+
80
+ # v0.4.1: set by fuse — text vs acoustic escalation tiers disagree
81
+ fusion_disputed: bool
82
+
83
+ # per-node execution metadata (duration, served model, run count);
84
+ # parallel nodes merge via reducer instead of clobbering each other
85
+ node_meta: Annotated[dict, _merge_meta]
86
+
87
+
88
+ # ─────────────────────────────────────────────────────────────────────────────
89
+ # Graph construction
90
+ # ─────────────────────────────────────────────────────────────────────────────
91
+
92
+ # which evaluation section is produced by which LLM node
93
+ SECTION_NODE = {"compliance": "compliance",
94
+ "quality": "quality",
95
+ "escalation": "escalation"}
96
+
97
+ MAX_ANCHOR_PASSES = 2 # first pass + one corrective re-run
98
+
99
+
100
+ def route_after_fuse(state):
101
+ """
102
+ v0.4.1 routing. Deterministic merging is only defensible when the text
103
+ and acoustic escalation tiers AGREE (they carry no information to break
104
+ their own tie). On disagreement, summon the arbitration LLM node.
105
+ """
106
+ if state.get("fusion_disputed"):
107
+ h = state["evaluation"]["escalation"].get("hybrid") or {}
108
+ print(f" [router] escalation tiers disagree "
109
+ f"(text={h.get('text_risk')}, acoustic={h.get('acoustic_risk')})"
110
+ f" -> arbitration")
111
+ return "arbitrate"
112
+ return "anchor"
113
+
114
+
115
+ def route_after_anchor(state):
116
+ """
117
+ v0.3.0 runtime routing. After anchoring, decide:
118
+ 1. Any section cited quotes that don't exist in the transcript, and we
119
+ still have a corrective pass left? -> re-run ONLY those LLM nodes
120
+ with the failed quotes as a revision note.
121
+ 2. Otherwise, does the call carry escalation risk? -> deep-dive
122
+ investigation node.
123
+ 3. Otherwise -> done.
124
+ """
125
+ feedback = state.get("anchor_feedback") or {}
126
+ if feedback and state.get("anchor_attempts", 0) < MAX_ANCHOR_PASSES:
127
+ targets = [SECTION_NODE[s] for s in feedback if s in SECTION_NODE]
128
+ if targets:
129
+ print(f" [router] unverified evidence -> re-running: "
130
+ f"{', '.join(targets)}")
131
+ return targets
132
+
133
+ risk = state["evaluation"]["escalation"]["risk_level"]
134
+ if risk != "none":
135
+ print(f" [router] risk_level={risk} -> investigation")
136
+ return "investigate"
137
+ return END
138
+
139
+
140
+ def build_graph():
141
+ g = StateGraph(EvalState)
142
+
143
+ g.add_node("assemble", assemble_node)
144
+ g.add_node("compliance", compliance_node)
145
+ g.add_node("quality", quality_node)
146
+ g.add_node("escalation", escalation_node)
147
+ g.add_node("chapters", chapter_node)
148
+ g.add_node("workflow", workflow_node)
149
+ g.add_node("aggregate", aggregate_node)
150
+ g.add_node("fuse", fuse_node)
151
+ g.add_node("arbitrate", arbitrate_node)
152
+ g.add_node("anchor", anchor_node)
153
+ g.add_node("investigate", investigate_node)
154
+
155
+ # assemble fans out to 5 parallel nodes
156
+ g.add_edge(START, "assemble")
157
+ g.add_edge("assemble", "compliance")
158
+ g.add_edge("assemble", "quality")
159
+ g.add_edge("assemble", "escalation")
160
+ g.add_edge("assemble", "chapters")
161
+ g.add_edge("assemble", "workflow")
162
+
163
+ # all 5 converge into aggregate
164
+ g.add_edge("compliance", "aggregate")
165
+ g.add_edge("quality", "aggregate")
166
+ g.add_edge("escalation", "aggregate")
167
+ g.add_edge("chapters", "aggregate")
168
+ g.add_edge("workflow", "aggregate")
169
+
170
+ # aggregate → fuse (acoustic-text fusion), then RUNTIME routing:
171
+ # text/acoustic tiers disagree → arbitrate (LLM weighs both in context;
172
+ # agreement merges deterministically and skips this — v0.4.1)
173
+ # unverified evidence → back to the offending LLM node(s) (once)
174
+ # escalation risk → investigation deep-dive
175
+ # (fuse/arbitrate run BEFORE routing, so an acoustically-detected
176
+ # risk can summon the investigation even when the words look calm)
177
+ # clean + calm → END
178
+ g.add_edge("aggregate", "fuse")
179
+ g.add_conditional_edges(
180
+ "fuse", route_after_fuse, ["arbitrate", "anchor"])
181
+ g.add_edge("arbitrate", "anchor")
182
+ g.add_conditional_edges(
183
+ "anchor", route_after_anchor,
184
+ ["compliance", "quality", "escalation", "investigate", END])
185
+ g.add_edge("investigate", END)
186
+
187
+ return g.compile()
188
+
189
+
190
+ # ─────────────────────────────────────────────────────────────────────────────
191
+ # Report
192
+ # ─────────────────────────────────────────────────────────────────────────────
193
+
194
+ def _mmss(s):
195
+ return f"{int(s // 60):02d}:{int(s % 60):02d}"
196
+
197
+
198
+ def print_graph_report(ev, dt):
199
+ meta = ev.get("metadata", {})
200
+ print("\n" + "=" * 68)
201
+ print(f" GRAPH EVALUATION {meta.get('call_id', '?')} "
202
+ f"({meta.get('domain', '?')}) [{dt:.1f}s total]")
203
+ print(f" rubric {ev.get('rubric_version', '?')}")
204
+ print("=" * 68)
205
+
206
+ # compliance
207
+ print("\n COMPLIANCE")
208
+ c = ev.get("compliance", {})
209
+ for key, name in [("name_announced", "Name announced"),
210
+ ("company_announced", "Company announced"),
211
+ ("recording_disclosure", "Recording disclosure"),
212
+ ("identity_verified", "Identity verified"),
213
+ ("resolution_provided", "Resolution provided"),
214
+ ("transfer_next_steps", "Transfer next-steps")]:
215
+ item = c.get(key, {})
216
+ mark = {True: "PASS", False: "FAIL", None: "N/A "}[item.get("passed")]
217
+ evd = item.get("evidence")
218
+ when = (f" @{_mmss(evd['sec'])}" if evd and evd.get("sec") is not None
219
+ else "")
220
+ quote = (f' "{evd["quote"][:50]}"' if evd
221
+ else (f" ({item.get('note')})" if item.get("note") else ""))
222
+ print(f" [{mark}]{when:>7} {name:<22}{quote}")
223
+ if c.get("identity_method"):
224
+ print(f" method: {c['identity_method']}")
225
+
226
+ # quality
227
+ print("\n QUALITY (1-5, hybrid text+acoustic)")
228
+ q = ev.get("quality", {})
229
+ dims = [("efficiency", "Efficiency"),
230
+ ("problem_resolution", "Problem resolution"),
231
+ ("clarity", "Clarity"),
232
+ ("professionalism", "Professionalism"),
233
+ ("empathy", "Empathy"),
234
+ ("customer_satisfaction", "Customer satisfaction")]
235
+ for key, name in dims:
236
+ d = q.get(key)
237
+ if d is None:
238
+ continue
239
+ h = d.get("hybrid") or {}
240
+ if h.get("method") == "weighted_mean":
241
+ how = (f" (text {h['text_score']} x {h['text_weight']} + "
242
+ f"audio {h['acoustic_score']} x {h['acoustic_weight']}, "
243
+ f"{h['channel'].lower()} ch)")
244
+ else:
245
+ how = " (text only)"
246
+ print(f" {d['score']}/5 {name:<24}{how}")
247
+ if d.get("signals_absent"):
248
+ print(f" missing: {', '.join(d['signals_absent'][:3])}")
249
+
250
+ # escalation
251
+ e = ev.get("escalation", {})
252
+ flags = e.get("red_flags", [])
253
+ eh = e.get("hybrid") or {}
254
+ fused = (f" [text: {eh['text_risk']} | acoustic: {eh['acoustic_risk']} "
255
+ f"(late-mean {eh['late_mean_escalation']}, peak {eh['peak_escalation']})"
256
+ f" via {eh['method']}]"
257
+ if eh.get("acoustic_risk") is not None else "")
258
+ print(f"\n ESCALATION: {e.get('risk_level', '?').upper()}{fused} "
259
+ f"| emotion(text): {e.get('customer_emotion_text', '?')}"
260
+ f" | flags: {', '.join(flags) if flags else 'none'}")
261
+ if eh.get("arbitration_rationale"):
262
+ print(f" arbitrator: {eh['arbitration_rationale']}")
263
+
264
+ # workflow (v0.4.0)
265
+ wf = ev.get("workflow")
266
+ if wf:
267
+ print(f"\n EXPECTED WORKFLOW (subject: {wf.get('subject', '?')})")
268
+ for s in wf.get("expected_steps", []):
269
+ mark = {True: "[x]", False: "[ ]", None: "[-]"}[s.get("met")]
270
+ evd = s.get("evidence")
271
+ when = (f" @{_mmss(evd['sec'])}" if evd and evd.get("sec") is not None
272
+ else "")
273
+ print(f" {mark}{when:>7} {s['step']}")
274
+
275
+ # investigation (v0.3.0 — only when risk_level != none)
276
+ inv = ev.get("investigation")
277
+ if inv:
278
+ print(f"\n INVESTIGATION [{inv.get('priority', '?').upper()} priority]")
279
+ print(f" {inv.get('summary', '')}")
280
+ for f_ in inv.get("contributing_factors", []):
281
+ print(f" - {f_}")
282
+ print(f" ACTION: {inv.get('recommended_action', '')}")
283
+
284
+ # summary
285
+ summary = ev.get("overall_summary")
286
+ if summary:
287
+ print(f"\n SUMMARY: {summary}")
288
+
289
+ # anchor stats
290
+ astats = ev.get("_anchor_stats", {})
291
+ if astats:
292
+ print(f"\n EVIDENCE: {astats.get('anchored', 0)}/{astats.get('total', 0)} "
293
+ f"quotes anchored to transcript timestamps")
294
+
295
+ print("=" * 68)
296
+
297
+
298
+ # ─────────────────────────────────────────────────────────────────────────────
299
+ # CLI
300
+ # ─────────────────────────────────────────────────────────────────────────────
301
+
302
+ def evaluate_call(call_id, results_dir="results"):
303
+ """Run the evaluation graph for one call and return the evaluation dict.
304
+
305
+ The `_pipeline` execution trace (node timings, anchor passes, wall clock)
306
+ is attached for the frontend pipeline strip. No file I/O -- callers (the
307
+ CLI below, the single-call orchestrator, the worker) decide where it goes.
308
+ """
309
+ graph = build_graph()
310
+ t0 = time.time()
311
+ final = graph.invoke({
312
+ "call_id": call_id,
313
+ "results_dir": results_dir,
314
+ })
315
+ dt = time.time() - t0
316
+
317
+ ev = final["evaluation"]
318
+ ev["_pipeline"] = {
319
+ "nodes": final.get("node_meta", {}),
320
+ "anchor_passes": final.get("anchor_attempts", 1),
321
+ "wall_clock": round(dt, 1),
322
+ }
323
+ return ev
324
+
325
+
326
+ def main():
327
+ ap = argparse.ArgumentParser(
328
+ description="Graph-based call evaluation (parallel LLM nodes)")
329
+ ap.add_argument("--call_id", required=True)
330
+ ap.add_argument("--results_dir", default="results_channels")
331
+ args = ap.parse_args()
332
+
333
+ print(f"\nEvaluating {args.call_id} via graph pipeline "
334
+ f"(rubric {RUBRIC_VERSION_GRAPH})...")
335
+ print("-" * 68)
336
+
337
+ ev = evaluate_call(args.call_id, args.results_dir)
338
+ dt = ev["_pipeline"]["wall_clock"]
339
+
340
+ # save
341
+ here = os.path.dirname(os.path.abspath(__file__))
342
+ out_dir = os.path.join(here, "results")
343
+ os.makedirs(out_dir, exist_ok=True)
344
+ out = os.path.join(out_dir, f"{args.call_id}_graph.json")
345
+ with open(out, "w", encoding="utf-8") as f:
346
+ json.dump(ev, f, indent=2)
347
+
348
+ print_graph_report(ev, dt)
349
+ print(f"\nSaved -> {out}")
350
+
351
+
352
+ if __name__ == "__main__":
353
+ main()
ml-services/evaluation/llm_client.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Provider-agnostic LLM client (OpenAI SDK-compatible for all providers).
3
+
4
+ Swap provider with a single string -- the rest of the pipeline never changes.
5
+ Used by extract.py via: chat_json(provider, system, user)
6
+
7
+ Provider notes
8
+ --------------
9
+ github : GitHub Models free tier -- GPT-4o-mini, zero cost, recommended default
10
+ openai : OpenAI direct -- costs credits, use for prod/comparison only
11
+ gemini : Google Gemini via OpenAI-compat endpoint -- free tier, 15 RPM daily cap
12
+ mistral : Mistral AI -- mistral-small is strong at structured JSON
13
+ cohere : Cohere -- command-r7b fastest (326ms), good at extraction
14
+ nvidia : NVIDIA NIM -- llama-3.1-8b, solid and fast
15
+ cerebras : Cerebras -- zai-glm-4.7 is a reasoning model (needs higher token budget)
16
+ cloudflare : Cloudflare Workers AI -- needs CLOUDFLARE_ID in .env
17
+ huggingface: HF Inference via featherless-ai -- slowest, cold-start ~14s
18
+ sambanova : SambaNova -- has DeepSeek-V3 + Llama-4, fast on warm runs
19
+ openrouter : OpenRouter -- free models available, quality varies
20
+ deepseek : DeepSeek -- needs paid balance (402 currently)
21
+
22
+ JSON mode support
23
+ -----------------
24
+ Providers that natively support response_format=json_object are flagged
25
+ json_mode=True. Others receive a JSON-enforcement suffix in the system prompt
26
+ and the response is extracted via regex fallback if needed.
27
+ """
28
+
29
+ from __future__ import annotations
30
+ import os, re, json
31
+ from typing import Optional
32
+ from openai import OpenAI
33
+ from env_util import load_env
34
+
35
+ load_env()
36
+
37
+
38
+ # ── Provider registry ─────────────────────────────────────────────────────────
39
+ # Each entry: base_url, key_env, default_model, json_mode, extra_kwargs
40
+ PROVIDERS: dict[str, dict] = {
41
+ "github": {
42
+ "base_url": "https://models.github.ai/inference",
43
+ "key_env": "GITHUB_TOKEN",
44
+ "default_model": "openai/gpt-4o-mini",
45
+ "json_mode": True,
46
+ },
47
+ "openai": {
48
+ "base_url": None,
49
+ "key_env": "OPENAI_API_KEY",
50
+ "default_model": "gpt-4o-mini",
51
+ "json_mode": True,
52
+ },
53
+ "gemini": {
54
+ "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
55
+ "key_env": "GEMINI_API_KEY",
56
+ "default_model": "gemini-2.0-flash",
57
+ "json_mode": True,
58
+ },
59
+ "mistral": {
60
+ "base_url": "https://api.mistral.ai/v1",
61
+ "key_env": "MISTRAL_API_KEY",
62
+ "default_model": "mistral-small-latest",
63
+ "json_mode": True,
64
+ },
65
+ "cohere": {
66
+ "base_url": "https://api.cohere.com/compatibility/v1",
67
+ "key_env": "COHERE_API_KEY",
68
+ "default_model": "command-r7b-12-2024",
69
+ "json_mode": False, # prompt-based JSON
70
+ },
71
+ "nvidia": {
72
+ "base_url": "https://integrate.api.nvidia.com/v1",
73
+ "key_env": "NVIDIA_API_KEY",
74
+ "default_model": "meta/llama-3.1-8b-instruct",
75
+ "json_mode": False,
76
+ },
77
+ "cerebras": {
78
+ "base_url": "https://api.cerebras.ai/v1",
79
+ "key_env": "CEREBRAS_API_KEY",
80
+ "default_model": "zai-glm-4.7",
81
+ "json_mode": False,
82
+ "reasoning": True, # needs higher token budget for chain-of-thought
83
+ },
84
+ "cloudflare": {
85
+ "base_url": None, # built dynamically using CLOUDFLARE_ID
86
+ "key_env": "CLOUDFLARE_API_TOKEN",
87
+ "default_model": "@cf/meta/llama-3.1-8b-instruct",
88
+ "json_mode": False,
89
+ },
90
+ "huggingface": {
91
+ "base_url": "https://router.huggingface.co/featherless-ai/v1",
92
+ "key_env": "HUGGINGFACE_API_TOKEN",
93
+ "default_model": "meta-llama/Llama-3.1-8B-Instruct",
94
+ "json_mode": False,
95
+ },
96
+ "sambanova": {
97
+ "base_url": "https://api.sambanova.ai/v1",
98
+ "key_env": "SAMBANOVA_API_KEY",
99
+ "default_model": "Meta-Llama-3.3-70B-Instruct",
100
+ "json_mode": False,
101
+ },
102
+ "openrouter": {
103
+ "base_url": "https://openrouter.ai/api/v1",
104
+ "key_env": "OPENROUTER_API_KEY",
105
+ "default_model": "nvidia/nemotron-3-ultra-550b-a55b:free",
106
+ "json_mode": False,
107
+ },
108
+ "deepseek": {
109
+ "base_url": "https://api.deepseek.com/v1",
110
+ "key_env": "DEEPSEEK_API_KEY",
111
+ "default_model": "deepseek-chat",
112
+ "json_mode": True,
113
+ },
114
+ }
115
+
116
+ JSON_ENFORCE_SUFFIX = (
117
+ "\n\nCRITICAL: Your response must be a single valid JSON object. "
118
+ "No markdown fences, no commentary, no preamble. Start with { and end with }."
119
+ )
120
+
121
+
122
+ def get_client(provider: str) -> tuple[OpenAI, dict]:
123
+ """Return (OpenAI client, provider_config)."""
124
+ if provider not in PROVIDERS:
125
+ raise ValueError(f"Unknown provider '{provider}'. Choose from: {list(PROVIDERS)}")
126
+ cfg = PROVIDERS[provider]
127
+
128
+ key_env = cfg.get("key_env")
129
+ api_key = os.environ.get(key_env, "") if key_env else "local"
130
+ if key_env and not api_key:
131
+ raise SystemExit(f"Missing {key_env} in .env for provider '{provider}'")
132
+
133
+ base_url = cfg["base_url"]
134
+ if provider == "cloudflare":
135
+ account_id = os.environ.get("CLOUDFLARE_ID", "")
136
+ if not account_id:
137
+ raise SystemExit("Missing CLOUDFLARE_ID in .env")
138
+ base_url = f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1"
139
+
140
+ kwargs: dict = {"api_key": api_key}
141
+ if base_url:
142
+ kwargs["base_url"] = base_url
143
+
144
+ return OpenAI(**kwargs), cfg
145
+
146
+
147
+ def _extract_json(text: str) -> str:
148
+ """Strip markdown fences and extract the first {...} block."""
149
+ text = text.strip()
150
+ # strip ```json ... ``` or ``` ... ```
151
+ text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.MULTILINE)
152
+ text = re.sub(r"\s*```$", "", text, flags=re.MULTILINE)
153
+ text = text.strip()
154
+ # find first { ... } spanning the whole string
155
+ start = text.find("{")
156
+ if start == -1:
157
+ return text
158
+ depth, end = 0, -1
159
+ for i, ch in enumerate(text[start:], start):
160
+ if ch == "{":
161
+ depth += 1
162
+ elif ch == "}":
163
+ depth -= 1
164
+ if depth == 0:
165
+ end = i
166
+ break
167
+ return text[start:end + 1] if end != -1 else text[start:]
168
+
169
+
170
+ def chat_json(
171
+ provider: str,
172
+ system: str,
173
+ user: str,
174
+ model: Optional[str] = None,
175
+ temperature: float = 0.1,
176
+ max_tokens: int = 4000,
177
+ ) -> str:
178
+ """
179
+ Single chat completion that returns a JSON string.
180
+
181
+ For providers with native json_mode, passes response_format=json_object.
182
+ For others, appends a JSON-enforcement instruction and strips fences from
183
+ the response. Either way the caller gets a raw JSON string to parse.
184
+ """
185
+ client, cfg = get_client(provider)
186
+ chosen_model = model or cfg["default_model"]
187
+ use_json_mode = cfg.get("json_mode", False)
188
+
189
+ # reasoning models need a much larger token budget
190
+ if cfg.get("reasoning") and max_tokens < 2048:
191
+ max_tokens = 2048
192
+
193
+ sys_msg = system if use_json_mode else system + JSON_ENFORCE_SUFFIX
194
+
195
+ kwargs: dict = {
196
+ "model": chosen_model,
197
+ "messages": [{"role": "system", "content": sys_msg},
198
+ {"role": "user", "content": user}],
199
+ "temperature": temperature,
200
+ "max_tokens": max_tokens,
201
+ }
202
+ if use_json_mode:
203
+ kwargs["response_format"] = {"type": "json_object"}
204
+
205
+ resp = client.chat.completions.create(**kwargs)
206
+ raw = resp.choices[0].message.content or ""
207
+ return _extract_json(raw) if not use_json_mode else raw.strip()
208
+
209
+
210
+ def list_providers() -> list[str]:
211
+ return list(PROVIDERS.keys())
212
+
213
+
214
+ def default_model(provider: str) -> str:
215
+ return PROVIDERS[provider]["default_model"]
216
+
217
+
218
+ # ── Quick smoke test ──────────────────────────────────────────────────────────
219
+ if __name__ == "__main__":
220
+ import time, argparse
221
+ ap = argparse.ArgumentParser(description="Ping all providers or a specific one.")
222
+ ap.add_argument("--provider", default=None, help="test one provider only")
223
+ ap.add_argument("--prompt", default="Reply with valid JSON: {\"status\": \"ok\", \"msg\": \"hello\"}")
224
+ args = ap.parse_args()
225
+
226
+ targets = [args.provider] if args.provider else list(PROVIDERS.keys())
227
+ print(f"\n {'Provider':<14} {'Model':<42} {'ms':>6} {'Result'}")
228
+ print(" " + "-" * 90)
229
+
230
+ for p in targets:
231
+ cfg = PROVIDERS[p]
232
+ mod = cfg["default_model"]
233
+ try:
234
+ t0 = time.time()
235
+ raw = chat_json(p, "You are a helpful assistant.", args.prompt)
236
+ ms = int((time.time() - t0) * 1000)
237
+ # try to parse to confirm it's valid JSON
238
+ parsed = json.loads(raw)
239
+ print(f" [OK] {p:<12} {mod:<42} {ms:>6}ms {str(parsed)[:60]}")
240
+ except SystemExit as e:
241
+ print(f" [--] {p:<12} {mod:<42} {'missing key -- skipped'}")
242
+ except Exception as e:
243
+ print(f" [XX] {p:<12} {mod:<42} {str(e)[:70]}")
ml-services/evaluation/nodes.py ADDED
@@ -0,0 +1,517 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Node functions for the graph-based evaluation pipeline.
3
+
4
+ Each node is a callable that takes EvalState and returns a partial state dict.
5
+ LLM nodes use a shared retry helper; deterministic nodes (assemble, aggregate,
6
+ anchor) do pure data transformation.
7
+ """
8
+ import os
9
+ import json
10
+ import time
11
+
12
+ from pydantic import ValidationError
13
+
14
+ import paths
15
+ from env_util import load_env
16
+ from assemble import load_manifest, build_packet, assemble_turns, estimate_duration
17
+ from pydantic import BaseModel
18
+ from typing import List, Literal
19
+
20
+ from rubric import (
21
+ ComplianceChecklist, QualityDimensions, EscalationRisk,
22
+ InvestigationReport, CallMetadata, CallEvaluation, RUBRIC_VERSION_GRAPH,
23
+ CallWorkflow, WorkflowStep, Evidence,
24
+ )
25
+ from prompts import (
26
+ COMPLIANCE_SYSTEM, COMPLIANCE_SKELETON,
27
+ QUALITY_SYSTEM, QUALITY_SKELETON,
28
+ ESCALATION_SYSTEM, ESCALATION_SKELETON,
29
+ INVESTIGATE_SYSTEM, INVESTIGATE_SKELETON,
30
+ ARBITRATE_SYSTEM, ARBITRATE_SKELETON,
31
+ SUBJECT_SYSTEM, SUBJECT_SKELETON,
32
+ WORKFLOW_GEN_SYSTEM, WORKFLOW_GEN_SKELETON,
33
+ WORKFLOW_CHECK_SYSTEM, WORKFLOW_CHECK_SKELETON,
34
+ WORKFLOW_RECHECK_SYSTEM, WORKFLOW_RECHECK_SKELETON,
35
+ )
36
+
37
+ load_env()
38
+ DATA = str(paths.NA_TESTSET)
39
+
40
+
41
+ # ─────────────────────────────────────────────────────────────────────────────
42
+ # Shared LLM call + retry helper
43
+ # ─────────────────────────────────────────────────────────────────────────────
44
+
45
+ def _strip_fences(s):
46
+ s = s.strip()
47
+ if s.startswith("```"):
48
+ s = s.split("```", 2)[1] if "```" in s[3:] else s[3:]
49
+ if s.startswith("json"):
50
+ s = s[4:]
51
+ s = s.rsplit("```", 1)[0]
52
+ return s.strip()
53
+
54
+
55
+ def _feedback_text(quotes):
56
+ """Build a revision note for a node whose cited quotes failed anchoring."""
57
+ if not quotes:
58
+ return None
59
+ listed = "\n".join(f'- "{q}"' for q in quotes)
60
+ return ("REVISION NOTE: in a previous evaluation of this call, the "
61
+ "following cited quotes were NOT found verbatim in the "
62
+ f"transcript:\n{listed}\n"
63
+ "Re-evaluate. Every quote must be copied EXACTLY, character for "
64
+ "character, from a single transcript turn. Do not paraphrase, "
65
+ "do not merge text from multiple turns.")
66
+
67
+
68
+ # provider escalation order for eval nodes: a malformed/invalid 200-OK
69
+ # response is a model-quality failure the transport-level router never sees,
70
+ # so retrying the same provider on the same content fails identically. We
71
+ # escalate the PROVIDER per attempt instead -- a different model's JSON
72
+ # behaviour almost always parses where another's choked.
73
+ _TIER_SEQUENCE = ("qa-primary", "qa-fallback", "qa-safety")
74
+
75
+
76
+ def _llm_eval_with_retry(system, skeleton, packet, validator, max_attempts=3,
77
+ feedback=None):
78
+ """
79
+ Call the LLM router, parse JSON, validate with `validator`, retry on
80
+ failure with error feedback AND provider escalation. Returns
81
+ (validated_model, dt, served_model). `feedback` is an optional revision
82
+ note (e.g. failed anchor quotes) appended to the user prompt.
83
+ """
84
+ from router import chat_json_routed
85
+
86
+ user = f"{packet}\n\n{skeleton}"
87
+ if feedback:
88
+ user += f"\n\n{feedback}"
89
+ last_err = None
90
+
91
+ for attempt in range(1, max_attempts + 1):
92
+ tier = _TIER_SEQUENCE[min(attempt - 1, len(_TIER_SEQUENCE) - 1)]
93
+ t0 = time.time()
94
+ raw, served = chat_json_routed(system, user, return_meta=True, tier=tier)
95
+ dt = time.time() - t0
96
+ try:
97
+ data = json.loads(_strip_fences(raw))
98
+ result = validator(data)
99
+ return result, dt, served
100
+ except (json.JSONDecodeError, ValidationError, ValueError) as e:
101
+ last_err = e
102
+ print(f" [{system[:40]}...] attempt {attempt} ({tier}) failed "
103
+ f"({type(e).__name__}); escalating provider...")
104
+ user = (f"{packet}\n\n{skeleton}"
105
+ + (f"\n\n{feedback}" if feedback else "")
106
+ + f"\n\nYour previous output was invalid: "
107
+ f"{str(e)[:400]}. Return corrected JSON only.")
108
+
109
+ raise RuntimeError(f"Node failed after {max_attempts} attempts: {last_err}")
110
+
111
+
112
+ # ─────────────────────────────────────────────────────────────────────────────
113
+ # Deterministic nodes
114
+ # ─────────────────────────────────────────────────────────────────────────────
115
+
116
+ def _runs(state, node):
117
+ """How many times `node` has executed in this invocation (1-based)."""
118
+ return ((state.get("node_meta") or {}).get(node, {}).get("runs", 0)) + 1
119
+
120
+
121
+ def assemble_node(state):
122
+ """Load transcript, build packet and turns for downstream nodes."""
123
+ t0 = time.time()
124
+ call_id = state["call_id"]
125
+ results_dir = state.get("results_dir", "results_channels")
126
+ manifest = load_manifest()
127
+ meta = manifest[call_id]
128
+
129
+ result_path = os.path.join(DATA, results_dir, meta["accent"],
130
+ call_id + ".json")
131
+ with open(result_path, encoding="utf-8") as f:
132
+ result = json.load(f)
133
+
134
+ packet, stats = build_packet(result, meta)
135
+ turns = assemble_turns(result)
136
+
137
+ cm = CallMetadata(
138
+ call_id=call_id,
139
+ domain=result.get("domain", meta.get("domain", "unknown")),
140
+ accent=meta.get("accent"),
141
+ duration_seconds=stats["duration_s"],
142
+ transcript_model=result.get("model"),
143
+ )
144
+
145
+ return {
146
+ "packet": packet,
147
+ "meta": cm.model_dump(),
148
+ "turns": turns,
149
+ "node_meta": {"assemble": {"duration": round(time.time() - t0, 2)}},
150
+ }
151
+
152
+
153
+ def aggregate_node(state):
154
+ """Merge parallel node outputs into a single CallEvaluation."""
155
+ ev = CallEvaluation(
156
+ rubric_version=RUBRIC_VERSION_GRAPH,
157
+ metadata=CallMetadata(**state["meta"]),
158
+ compliance=state["compliance"],
159
+ quality=state["quality"],
160
+ escalation=state["escalation"],
161
+ workflow=state.get("workflow"),
162
+ overall_summary=None,
163
+ )
164
+ return {"evaluation": ev.model_dump()}
165
+
166
+
167
+ def _collect_unanchored(node, out):
168
+ """Recursively collect evidence quotes that anchoring could not locate."""
169
+ if isinstance(node, dict):
170
+ if "quote" in node and "speaker" in node and node.get("sec") is None:
171
+ out.append(node["quote"])
172
+ else:
173
+ for v in node.values():
174
+ _collect_unanchored(v, out)
175
+ elif isinstance(node, list):
176
+ for v in node:
177
+ _collect_unanchored(v, out)
178
+
179
+
180
+ def anchor_node(state):
181
+ """
182
+ Attach real transcript timestamps to every evidence quote.
183
+ Also reports WHICH sections cited quotes that could not be located, so
184
+ the graph can route those sections back for re-evaluation (v0.3.0).
185
+ """
186
+ from eval_call_data import anchor_evidence
187
+
188
+ ev_dict = state["evaluation"]
189
+ if not isinstance(ev_dict, dict):
190
+ ev_dict = ev_dict.model_dump()
191
+
192
+ turns_indexed = [{"i": i, **t} for i, t in enumerate(state["turns"])]
193
+ stats = {"total": 0, "anchored": 0}
194
+ anchor_evidence(ev_dict, turns_indexed, stats)
195
+ ev_dict["_anchor_stats"] = stats
196
+
197
+ # per-section unanchored quotes -> feedback for the re-anchor loop
198
+ feedback = {}
199
+ for section in ("compliance", "quality", "escalation"):
200
+ missing = []
201
+ _collect_unanchored(ev_dict.get(section, {}), missing)
202
+ if missing:
203
+ feedback[section] = missing
204
+
205
+ attempts = state.get("anchor_attempts", 0) + 1
206
+ if feedback:
207
+ n = sum(len(v) for v in feedback.values())
208
+ print(f" [anchor] pass {attempts}: {n} quote(s) not found verbatim "
209
+ f"in sections: {', '.join(feedback)}")
210
+ else:
211
+ print(f" [anchor] pass {attempts}: all quotes anchored")
212
+
213
+ return {
214
+ "evaluation": ev_dict,
215
+ "anchor_attempts": attempts,
216
+ "anchor_feedback": feedback,
217
+ }
218
+
219
+
220
+ # ─────────────────────────────────────────────────────────────────────────────
221
+ # LLM nodes — each validates its own Pydantic fragment
222
+ # ─────────────────────────────────────────────────────────────────────────────
223
+
224
+ def _section_feedback(state, section):
225
+ """Revision note if this section's quotes failed anchoring last pass."""
226
+ fb = (state.get("anchor_feedback") or {}).get(section)
227
+ if fb:
228
+ print(f" [{section}] re-running with {len(fb)} failed-quote note(s)")
229
+ return _feedback_text(fb)
230
+
231
+
232
+ def compliance_node(state):
233
+ """Evaluate 6 compliance checks."""
234
+ result, dt, served = _llm_eval_with_retry(
235
+ system=COMPLIANCE_SYSTEM,
236
+ skeleton=COMPLIANCE_SKELETON,
237
+ packet=state["packet"],
238
+ validator=lambda d: ComplianceChecklist.model_validate(d),
239
+ feedback=_section_feedback(state, "compliance"),
240
+ )
241
+ print(f" [compliance] done in {dt:.1f}s via {served}")
242
+ return {"compliance": result,
243
+ "node_meta": {"compliance": {"duration": round(dt, 1),
244
+ "served_by": served,
245
+ "runs": _runs(state, "compliance")}}}
246
+
247
+
248
+ def quality_node(state):
249
+ """Score 6 quality dimensions with enriched signal guidance."""
250
+ result, dt, served = _llm_eval_with_retry(
251
+ system=QUALITY_SYSTEM,
252
+ skeleton=QUALITY_SKELETON,
253
+ packet=state["packet"],
254
+ validator=lambda d: QualityDimensions.model_validate(d),
255
+ feedback=_section_feedback(state, "quality"),
256
+ )
257
+ print(f" [quality] done in {dt:.1f}s via {served}")
258
+ return {"quality": result,
259
+ "node_meta": {"quality": {"duration": round(dt, 1),
260
+ "served_by": served,
261
+ "runs": _runs(state, "quality")}}}
262
+
263
+
264
+ def escalation_node(state):
265
+ """Assess escalation risk: red flags, emotion, risk level."""
266
+ result, dt, served = _llm_eval_with_retry(
267
+ system=ESCALATION_SYSTEM,
268
+ skeleton=ESCALATION_SKELETON,
269
+ packet=state["packet"],
270
+ validator=lambda d: EscalationRisk.model_validate(d),
271
+ feedback=_section_feedback(state, "escalation"),
272
+ )
273
+ print(f" [escalation] done in {dt:.1f}s via {served}")
274
+ return {"escalation": result,
275
+ "node_meta": {"escalation": {"duration": round(dt, 1),
276
+ "served_by": served,
277
+ "runs": _runs(state, "escalation")}}}
278
+
279
+
280
+ def investigate_node(state):
281
+ """
282
+ Conditional deep-dive (v0.3.0): runs ONLY when the anchored evaluation
283
+ carries risk_level != "none". Produces a manager-ready incident report
284
+ and attaches it to the evaluation.
285
+ """
286
+ ev = state["evaluation"]
287
+ esc = ev["escalation"]
288
+ context = (
289
+ f"{state['packet']}\n\n"
290
+ f"FIRST-PASS ESCALATION SCREENING RESULT:\n"
291
+ f"{json.dumps({k: esc[k] for k in ('red_flags', 'customer_emotion_text', 'risk_level')}, indent=2)}"
292
+ )
293
+ report, dt, served = _llm_eval_with_retry(
294
+ system=INVESTIGATE_SYSTEM,
295
+ skeleton=INVESTIGATE_SKELETON,
296
+ packet=context,
297
+ validator=lambda d: InvestigationReport.model_validate(d),
298
+ )
299
+ print(f" [investigate] done in {dt:.1f}s via {served} "
300
+ f"(priority: {report.priority})")
301
+
302
+ # anchor the report's own evidence quotes too
303
+ from eval_call_data import anchor_evidence
304
+ report_dict = report.model_dump()
305
+ turns_indexed = [{"i": i, **t} for i, t in enumerate(state["turns"])]
306
+ anchor_evidence(report_dict, turns_indexed, {"total": 0, "anchored": 0})
307
+
308
+ ev["investigation"] = report_dict
309
+ return {"evaluation": ev,
310
+ "node_meta": {"investigate": {"duration": round(dt, 1),
311
+ "served_by": served,
312
+ "priority": report.priority}}}
313
+
314
+
315
+ # ── workflow track (v0.4.0) — three isolated LLM phases in one node ─────────
316
+
317
+ class _Subject(BaseModel):
318
+ subject: str
319
+
320
+
321
+ class _StepsDraft(BaseModel):
322
+ expected_steps: List[WorkflowStep]
323
+
324
+
325
+ def workflow_node(state):
326
+ """
327
+ Subject-derived expected workflow (v0.4.0). Three phases with strict
328
+ prompt isolation:
329
+ a. subject — reads the transcript, states the REQUEST in one sentence
330
+ b. expected — sees ONLY domain + subject (never the transcript), lists
331
+ the boxes a competent agent should check for such a call
332
+ c. check — audits the transcript against that independent checklist
333
+ The isolation in (b) is the point: expectations are formed before knowing
334
+ what actually happened, so the audit cannot rationalize the observed call.
335
+ """
336
+ domain = state["meta"]["domain"]
337
+
338
+ subj, dt_a, served_a = _llm_eval_with_retry(
339
+ system=SUBJECT_SYSTEM, skeleton=SUBJECT_SKELETON,
340
+ packet=state["packet"],
341
+ validator=lambda d: _Subject.model_validate(d))
342
+ print(f' [workflow] subject in {dt_a:.1f}s: "{subj.subject}"')
343
+
344
+ # phase b: NO transcript — only domain + subject
345
+ gen_packet = f"DOMAIN: {domain}\nCALL SUBJECT: {subj.subject}"
346
+ draft, dt_b, served_b = _llm_eval_with_retry(
347
+ system=WORKFLOW_GEN_SYSTEM, skeleton=WORKFLOW_GEN_SKELETON,
348
+ packet=gen_packet,
349
+ validator=lambda d: _StepsDraft.model_validate(d))
350
+ print(f" [workflow] {len(draft.expected_steps)} expected steps in {dt_b:.1f}s")
351
+
352
+ checklist = json.dumps(
353
+ {"expected_steps": [{"step": s.step, "rationale": s.rationale}
354
+ for s in draft.expected_steps]}, indent=2)
355
+ check_packet = (f"EXPECTED WORKFLOW (written without seeing this call):\n"
356
+ f"{checklist}\n\nTRANSCRIPT:\n{state['packet']}")
357
+ # completeness is part of validity: a truncated audit (fewer entries than
358
+ # the draft) would otherwise be silently padded with met=null at the join
359
+ def _check_validator(d):
360
+ r = _StepsDraft.model_validate(d)
361
+ if len(r.expected_steps) != len(draft.expected_steps):
362
+ raise ValueError(
363
+ f"audit returned {len(r.expected_steps)} entries for "
364
+ f"{len(draft.expected_steps)} steps; return one entry per "
365
+ f"given step, same order")
366
+ return r
367
+
368
+ checked, dt_c, served_c = _llm_eval_with_retry(
369
+ system=WORKFLOW_CHECK_SYSTEM, skeleton=WORKFLOW_CHECK_SKELETON,
370
+ packet=check_packet, validator=_check_validator)
371
+
372
+ # join by index against the DRAFT's step text — the draft is the contract;
373
+ # the checker only contributes met/evidence. Missing entries stay unchecked.
374
+ steps = []
375
+ for i, ds in enumerate(draft.expected_steps):
376
+ cs = checked.expected_steps[i] if i < len(checked.expected_steps) else None
377
+ steps.append(WorkflowStep(
378
+ step=ds.step, rationale=ds.rationale,
379
+ met=cs.met if cs else None,
380
+ evidence=cs.evidence if cs else None))
381
+
382
+ # single-item recheck pass: fires ONLY on met=false steps, isolated from
383
+ # the other steps so it can never perturb something the main pass already
384
+ # got right. Catches the specific failure mode where the main pass wants
385
+ # a direct question and misses that the value was established via a
386
+ # later read-back/confirmation instead.
387
+ def _recheck_validator(d):
388
+ if "met" not in d:
389
+ raise ValueError("missing met")
390
+ return d
391
+
392
+ dt_d = 0.0
393
+ for s in steps:
394
+ if s.met is not False:
395
+ continue
396
+ recheck_packet = (
397
+ f"CHECKLIST ITEM: {s.step}\n"
398
+ f"RATIONALE: {s.rationale}\n"
399
+ f"FIRST-PASS VERDICT: met=false\n\n"
400
+ f"TRANSCRIPT:\n{state['packet']}")
401
+ try:
402
+ rc, dt_r, _ = _llm_eval_with_retry(
403
+ system=WORKFLOW_RECHECK_SYSTEM, skeleton=WORKFLOW_RECHECK_SKELETON,
404
+ packet=recheck_packet, validator=_recheck_validator)
405
+ except RuntimeError:
406
+ continue
407
+ dt_d += dt_r
408
+ if rc.get("met") is True and rc.get("evidence"):
409
+ s.met = True
410
+ s.evidence = Evidence.model_validate(rc["evidence"])
411
+
412
+ met = sum(1 for s in steps if s.met is True)
413
+ missed = sum(1 for s in steps if s.met is False)
414
+ print(f" [workflow] audit in {dt_c:.1f}s: {met} met, {missed} missed, "
415
+ f"{len(steps) - met - missed} n/a of {len(steps)}"
416
+ + (f" (+{dt_d:.1f}s recheck)" if dt_d else ""))
417
+
418
+ dt = dt_a + dt_b + dt_c + dt_d
419
+ return {"workflow": CallWorkflow(subject=subj.subject,
420
+ expected_steps=steps).model_dump(),
421
+ "node_meta": {"workflow": {"duration": round(dt, 1),
422
+ "served_by": served_c,
423
+ "phases": {"subject": round(dt_a, 1),
424
+ "expected": round(dt_b, 1),
425
+ "check": round(dt_c, 1)},
426
+ "runs": _runs(state, "workflow")}}}
427
+
428
+
429
+ def fuse_node(state):
430
+ """
431
+ Deterministic acoustic-text fusion (v0.4.0). Loads the audio sentiment
432
+ model's output (if any) and fuses it into the three audio-informed
433
+ quality dimensions + the escalation risk level, attaching provenance.
434
+ Runs between aggregate and anchor so the fused risk drives routing.
435
+ """
436
+ from fusion import load_acoustic, fuse_evaluation
437
+ t0 = time.time()
438
+ ev = state["evaluation"]
439
+ rows = load_acoustic(state["call_id"], state["meta"]["domain"])
440
+ summary = fuse_evaluation(ev, rows)
441
+ if summary["acoustic_available"]:
442
+ print(f" [fuse] acoustic data found: fused "
443
+ f"{', '.join(summary['fused_dims']) or 'nothing'}; "
444
+ f"acoustic risk = {summary['acoustic_risk']}")
445
+ else:
446
+ print(" [fuse] no acoustic data for this call -> text-only scores")
447
+ return {"evaluation": ev,
448
+ "fusion_disputed": summary["disputed"],
449
+ "node_meta": {"fuse": {"duration": round(time.time() - t0, 2),
450
+ **summary}}}
451
+
452
+
453
+ class _Arbitration(BaseModel):
454
+ risk_level: Literal["none", "review", "escalate"]
455
+ rationale: str
456
+
457
+
458
+ def arbitrate_node(state):
459
+ """
460
+ Conditional arbitration (v0.4.1): runs ONLY when the text tier and the
461
+ acoustic tier disagree about escalation risk. Deterministic merging is
462
+ fine when the channels agree; when they disagree, a fixed rule has no
463
+ basis to prefer one -- an LLM weighs both signals against the transcript
464
+ (was the issue resolved? does vocal escalation RISE late in the call?).
465
+ """
466
+ ev = state["evaluation"]
467
+ esc = ev["escalation"]
468
+ h = esc.get("hybrid") or {}
469
+ traj = (ev.get("_acoustic") or {}).get("trajectory") or []
470
+ # compact trajectory line: customer escalation score per position bucket
471
+ esc_series = " ".join(f"{t['p']:.2f}:{t['esc']:.2f}"
472
+ for t in traj if t.get("esc") is not None)
473
+
474
+ context = (
475
+ f"{state['packet']}\n\n"
476
+ f"TEXT ASSESSMENT:\n"
477
+ f"{json.dumps({k: esc[k] for k in ('red_flags', 'customer_emotion_text', 'risk_level')}, indent=2)}\n\n"
478
+ f"ACOUSTIC ASSESSMENT (audio model, customer channel):\n"
479
+ f" tier: {h.get('acoustic_risk')}\n"
480
+ f" mean escalation over final third: {h.get('late_mean_escalation')}\n"
481
+ f" peak escalation: {h.get('peak_escalation')}\n"
482
+ f" trajectory (position:score, 0=start 1=end): {esc_series or 'n/a'}"
483
+ )
484
+
485
+ verdict, dt, served = _llm_eval_with_retry(
486
+ system=ARBITRATE_SYSTEM, skeleton=ARBITRATE_SKELETON,
487
+ packet=context,
488
+ validator=lambda d: _Arbitration.model_validate(d))
489
+
490
+ print(f" [arbitrate] text={h.get('text_risk')} vs "
491
+ f"acoustic={h.get('acoustic_risk')} -> {verdict.risk_level} "
492
+ f"in {dt:.1f}s via {served}")
493
+
494
+ esc["risk_level"] = verdict.risk_level
495
+ esc["hybrid"] = {**h, "method": "llm_arbitration",
496
+ "arbitration_rationale": verdict.rationale}
497
+ return {"evaluation": ev,
498
+ "node_meta": {"arbitrate": {"duration": round(dt, 1),
499
+ "served_by": served,
500
+ "verdict": verdict.risk_level}}}
501
+
502
+
503
+ def chapter_node(state):
504
+ """Segment the call into coherent chapters (reuses segment.py)."""
505
+ from segment import segment
506
+ cc, dt, issues = segment(
507
+ state["call_id"],
508
+ provider="router",
509
+ results_dir=state.get("results_dir", "results_channels"),
510
+ )
511
+ if issues:
512
+ print(f" [chapters] {len(issues)} invariant issue(s): {issues}")
513
+ print(f" [chapters] {cc.n_chapters} chapters in {dt:.1f}s")
514
+ return {"chapters": cc.model_dump(),
515
+ "node_meta": {"chapters": {"duration": round(dt, 1),
516
+ "served_by": "router",
517
+ "runs": _runs(state, "chapters")}}}
ml-services/evaluation/paths.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Central path configuration for the ml-services pipeline.
2
+
3
+ Every location the pipeline reads or writes derives from two roots:
4
+
5
+ REPO_ROOT -- the repository checkout, derived from this file's location.
6
+ DATA_ROOT -- the data directory; env-overridable (CAPSTONE_DATA_ROOT) so a
7
+ hosted container can point it at a writable scratch volume.
8
+ Defaults to {REPO_ROOT}/data, the local dev layout.
9
+
10
+ This module also loads .env (repo root) into os.environ at import time,
11
+ via setdefault -- real environment variables always win over .env entries.
12
+ Doing the load here (rather than in env_util) guarantees CAPSTONE_* keys in
13
+ .env are honored regardless of module import order; env_util.load_env stays
14
+ as a re-export for existing callers.
15
+ """
16
+ import os
17
+ from pathlib import Path
18
+
19
+ REPO_ROOT = Path(__file__).resolve().parents[2]
20
+ ENV_FILE = Path(os.environ.get("CAPSTONE_ENV_FILE", REPO_ROOT / ".env"))
21
+
22
+
23
+ def load_env(path=None):
24
+ """Load KEY=VALUE lines from a .env file into os.environ (setdefault)."""
25
+ path = Path(path) if path else ENV_FILE
26
+ vals = {}
27
+ if path.exists():
28
+ for line in path.read_text(encoding="utf-8").splitlines():
29
+ line = line.strip()
30
+ if not line or line.startswith("#") or "=" not in line:
31
+ continue
32
+ k, v = line.split("=", 1)
33
+ vals[k.strip()] = v.strip()
34
+ os.environ.setdefault(k.strip(), v.strip())
35
+ return vals
36
+
37
+
38
+ load_env()
39
+
40
+ DATA_ROOT = Path(os.environ.get("CAPSTONE_DATA_ROOT", str(REPO_ROOT / "data")))
41
+
42
+ NA_TESTSET = DATA_ROOT / "na_testset"
43
+ MANIFEST = NA_TESTSET / "manifest.json"
44
+ # Registry for calls ingested at runtime (fresh uploads); merged over MANIFEST
45
+ # by assemble.load_manifest so every downstream consumer sees them.
46
+ RUNTIME_MANIFEST = NA_TESTSET / "manifest_runtime.json"
47
+
48
+ SENTIMENT_ROOT = DATA_ROOT / "sentiment"
49
+ SENTENCE_SEG_ROOT = DATA_ROOT / "sentence_segments"
50
+
51
+ EVAL_RESULTS = Path(os.environ.get(
52
+ "CAPSTONE_EVAL_RESULTS", str(Path(__file__).resolve().parent / "results")))
53
+
54
+ FRONTEND_ROOT = REPO_ROOT / "frontend"
55
+ FRONTEND_PUBLIC = Path(os.environ.get(
56
+ "CAPSTONE_FRONTEND_PUBLIC", str(FRONTEND_ROOT / "public")))
ml-services/evaluation/prompts.py ADDED
@@ -0,0 +1,477 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Focused system prompts for the graph-based evaluation pipeline.
3
+
4
+ Each prompt covers ONE evaluation domain (compliance / quality / escalation)
5
+ so the LLM concentrates on a narrower task with richer signal guidance.
6
+ The monolithic prompt in extract.py is preserved for backward compatibility.
7
+ """
8
+ from rubric import RUBRIC_VERSION_GRAPH
9
+
10
+ # ─────────────────────────────────────────────────────────────────────────────
11
+ # COMPLIANCE — 6 binary checks, strict evidence requirement
12
+ # ─────────────────────────────────────────────────────────────────────────────
13
+
14
+ COMPLIANCE_SYSTEM = f"""\
15
+ You are a call-center compliance auditor. You evaluate ONE customer-service \
16
+ call transcript against 6 mandatory compliance checks and output ONLY a JSON \
17
+ object. Rubric version {RUBRIC_VERSION_GRAPH}.
18
+
19
+ You are given a ROLE-MAPPED transcript: every line is tagged \
20
+ [AGENT mm:ss] or [CUSTOMER mm:ss]. Channel identity is authoritative.
21
+
22
+ CHECKS (passed = true / false / null). null ONLY when the item does not \
23
+ apply to this call.
24
+
25
+ 1. name_announced
26
+ Agent stated their own name at the start of the call.
27
+
28
+ 2. company_announced
29
+ Agent stated the company or bank name.
30
+
31
+ 3. recording_disclosure
32
+ Agent said the call may be recorded or monitored.
33
+ If never stated, passed=false (do NOT assume it happened off-mic).
34
+ Use null only if clearly not required for this call type.
35
+
36
+ 4. identity_verified
37
+ Agent verified the customer's identity BEFORE discussing account details.
38
+ Accepted methods: last 4 digits of account, full account number,
39
+ customer ID, date of birth, security question.
40
+ Set identity_method to the method used (e.g. "date of birth").
41
+
42
+ 5. resolution_provided
43
+ Agent gave a clear resolution or explicit next steps before closing.
44
+
45
+ 6. transfer_next_steps
46
+ If the call was transferred, agent explained what happens next and
47
+ where the customer is being transferred to.
48
+ passed=null if there was no transfer in this call.
49
+
50
+ RULES:
51
+ 1. EVIDENCE: every true or false MUST cite a verbatim quote copied EXACTLY \
52
+ from the transcript, with speaker and timestamp. Never invent or paraphrase.
53
+ 2. CONSERVATISM: do not mark true unless the transcript explicitly shows it. \
54
+ If ambiguous, mark false and explain in the note field.
55
+ 3. OUTPUT: a single JSON object, no markdown fences, no commentary."""
56
+
57
+ COMPLIANCE_SKELETON = """\
58
+ Return EXACTLY this JSON shape (fill values, keep all keys):
59
+ {
60
+ "name_announced": {"passed": true, "evidence": {"quote": "...", "speaker": "AGENT", "timestamp": "00:12"}, "note": null},
61
+ "company_announced": {"passed": true, "evidence": {"quote": "...", "speaker": "AGENT", "timestamp": "00:12"}, "note": null},
62
+ "recording_disclosure": {"passed": false, "evidence": null, "note": "never stated"},
63
+ "identity_verified": {"passed": true, "evidence": {"quote": "...", "speaker": "AGENT", "timestamp": "00:42"}, "note": null},
64
+ "identity_method": "date of birth",
65
+ "resolution_provided": {"passed": true, "evidence": {"quote": "...", "speaker": "AGENT", "timestamp": "10:07"}, "note": null},
66
+ "transfer_next_steps": {"passed": null, "evidence": null, "note": "no transfer"}
67
+ }"""
68
+
69
+
70
+ # ─────────────────────────────────────────────────────────────────────────────
71
+ # QUALITY — 6 scored dimensions with enriched signal lists
72
+ # ─────────────────────────────────────────────────────────────────────────────
73
+
74
+ QUALITY_SYSTEM = f"""\
75
+ You are a call-center quality analyst. You score 6 quality dimensions from \
76
+ a customer-service call transcript and output ONLY a JSON object. \
77
+ Rubric version {RUBRIC_VERSION_GRAPH}.
78
+
79
+ You are given a ROLE-MAPPED transcript: every line is tagged \
80
+ [AGENT mm:ss] or [CUSTOMER mm:ss]. Channel identity is authoritative.
81
+
82
+ For each dimension:
83
+ - Score 1-5 from TEXT signals only (1=poor, 5=excellent).
84
+ - List specific signals_present and signals_absent from the rubric below.
85
+ - Cite at least one verbatim evidence quote per dimension.
86
+ - Set requires_audio=true for dimensions that also need acoustic analysis.
87
+
88
+ DIMENSIONS AND SIGNALS:
89
+
90
+ 1. EFFICIENCY
91
+ - First-call resolution (FCR) — issue resolved without follow-up?
92
+ - Appropriate call duration for the complexity of the issue?
93
+ - No unnecessary holds or transfers?
94
+ Score 5 if FCR with no wasted time. Score 1 if multiple holds, \
95
+ unnecessary transfers, or issue left unresolved.
96
+
97
+ 2. PROBLEM RESOLUTION
98
+ - Offered multiple options (A, B, C) when applicable?
99
+ - Explained pros and cons of each option?
100
+ - Waited for customer preference before proceeding?
101
+ - Had a backup plan if first option didn't work?
102
+ - Respected customer's choice even if agent preferred another?
103
+ - Solution fits the customer's specific situation?
104
+ - Explained WHY (not just what)?
105
+ - Was the customer's actual issue solved by the end?
106
+ - Offered alternatives if the issue was unsolvable?
107
+ Score 5 if issue solved with clear options and explanation. \
108
+ Score 1 if issue ignored or dismissed with no alternatives.
109
+
110
+ 3. CLARITY
111
+ - Asked open-ended diagnostic questions ("When did this start?", \
112
+ "What exactly happened?")?
113
+ - Checked understanding ("Does that make sense?")?
114
+ - Asked clarifying questions before jumping to a solution?
115
+ - Confirmed understanding mid-call?
116
+ - Provided a recap or summary at the end?
117
+ Score 5 if thorough discovery and clear recap. \
118
+ Score 1 if agent assumed the problem and skipped confirmation.
119
+
120
+ 4. PROFESSIONALISM
121
+ TEXT signals (score from these):
122
+ - Proper grammar and complete sentences throughout?
123
+ - No slang or informal language?
124
+ - Knew the answer or actively found it?
125
+ - Provided accurate information?
126
+ - Explained WHY, not just what to do?
127
+ - Listened fully without interrupting?
128
+ - Willing to repeat or rephrase when asked?
129
+ AUDIO signals (set requires_audio=true):
130
+ - Clear pace, confident voice, consistent tone — cannot judge from text.
131
+ Score from text signals only. Set requires_audio=true.
132
+
133
+ 5. EMPATHY
134
+ HIGH-empathy signals (text):
135
+ - Validation phrases: "I totally understand your frustration", \
136
+ "That must be stressful", "I'm sorry you dealt with this", \
137
+ "I hear you", "I want to help you"
138
+ - Uses customer's name naturally
139
+ - Pauses to let customer finish speaking (no interruptions)
140
+ - Acknowledges the concern before moving to solutions
141
+ - Matches the emotional register of the customer
142
+ LOW-empathy signals (text):
143
+ - Dismissive: "That's not my problem", "It's not that big a deal", \
144
+ "Yeah, rates go up. Nothing I can do."
145
+ - Interrupts the customer constantly
146
+ - Transfers without explaining why
147
+ - No validation or regret expressed
148
+ - Flat, scripted responses with no personal engagement
149
+ AUDIO signals (set requires_audio=true):
150
+ - Flat/robotic tone, energy/interest, warmth — cannot judge from text.
151
+ Score from text signals only. Set requires_audio=true.
152
+
153
+ 6. CUSTOMER SATISFACTION
154
+ - Problem was identified clearly?
155
+ - Issue was actually addressed (not deflected)?
156
+ - Clear resolution was provided?
157
+ - Agent didn't dismiss or minimize the customer's concern?
158
+ - Agent followed through on promises made during the call?
159
+ - No escalation threat from the customer?
160
+ - First-call resolution achieved?
161
+ - Customer didn't mention switching to a competitor?
162
+ AUDIO signals (set requires_audio=true):
163
+ - Customer sounds satisfied at end, not frustrated — cannot judge from text.
164
+ Score from text signals only. Set requires_audio=true.
165
+
166
+ MODALITY RULE: Judge ONLY from text. For acoustic qualities (tone, pace, \
167
+ energy, whether the customer "sounds" satisfied) do not guess — set \
168
+ requires_audio=true and score from text signals alone.
169
+
170
+ OUTPUT: a single JSON object, no markdown fences, no commentary."""
171
+
172
+ QUALITY_SKELETON = """\
173
+ Return EXACTLY this JSON shape (fill values, keep all keys):
174
+ {
175
+ "efficiency": {"score": 4, "signals_present": ["first-call resolution"], "signals_absent": [], "evidence": [{"quote": "...", "speaker": "AGENT", "timestamp": "08:38"}], "requires_audio": false},
176
+ "problem_resolution": {"score": 4, "signals_present": ["explained why"], "signals_absent": ["no explicit options"], "evidence": [{"quote": "...", "speaker": "AGENT", "timestamp": "05:20"}], "requires_audio": false},
177
+ "clarity": {"score": 3, "signals_present": ["open-ended questions"], "signals_absent": ["no recap at end"], "evidence": [{"quote": "...", "speaker": "AGENT", "timestamp": "02:15"}], "requires_audio": false},
178
+ "professionalism": {"score": 4, "signals_present": ["complete sentences", "accurate information"], "signals_absent": [], "evidence": [{"quote": "...", "speaker": "AGENT", "timestamp": "01:30"}], "requires_audio": true},
179
+ "empathy": {"score": 3, "signals_present": ["uses customer name"], "signals_absent": ["no validation phrase"], "evidence": [{"quote": "...", "speaker": "AGENT", "timestamp": "03:45"}], "requires_audio": true},
180
+ "customer_satisfaction": {"score": 4, "signals_present": ["issue addressed", "clear resolution"], "signals_absent": [], "evidence": [{"quote": "...", "speaker": "CUSTOMER", "timestamp": "09:50"}], "requires_audio": true}
181
+ }"""
182
+
183
+
184
+ # ─────────────────────────────────────────────────────────────────────────────
185
+ # ESCALATION — red flags, customer emotion, risk level
186
+ # ─────────────────────────────────────────────────────────────────────────────
187
+
188
+ ESCALATION_SYSTEM = f"""\
189
+ You assess escalation risk in a customer-service call transcript and output \
190
+ ONLY a JSON object. Rubric version {RUBRIC_VERSION_GRAPH}.
191
+
192
+ You are given a ROLE-MAPPED transcript: every line is tagged \
193
+ [AGENT mm:ss] or [CUSTOMER mm:ss]. Channel identity is authoritative.
194
+
195
+ RED FLAGS (include ALL that apply from this list):
196
+ - manager_requested: customer explicitly asks for a manager or supervisor.
197
+ Example: "I want to speak to a manager", "Can I talk to your supervisor?"
198
+ - competitor_switch: customer threatens to leave for a competitor.
199
+ Example: "I'm switching to [other company]", "I'll take my business elsewhere"
200
+ - repeat_attempts: customer indicates this is not their first call about the issue.
201
+ Example: "I've been calling a couple of times", "I already called about this"
202
+ - issue_too_complex: agent is visibly struggling — keeps putting the customer \
203
+ on hold, cannot find an answer, escalates internally, or admits they don't know.
204
+ - explicit_dissatisfaction: customer makes strong negative statements beyond \
205
+ normal frustration. Example: "This is unacceptable", "I've never had such \
206
+ terrible service"
207
+
208
+ CUSTOMER EMOTION (infer from WORDS only, not tone):
209
+ calm — no negative language, cooperative
210
+ mild_frustration — minor complaints but still engaged
211
+ frustrated — repeated complaints, impatience, but no threats
212
+ angry — hostile language, demands, threats
213
+ distressed — emotional distress, desperation, vulnerability
214
+
215
+ RISK LEVEL:
216
+ escalate — immediate escalation needed. Triggers: manager_requested, \
217
+ angry or distressed emotion, or 2+ red flags present.
218
+ review — a manager should review this call. Triggers: 1 red flag, \
219
+ or frustrated emotion with unresolved issue.
220
+ none — no escalation needed. Customer is calm or mildly frustrated \
221
+ and the issue was handled.
222
+
223
+ RULES:
224
+ 1. EVIDENCE: cite a verbatim quote for every red flag detected. \
225
+ Never invent quotes.
226
+ 2. Set requires_audio=true — emotion intensity needs acoustic confirmation \
227
+ before driving real action.
228
+ 3. OUTPUT: a single JSON object, no markdown fences, no commentary."""
229
+
230
+ ESCALATION_SKELETON = """\
231
+ Return EXACTLY this JSON shape (fill values, keep all keys):
232
+ {
233
+ "red_flags": [],
234
+ "customer_emotion_text": "calm",
235
+ "risk_level": "none",
236
+ "evidence": [{"quote": "...", "speaker": "CUSTOMER", "timestamp": "06:30"}],
237
+ "requires_audio": true
238
+ }"""
239
+
240
+
241
+ # ─────────────────────────────────────────────────────────────────────────────
242
+ # INVESTIGATION — conditional deep-dive, runs only when risk_level != "none"
243
+ # ─────────────────────────────────────────────────────────────────────────────
244
+
245
+ INVESTIGATE_SYSTEM = f"""\
246
+ You are an escalation review analyst. A first-pass screening flagged this \
247
+ customer-service call as an escalation risk. Your job is to produce a concise \
248
+ incident report a manager can act on, and output ONLY a JSON object. \
249
+ Rubric version {RUBRIC_VERSION_GRAPH}.
250
+
251
+ You are given the ROLE-MAPPED transcript plus the screening result \
252
+ (red flags, customer emotion, risk level).
253
+
254
+ REPORT REQUIREMENTS:
255
+ 1. summary — 2-3 sentences: what happened, why it is a risk, current state \
256
+ at call end. Written for a manager who has NOT heard the call.
257
+ 2. contributing_factors — the specific agent behaviours and customer \
258
+ circumstances that led here. Name both sides where applicable.
259
+ 3. recommended_action — ONE concrete next step (e.g. "callback within 24h \
260
+ with fee reversal authority", "coach agent on hold etiquette").
261
+ 4. priority — high (escalate risk, angry/distressed customer), \
262
+ medium (review risk with unresolved issue), low (review risk, issue resolved).
263
+
264
+ RULES:
265
+ 1. EVIDENCE: cite the verbatim quotes that justify the report. \
266
+ Never invent quotes.
267
+ 2. Be specific. "Improve communication" is useless; \
268
+ "agent left customer on hold 3 times without time estimates" is useful.
269
+ 3. OUTPUT: a single JSON object, no markdown fences, no commentary."""
270
+
271
+ INVESTIGATE_SKELETON = """\
272
+ Return EXACTLY this JSON shape (fill values, keep all keys):
273
+ {
274
+ "summary": "...",
275
+ "contributing_factors": ["...", "..."],
276
+ "recommended_action": "...",
277
+ "priority": "medium",
278
+ "evidence": [{"quote": "...", "speaker": "CUSTOMER", "timestamp": "06:30"}]
279
+ }"""
280
+
281
+
282
+ # ─────────────────────────────────────────────────────────────────────────────
283
+ # WORKFLOW (v0.4.0) — three ISOLATED phases. Phase b never sees the
284
+ # transcript, so the expected checklist cannot be shaped by what actually
285
+ # happened on the call.
286
+ # ─────────────────────────────────────────────────────────────────────────────
287
+
288
+ SUBJECT_SYSTEM = """\
289
+ You are a call-intake classifier. Read the transcript and state the SUBJECT \
290
+ of the call: ONE sentence (max 20 words) describing what the customer \
291
+ contacted about and what they wanted.
292
+
293
+ RULES:
294
+ 1. State the REQUEST only. Do NOT mention how the call went, whether it was \
295
+ resolved, or anything the agent did. A reader must not be able to tell the \
296
+ outcome from your sentence.
297
+ 2. NO call-specific figures: no dollar amounts, dates, account numbers, or \
298
+ personal names. Say "set up a recurring transfer between accounts", never \
299
+ "transfer $400 on the 15th". Your sentence seeds an expected checklist -- \
300
+ leaked specifics would make its boxes trivially satisfiable.
301
+ 3. Be concrete about the TYPE of request: "set up an automatic monthly \
302
+ transfer between checking and savings" beats "a banking question".
303
+ 4. OUTPUT: a single JSON object, no markdown fences, no commentary."""
304
+
305
+ SUBJECT_SKELETON = """\
306
+ Return EXACTLY this JSON shape:
307
+ {
308
+ "subject": "..."
309
+ }"""
310
+
311
+
312
+ WORKFLOW_GEN_SYSTEM = """\
313
+ You are a contact-center QA workflow designer. You are given ONLY the \
314
+ call domain and a one-sentence subject. You have NOT seen the transcript \
315
+ and must not assume anything about how the call actually went.
316
+
317
+ List the steps a competent agent would be EXPECTED to perform on a call \
318
+ with this subject: 4-8 steps, in the order they should occur. Mix the \
319
+ universal procedure (greeting, identity verification, recap) with steps \
320
+ SPECIFIC to this subject (e.g. for a transfer setup: confirm amount, \
321
+ confirm date, state cancellation terms).
322
+
323
+ RULES:
324
+ 1. Each step is a short imperative phrase plus a one-line rationale.
325
+ 2. Steps must be OBSERVABLE in a transcript (no "be friendly" — instead \
326
+ "greet the customer and offer help").
327
+ 3. OUTPUT: a single JSON object, no markdown fences, no commentary."""
328
+
329
+ WORKFLOW_GEN_SKELETON = """\
330
+ Return EXACTLY this JSON shape:
331
+ {
332
+ "expected_steps": [
333
+ {"step": "...", "rationale": "..."},
334
+ {"step": "...", "rationale": "..."}
335
+ ]
336
+ }"""
337
+
338
+
339
+ WORKFLOW_CHECK_SYSTEM = """\
340
+ You are a call QA auditor. You are given an expected-workflow checklist \
341
+ (written by someone who knew only the call's subject, not its content) and \
342
+ the ROLE-MAPPED transcript. Audit the call against the checklist.
343
+
344
+ For EACH step, in the same order, decide:
345
+ met=true — the agent performed it (cite a verbatim quote as evidence)
346
+ met=false — it should have happened and did not
347
+ met=null — cannot be determined from text, or genuinely not applicable
348
+
349
+ RULES:
350
+ 1. Keep each "step" string EXACTLY as given. Do not add, remove, or reorder \
351
+ steps.
352
+ 2. EVIDENCE: quotes must be copied verbatim from a single transcript turn. \
353
+ Never invent or paraphrase quotes. Omit evidence when met is false or null.
354
+ 3. Judge conservatively toward true: partial or implied performance without \
355
+ clear text evidence is met=null, not met=true.
356
+ 4. Do NOT hide behind null. You have the FULL transcript: if a step plainly \
357
+ never happened anywhere in it, that is met=false -- a determination, not an \
358
+ unknown. Reserve met=null for steps the text genuinely cannot decide \
359
+ (audio-only behaviour, transcript cut off mid-call) or steps made \
360
+ inapplicable by how the call unfolded. An audit that returns mostly null has \
361
+ failed at its job.
362
+ 5. A step listing multiple items with "or" / "any" (e.g. "explain any \
363
+ fees, minimum balance requirements, or transfer limits") is disjunctive: \
364
+ met=true if AT LEAST ONE listed item was substantively covered: cite that \
365
+ item's quote as evidence. met=false only if NONE of the listed items were \
366
+ covered anywhere in the call.
367
+ 6. OUTPUT: a single JSON object, no markdown fences, no commentary."""
368
+
369
+ WORKFLOW_CHECK_SKELETON = """\
370
+ Return EXACTLY this JSON shape (one entry per given step, same order):
371
+ {
372
+ "expected_steps": [
373
+ {"step": "...", "rationale": "...", "met": true, "evidence": {"quote": "...", "speaker": "AGENT", "timestamp": "00:15"}},
374
+ {"step": "...", "rationale": "...", "met": false, "evidence": null}
375
+ ]
376
+ }"""
377
+
378
+
379
+ # ─────────────────────────────────────────────────────────────────────────────
380
+ # WORKFLOW RECHECK (v0.4.2) — single-item second pass, isolated per step.
381
+ # Fires ONLY on steps the main check marked met=false. Because it never sees
382
+ # the other 7 steps, it cannot perturb anything the main pass already got
383
+ # right -- unlike editing WORKFLOW_CHECK_SYSTEM itself, whose added rules
384
+ # were shown (by repeated A/B testing) to bleed into unrelated steps' verdicts
385
+ # even when the added rule's content had nothing to do with them.
386
+ # ─────────────────────────────────────────────────────────────────────────────
387
+
388
+ WORKFLOW_RECHECK_SYSTEM = """\
389
+ You are a call QA auditor doing a SECOND-PASS check on ONE checklist item \
390
+ that a first-pass audit marked as missed (met=false). Your only job is to \
391
+ double-check that single verdict against the full transcript -- you are not \
392
+ scoring any other item.
393
+
394
+ A first-pass auditor sometimes marks a step false because it looked for a \
395
+ DIRECT QUESTION and didn't find one, while missing that the same information \
396
+ was actually established another way later in the call (e.g. the agent \
397
+ read back or confirmed the specific value instead of asking for it \
398
+ up front). Your job is to catch that specific failure mode -- and only that \
399
+ failure mode. Do not relitigate the whole call.
400
+
401
+ Decide:
402
+ met=true — the step's specific requirement is actually satisfied \
403
+ somewhere in the transcript, even if not phrased as a direct question \
404
+ (a read-back, recap, or confirmation stating the concrete value counts).
405
+ met=false — confirm the original verdict: it was never satisfied.
406
+
407
+ EVIDENCE RULES (strict -- this is what most often goes wrong on second pass):
408
+ - The quote must be copied verbatim from a single transcript turn.
409
+ - The quote must contain the ACTUAL, SPECIFIC content the step asks for \
410
+ (e.g. if the step asks for account numbers, the quote must contain the \
411
+ literal numbers -- not just the words "checking account" or "savings \
412
+ account" with no number attached). A quote that is merely on-topic but \
413
+ lacks the concrete value does NOT satisfy the step: keep met=false.
414
+ - If you cannot find a quote meeting that bar, you must return met=false \
415
+ with evidence=null. Do not lower the bar to find something to cite.
416
+ - The quote must serve the exact purpose stated in the step's RATIONALE, \
417
+ not merely share similar words with it. In particular: account numbers or \
418
+ other details exchanged to CARRY OUT a transaction (e.g. to set up a \
419
+ transfer) are not, by themselves, evidence that the customer's IDENTITY \
420
+ was verified -- and vice versa. If the quote's real purpose in context was \
421
+ a different checklist item, it does not count here: met=false.
422
+
423
+ OUTPUT: a single JSON object, no markdown fences, no commentary."""
424
+
425
+ WORKFLOW_RECHECK_SKELETON = """\
426
+ Return EXACTLY this JSON shape:
427
+ {
428
+ "met": true,
429
+ "evidence": {"quote": "...", "speaker": "AGENT", "timestamp": "00:15"}
430
+ }
431
+ or if still unmet:
432
+ {
433
+ "met": false,
434
+ "evidence": null
435
+ }"""
436
+
437
+
438
+ # ─────────────────────────────────────────────────────────────────────────────
439
+ # ARBITRATION (v0.4.1) — conditional node, runs ONLY when the text LLM's
440
+ # escalation tier and the audio model's acoustic tier disagree. Batch evidence
441
+ # (22 calls): when either channel flags risk, they almost never agree (1/10) —
442
+ # so disagreement cannot be resolved by a fixed rule; it needs the context.
443
+ # ─────────────────────────────────────────────────────────────────────────────
444
+
445
+ ARBITRATE_SYSTEM = """\
446
+ You are an escalation arbitrator. Two independent assessments of the same \
447
+ customer-service call DISAGREE about its escalation risk:
448
+ - a TEXT assessment (an LLM reading the transcript: red flags, wording, \
449
+ outcome)
450
+ - an ACOUSTIC assessment (an audio model scoring the customer's vocal \
451
+ escalation per sentence: it hears tone, not words)
452
+
453
+ You are given both assessments and the transcript. Decide the final \
454
+ risk_level: none, review, or escalate.
455
+
456
+ HOW TO WEIGH THE CHANNELS:
457
+ 1. Neither channel outranks the other by default. Text knows WHAT was said \
458
+ and whether the issue was resolved; audio knows HOW the customer sounded, \
459
+ which words can mask (polite phrasing over rising agitation, or animated \
460
+ speech that reads angry on paper but is merely energetic).
461
+ 2. A high acoustic signal with a genuinely resolved, thanked-and-closed call \
462
+ usually means vocal energy, not risk. A calm transcript with a high LATE \
463
+ acoustic signal (end of call) deserves suspicion: the customer may have \
464
+ given up rather than calmed down.
465
+ 3. Anchor your decision in the trajectory: escalation that RISES toward the \
466
+ end of the call matters more than an isolated mid-call spike.
467
+ 4. rationale: 1-2 sentences a manager can read, naming which channel you \
468
+ sided with and the deciding observation.
469
+
470
+ OUTPUT: a single JSON object, no markdown fences, no commentary."""
471
+
472
+ ARBITRATE_SKELETON = """\
473
+ Return EXACTLY this JSON shape:
474
+ {
475
+ "risk_level": "review",
476
+ "rationale": "..."
477
+ }"""
ml-services/evaluation/router.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LiteLLM-based priority router for QA extraction.
3
+
4
+ Routing policy is DERIVED FROM benchmark.py results, not guessed:
5
+ - primary = mistral/mistral-small-latest
6
+ only model that DISCRIMINATED scores between calls ([5,4,3,5,2] vs
7
+ [4,4,3,4,2]); high evidence fidelity; conservative.
8
+ - fallback = sambanova/Meta-Llama-3.3-70B-Instruct
9
+ fastest (2.6s), best raw fidelity (75%), perfectly conservative.
10
+ - safety = github/gpt-4o-mini
11
+ always-on free baseline, reliable JSON.
12
+
13
+ DeepSeek-V3.1 was EXCLUDED: 50% evidence fidelity = fabricates quotes, the worst
14
+ failure mode for a compliance system.
15
+
16
+ LiteLLM Router gives us retries, fallback chaining, and per-deployment cooldowns
17
+ for free. On a 429/timeout/error the call transparently drops to the next tier.
18
+
19
+ Public API mirrors llm_client.chat_json so extract.py can swap to routing with a
20
+ one-line change: chat_json_routed(system, user) -> json string
21
+ """
22
+ import os, logging
23
+ from env_util import load_env
24
+ load_env()
25
+
26
+ import litellm
27
+ litellm.suppress_debug_info = True
28
+ litellm.set_verbose = False
29
+ logging.getLogger("LiteLLM").setLevel(logging.ERROR)
30
+ logging.getLogger("litellm").setLevel(logging.ERROR)
31
+
32
+ from litellm import Router
33
+ from llm_client import JSON_ENFORCE_SUFFIX, _extract_json
34
+
35
+
36
+ # ── Routing policy (priority order) ───────────────────────────────────────────
37
+ def _model_list():
38
+ return [
39
+ {"model_name": "qa-primary",
40
+ "litellm_params": {"model": "mistral/mistral-small-latest",
41
+ "api_key": os.environ.get("MISTRAL_API_KEY")}},
42
+ {"model_name": "qa-fallback",
43
+ "litellm_params": {"model": "sambanova/Meta-Llama-3.3-70B-Instruct",
44
+ "api_key": os.environ.get("SAMBANOVA_API_KEY")}},
45
+ {"model_name": "qa-safety",
46
+ "litellm_params": {"model": "github/gpt-4o-mini",
47
+ "api_key": os.environ.get("GITHUB_TOKEN")}},
48
+ ]
49
+
50
+ # strict priority: primary -> fallback -> safety
51
+ FALLBACKS = [{"qa-primary": ["qa-fallback", "qa-safety"]}]
52
+
53
+ _router = None
54
+
55
+
56
+ def get_router():
57
+ global _router
58
+ if _router is None:
59
+ _router = Router(
60
+ model_list=_model_list(),
61
+ fallbacks=FALLBACKS,
62
+ num_retries=2, # retries on the same tier before falling back
63
+ timeout=90, # per-request timeout (s)
64
+ cooldown_time=60, # park a failing deployment for 60s
65
+ retry_after=2,
66
+ )
67
+ return _router
68
+
69
+
70
+ def chat_json_routed(system, user, temperature=0.1, max_tokens=4000,
71
+ return_meta=False, tier="qa-primary"):
72
+ """
73
+ Routed JSON completion: tries `tier`, transparently falls back on API
74
+ failure. Returns the JSON string (extracted from any markdown fences). With
75
+ return_meta=True, returns (json_str, served_model_string).
76
+
77
+ `tier` selects the starting deployment (qa-primary/qa-fallback/qa-safety).
78
+ Callers escalate deliberately when a 200-OK response is unparseable JSON --
79
+ a model-quality failure the transport-level router never sees.
80
+ """
81
+ router = get_router()
82
+ messages = [
83
+ {"role": "system", "content": system + JSON_ENFORCE_SUFFIX},
84
+ {"role": "user", "content": user},
85
+ ]
86
+ resp = router.completion(
87
+ model=tier,
88
+ messages=messages,
89
+ temperature=temperature,
90
+ max_tokens=max_tokens,
91
+ )
92
+ raw = resp.choices[0].message.content or ""
93
+ out = _extract_json(raw)
94
+ if return_meta:
95
+ return out, getattr(resp, "model", "?")
96
+ return out
97
+
98
+
99
+ # ── Self-test ─────────────────────────────────────────────────────────────────
100
+ if __name__ == "__main__":
101
+ import json, time, argparse
102
+ ap = argparse.ArgumentParser()
103
+ ap.add_argument("--test-fallback", action="store_true",
104
+ help="break the primary to prove fallback fires")
105
+ args = ap.parse_args()
106
+
107
+ SYS = "You are a helpful assistant."
108
+ USR = 'Return JSON: {"status": "ok", "n": 42}'
109
+
110
+ if not args.test_fallback:
111
+ print("\n[1] Normal routed call (should serve from PRIMARY = mistral):")
112
+ t0 = time.time()
113
+ out, served = chat_json_routed(SYS, USR, return_meta=True)
114
+ print(f" served by : {served}")
115
+ print(f" latency : {int((time.time()-t0)*1000)}ms")
116
+ print(f" parsed : {json.loads(out)}")
117
+ else:
118
+ print("\n[2] Fallback test: primary deliberately broken ->")
119
+ broken = Router(
120
+ model_list=[
121
+ {"model_name": "qa-primary",
122
+ "litellm_params": {"model": "mistral/mistral-small-latest",
123
+ "api_key": "sk-DELIBERATELY-BROKEN"}},
124
+ {"model_name": "qa-fallback",
125
+ "litellm_params": {"model": "sambanova/Meta-Llama-3.3-70B-Instruct",
126
+ "api_key": os.environ.get("SAMBANOVA_API_KEY")}},
127
+ {"model_name": "qa-safety",
128
+ "litellm_params": {"model": "github/gpt-4o-mini",
129
+ "api_key": os.environ.get("GITHUB_TOKEN")}},
130
+ ],
131
+ fallbacks=[{"qa-primary": ["qa-fallback", "qa-safety"]}],
132
+ num_retries=1, timeout=90,
133
+ )
134
+ t0 = time.time()
135
+ resp = broken.completion(
136
+ model="qa-primary",
137
+ messages=[{"role": "system", "content": SYS + JSON_ENFORCE_SUFFIX},
138
+ {"role": "user", "content": USR}],
139
+ max_tokens=30)
140
+ served = getattr(resp, "model", "?")
141
+ print(f" primary failed -> served by FALLBACK: {served}")
142
+ print(f" latency : {int((time.time()-t0)*1000)}ms")
143
+ print(f" parsed : {json.loads(_extract_json(resp.choices[0].message.content))}")
ml-services/evaluation/rubric.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Formal, versioned translation of the domain expert's QA rubric into a
3
+ machine-readable schema (Pydantic v2). This is the OUTPUT CONTRACT the LLM
4
+ evaluation layer must satisfy.
5
+
6
+ Design principle -- MODALITY SPLIT
7
+ ----------------------------------
8
+ The expert's rubric mixes two kinds of judgment:
9
+ * TEXT-derivable : can be decided from the transcript alone.
10
+ * AUDIO/paralinguistic: tone, pace, energy, "sounds satisfied" -- these
11
+ require the acoustic model (WP3) and must NOT be
12
+ guessed from text. They are represented here but
13
+ flagged `requires_audio=True` and left null by the
14
+ text LLM, to be filled by audio-text fusion (WBS 3.4).
15
+
16
+ Each leaf judgment carries EVIDENCE (a verbatim quote + speaker + timestamp) so
17
+ every score is provable -- no ungrounded compliance passes. This directly
18
+ supports the "Verbatim Evidence Extractor" track later.
19
+
20
+ RUBRIC_VERSION is bumped whenever criteria change so labelled data stays traceable.
21
+ """
22
+ from __future__ import annotations
23
+ from typing import Optional, List, Literal
24
+ from pydantic import BaseModel, Field
25
+
26
+ RUBRIC_VERSION = "0.1.0" # legacy monolithic path (extract.py)
27
+ RUBRIC_VERSION_GRAPH = "0.4.1" # graph path: 0.2.0 added customer_satisfaction,
28
+ # 0.3.0 added evidence re-anchor loop +
29
+ # escalation investigation node,
30
+ # 0.4.0 added acoustic-text fusion (hybrid
31
+ # scores) + expected-workflow track,
32
+ # 0.4.1 refit fusion thresholds on the
33
+ # 22-call batch + arbitrate node on
34
+ # text/acoustic disagreement
35
+
36
+ Speaker = Literal["AGENT", "CUSTOMER"]
37
+
38
+
39
+ # ─────────────────────────────────────────────────────────────────────────────
40
+ # Shared evidence object -- makes every judgment provable
41
+ # ─────────────────────────────────────────────────────────────────────────────
42
+ class Evidence(BaseModel):
43
+ quote: str = Field(description="Verbatim text copied from the transcript (no paraphrasing).")
44
+ speaker: Speaker
45
+ timestamp: Optional[str] = Field(default=None, description="mm:ss of the turn, if available.")
46
+
47
+
48
+ # ─────────────────────────────────────────────────────────────────────────────
49
+ # 1. COMPLIANCE (all TEXT-derivable; binary pass/fail with N/A)
50
+ # passed = True | False | None(=not applicable, e.g. recording disclosure
51
+ # "when needed", or transfer steps when there was no transfer)
52
+ # ─────────────────────────────────────────────────────────────────────────────
53
+ class ComplianceItem(BaseModel):
54
+ passed: Optional[bool] = Field(description="True=met, False=violated, None=not applicable to this call.")
55
+ evidence: Optional[Evidence] = Field(default=None, description="Required when passed is True or False.")
56
+ note: Optional[str] = None
57
+
58
+
59
+ class ComplianceChecklist(BaseModel):
60
+ # "Announce their name"
61
+ name_announced: ComplianceItem
62
+ # "Announce company name"
63
+ company_announced: ComplianceItem
64
+ # "State 'this call may be recorded' (when needed)" -> None if not needed
65
+ recording_disclosure: ComplianceItem
66
+ # "Confirm customer identity before discussing account" (last 4 / acct # / customer ID / DOB)
67
+ identity_verified: ComplianceItem
68
+ identity_method: Optional[str] = Field(
69
+ default=None, description="e.g. 'last 4 digits', 'account number', 'date of birth', 'customer ID'.")
70
+ # "Provide resolution / next steps before closing"
71
+ resolution_provided: ComplianceItem
72
+ # "If call transferred, provide information of next steps" -> None if no transfer
73
+ transfer_next_steps: ComplianceItem
74
+
75
+
76
+ # ─────────────────────────────────────────────────────────────────────────────
77
+ # 2. QUALITY (1-5 each). Each dimension lists the concrete signals the rubric
78
+ # names, split into text vs audio. The text LLM fills text signals + a score;
79
+ # `requires_audio` flags dimensions whose FULL judgment also needs WP3.
80
+ # ─────────────────────────────────────────────────────���───────────────────────
81
+ class HybridScore(BaseModel):
82
+ """Provenance for a fused score (v0.4.0). method='text_only' means the
83
+ acoustic model contributed nothing (no data / dimension is text-defined)."""
84
+ text_score: int = Field(ge=1, le=5, description="LLM score from text signals alone.")
85
+ acoustic_score: Optional[float] = Field(default=None, description="Acoustic subscore mapped to 1-5.")
86
+ text_weight: float
87
+ acoustic_weight: float
88
+ coverage: Optional[float] = Field(
89
+ default=None, description="Fraction of channel sentences the audio model processed.")
90
+ channel: Optional[str] = Field(default=None, description="Speaker channel the acoustic signal came from.")
91
+ method: Literal["weighted_mean", "text_only"] = "text_only"
92
+
93
+
94
+ class QualityDimension(BaseModel):
95
+ score: int = Field(ge=1, le=5, description="1=poor, 5=excellent. Text LLM score; "
96
+ "overwritten with the fused score when acoustic data exists (v0.4.0).")
97
+ signals_present: List[str] = Field(default_factory=list, description="Rubric behaviours observed.")
98
+ signals_absent: List[str] = Field(default_factory=list, description="Expected behaviours not observed.")
99
+ evidence: List[Evidence] = Field(default_factory=list)
100
+ requires_audio: bool = Field(
101
+ default=False, description="True if a complete judgment also needs acoustic signals (WP3).")
102
+ hybrid: Optional[HybridScore] = Field(
103
+ default=None, description="How this score was computed (v0.4.0+). Null in older evaluations.")
104
+
105
+
106
+ class QualityDimensions(BaseModel):
107
+ # a. Efficiency: FCR, appropriate duration, unnecessary holds/transfers (text-derivable;
108
+ # duration is metadata)
109
+ efficiency: QualityDimension
110
+ # c. Problem Resolution: options A/B/C, pros/cons, explains WHY, respects choice, issue solved
111
+ problem_resolution: QualityDimension
112
+ # b. Clarity: open-ended questions, "does that make sense?", confirms understanding, recap
113
+ clarity: QualityDimension
114
+ # d. Professionalism: grammar/complete sentences/no slang (TEXT) + pace/confident voice (AUDIO)
115
+ professionalism: QualityDimension # requires_audio likely True
116
+ # e. Empathy: validation phrases + name use (TEXT) + flat/robotic tone, matches emotion (AUDIO)
117
+ empathy: QualityDimension # requires_audio likely True
118
+ # f. Customer Satisfaction: problem addressed, clear resolution, followed through (TEXT)
119
+ # + "sounds satisfied", not frustrated at end (AUDIO). Added in v0.2.0.
120
+ customer_satisfaction: Optional[QualityDimension] = Field(
121
+ default=None,
122
+ description="Customer outcome signals. Added in v0.2.0; null in v0.1.0 evaluations."
123
+ )
124
+
125
+
126
+ # ─────────────────────────────────────────────────────────────────────────────
127
+ # 3. ESCALATION RISK
128
+ # Red flags are mostly TEXT (phrases). Customer emotion is text-inferred here
129
+ # but should be CONFIRMED by the audio model before it drives an action.
130
+ # ─────────────────────────────────────────────────────────────────────────────
131
+ RedFlag = Literal[
132
+ "manager_requested", # "I want to speak to a manager"
133
+ "competitor_switch", # "I'm switching to [competitor]"
134
+ "repeat_attempts", # "I've called a couple of times"
135
+ "issue_too_complex", # agent struggling / repeated holds
136
+ "explicit_dissatisfaction", # other strong negative statements
137
+ ]
138
+
139
+
140
+ class EscalationFusion(BaseModel):
141
+ """Provenance for the fused risk level.
142
+ v0.4.0 fused via ordinal max (more severe tier wins, "ordinal_max").
143
+ v0.4.1: tiers that AGREE merge deterministically ("agreement"); tiers
144
+ that DISAGREE are resolved by an arbitration LLM node that weighs both
145
+ signals in context ("llm_arbitration") -- disagreement is exactly where
146
+ mechanical merging has nothing to stand on (batch evidence: of 10 calls
147
+ where either channel flagged risk, both flagged on only 1)."""
148
+ text_risk: Literal["none", "review", "escalate"]
149
+ acoustic_risk: Optional[Literal["none", "review", "escalate"]] = None
150
+ late_mean_escalation: Optional[float] = Field(
151
+ default=None, description="Mean customer escalation_score over the final third of the call.")
152
+ peak_escalation: Optional[float] = Field(
153
+ default=None, description="Max customer escalation_score anywhere in the call. Context only "
154
+ "since v0.4.1 -- not a tier trigger (batch median 0.56 gave it no "
155
+ "discriminating power).")
156
+ method: Literal["agreement", "llm_arbitration", "ordinal_max", "text_only"] = "text_only"
157
+ arbitration_rationale: Optional[str] = Field(
158
+ default=None, description="Arbitrator's reasoning when method='llm_arbitration'.")
159
+
160
+
161
+ class EscalationRisk(BaseModel):
162
+ red_flags: List[RedFlag] = Field(default_factory=list)
163
+ customer_emotion_text: Literal["calm", "mild_frustration", "frustrated", "angry", "distressed"] = Field(
164
+ description="Emotion inferred from TEXT only; audio model confirms before action.")
165
+ risk_level: Literal["none", "review", "escalate"] = Field(
166
+ description="none / review (manager may review) / escalate (immediate). "
167
+ "Fused with the acoustic tier when audio data exists (v0.4.0).")
168
+ evidence: List[Evidence] = Field(default_factory=list)
169
+ requires_audio: bool = Field(
170
+ default=True, description="Emotion intensity needs acoustic confirmation.")
171
+ hybrid: Optional[EscalationFusion] = Field(
172
+ default=None, description="How risk_level was computed (v0.4.0+). Null in older evaluations.")
173
+
174
+
175
+ # ─────────────────────────────────────────────────────────────────────────────
176
+ # 4. INVESTIGATION (v0.3.0) -- produced ONLY when risk_level != "none".
177
+ # A conditional deep-dive that turns escalation red flags into a
178
+ # manager-ready incident report.
179
+ # ─────────────────────────────────────────────────────────────────────────────
180
+ class InvestigationReport(BaseModel):
181
+ summary: str = Field(description="2-3 sentence incident summary for a manager.")
182
+ contributing_factors: List[str] = Field(
183
+ default_factory=list, description="What led to the escalation risk (agent + customer side).")
184
+ recommended_action: str = Field(description="Concrete next step for the reviewing manager.")
185
+ priority: Literal["low", "medium", "high"] = Field(
186
+ description="Review urgency. escalate risk -> high, review risk -> low/medium.")
187
+ evidence: List[Evidence] = Field(default_factory=list)
188
+
189
+
190
+ # ─────────────────────────────────────────────────────────────────────────────
191
+ # 5. EXPECTED WORKFLOW (v0.4.0) -- three isolated LLM phases:
192
+ # a. subject: one sentence stating what the customer contacted about
193
+ # (the REQUEST only, never the outcome).
194
+ # b. expected steps: generated from domain + subject ONLY -- the model has
195
+ # not seen the transcript, so expectations cannot be contaminated by
196
+ # what actually happened on the call.
197
+ # c. check: audits the transcript against that independent checklist.
198
+ # ─────────────────────────────────────────────────────────────────────────────
199
+ class WorkflowStep(BaseModel):
200
+ step: str = Field(description="Short imperative phrase, e.g. 'Verify customer identity'.")
201
+ rationale: Optional[str] = Field(default=None, description="Why this step is expected for this call type.")
202
+ met: Optional[bool] = Field(
203
+ default=None, description="True=performed, False=missed, None=not determinable/applicable. "
204
+ "Filled by the check phase; null before checking.")
205
+ evidence: Optional[Evidence] = None
206
+
207
+
208
+ class CallWorkflow(BaseModel):
209
+ subject: str = Field(description="One-sentence gist of what the customer wanted (request, not outcome).")
210
+ expected_steps: List[WorkflowStep] = Field(
211
+ description="4-8 boxes a competent agent should check for a call with this subject.")
212
+
213
+
214
+ # ─────────────────────────────────────────────────────────────────────────────
215
+ # Top-level evaluation payload
216
+ # ─────────────────────────────────────────────────────────────────────────────
217
+ class CallMetadata(BaseModel):
218
+ call_id: str
219
+ domain: str # injected as "Call Type" lens
220
+ accent: Optional[str] = None
221
+ duration_seconds: Optional[float] = None
222
+ transcript_model: Optional[str] = None
223
+
224
+
225
+ class CallEvaluation(BaseModel):
226
+ rubric_version: str = RUBRIC_VERSION
227
+ metadata: CallMetadata
228
+ compliance: ComplianceChecklist
229
+ quality: QualityDimensions
230
+ escalation: EscalationRisk
231
+ investigation: Optional[InvestigationReport] = Field(
232
+ default=None,
233
+ description="Deep-dive incident report. Only populated when risk_level != 'none' (v0.3.0+).")
234
+ workflow: Optional[CallWorkflow] = Field(
235
+ default=None,
236
+ description="Subject-derived expected workflow + audit results (v0.4.0+).")
237
+ overall_summary: Optional[str] = Field(
238
+ default=None, description="2-3 sentence plain-language summary for the dashboard.")
239
+
240
+
241
+ # Documentation map: which rubric areas are text vs audio vs metadata.
242
+ # Used by the prompt builder (Phase 1) and the report.
243
+ MODALITY_MAP = {
244
+ "compliance.name_announced": "text",
245
+ "compliance.company_announced": "text",
246
+ "compliance.recording_disclosure": "text",
247
+ "compliance.identity_verified": "text",
248
+ "compliance.resolution_provided": "text",
249
+ "compliance.transfer_next_steps": "text",
250
+ "quality.efficiency": "text+metadata", # duration from metadata
251
+ "quality.problem_resolution": "text",
252
+ "quality.clarity": "text",
253
+ "quality.professionalism": "text+audio", # grammar=text, tone/pace=audio
254
+ "quality.empathy": "text+audio", # phrases=text, warmth/tone=audio
255
+ "escalation.red_flags": "text",
256
+ "escalation.customer_emotion": "text+audio", # words=text, intensity=audio
257
+ "quality.customer_satisfaction": "text+audio", # outcome=text, "sounds satisfied"=audio
258
+ }
259
+
260
+
261
+ if __name__ == "__main__":
262
+ # emit the JSON schema so we can eyeball the contract
263
+ import json
264
+ print(f"RUBRIC_VERSION = {RUBRIC_VERSION}\n")
265
+ print(json.dumps(CallEvaluation.model_json_schema(), indent=2)[:1500], "...")
ml-services/evaluation/run_graph_batch.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Batch-run the graph evaluation over every call that has audio sentiment data.
3
+
4
+ Purpose (v0.4.0 validation):
5
+ 1. Ground the fusion constants — collect text-vs-acoustic score pairs and
6
+ escalation trajectories across calls so thresholds can be fitted to the
7
+ observed distributions instead of asserted.
8
+ 2. Probe the workflow track for leakage/leniency — if expected-step pass
9
+ rates are ~100% on every call including badly-handled ones, the checklist
10
+ generation or the audit is compromised.
11
+
12
+ Usage: python run_graph_batch.py [--results_dir results] [--only banking]
13
+ Output: results/{call_id}_graph.json per call + results/batch_summary.json
14
+ """
15
+ import os
16
+ import json
17
+ import time
18
+ import argparse
19
+ import traceback
20
+
21
+ import paths
22
+ from graph import build_graph
23
+ from rubric import RUBRIC_VERSION_GRAPH
24
+
25
+ SENTIMENT_ROOT = str(paths.SENTIMENT_ROOT)
26
+
27
+
28
+ def calls_with_sentiment(only=None):
29
+ out = []
30
+ for domain in sorted(os.listdir(SENTIMENT_ROOT)):
31
+ if only and domain != only:
32
+ continue
33
+ d = os.path.join(SENTIMENT_ROOT, domain)
34
+ for f in sorted(os.listdir(d)):
35
+ if f.endswith(".json") and not f.endswith("_segments.json"):
36
+ out.append(f[:-5])
37
+ return out
38
+
39
+
40
+ def summarize(ev):
41
+ """Extract the batch-level facts we want to analyze."""
42
+ q = ev.get("quality", {})
43
+ dims = {}
44
+ for name, d in q.items():
45
+ if isinstance(d, dict) and "score" in d:
46
+ h = d.get("hybrid") or {}
47
+ dims[name] = {"fused": d["score"],
48
+ "text": h.get("text_score", d["score"]),
49
+ "acoustic": h.get("acoustic_score"),
50
+ "coverage": h.get("coverage")}
51
+ e = ev.get("escalation", {})
52
+ eh = e.get("hybrid") or {}
53
+ wf = ev.get("workflow") or {}
54
+ steps = wf.get("expected_steps", [])
55
+ return {
56
+ "quality": dims,
57
+ "risk": e.get("risk_level"),
58
+ "risk_text": eh.get("text_risk"),
59
+ "risk_acoustic": eh.get("acoustic_risk"),
60
+ "esc_late_mean": eh.get("late_mean_escalation"),
61
+ "esc_peak": eh.get("peak_escalation"),
62
+ "esc_method": eh.get("method"),
63
+ "emotion": e.get("customer_emotion_text"),
64
+ "red_flags": e.get("red_flags", []),
65
+ "wf_subject": wf.get("subject"),
66
+ "wf_total": len(steps),
67
+ "wf_met": sum(1 for s in steps if s.get("met") is True),
68
+ "wf_missed": sum(1 for s in steps if s.get("met") is False),
69
+ "anchor": ev.get("_anchor_stats"),
70
+ "investigated": ev.get("investigation") is not None,
71
+ "wall_clock": (ev.get("_pipeline") or {}).get("wall_clock"),
72
+ }
73
+
74
+
75
+ def main():
76
+ ap = argparse.ArgumentParser()
77
+ ap.add_argument("--results_dir", default="results",
78
+ help="transcription dir under na_testset (default: results "
79
+ "— the flattened backend format covers all 22 calls)")
80
+ ap.add_argument("--only", default=None, help="restrict to one domain")
81
+ ap.add_argument("--skip_existing", action="store_true")
82
+ args = ap.parse_args()
83
+
84
+ calls = calls_with_sentiment(args.only)
85
+ here = os.path.dirname(os.path.abspath(__file__))
86
+ out_dir = os.path.join(here, "results")
87
+ os.makedirs(out_dir, exist_ok=True)
88
+
89
+ graph = build_graph()
90
+ summary, failures = {}, {}
91
+ t_start = time.time()
92
+
93
+ print(f"Batch: {len(calls)} calls, rubric {RUBRIC_VERSION_GRAPH}, "
94
+ f"results_dir={args.results_dir}\n" + "=" * 68)
95
+
96
+ for i, call_id in enumerate(calls, 1):
97
+ out_path = os.path.join(out_dir, f"{call_id}_graph.json")
98
+ if args.skip_existing and os.path.exists(out_path):
99
+ with open(out_path, encoding="utf-8") as f:
100
+ prev = json.load(f)
101
+ if prev.get("rubric_version") == RUBRIC_VERSION_GRAPH:
102
+ summary[call_id] = summarize(prev)
103
+ print(f"[{i:2}/{len(calls)}] {call_id} — cached")
104
+ continue
105
+
106
+ print(f"[{i:2}/{len(calls)}] {call_id}")
107
+ t0 = time.time()
108
+ try:
109
+ final = graph.invoke({"call_id": call_id,
110
+ "results_dir": args.results_dir})
111
+ ev = final["evaluation"]
112
+ ev["_pipeline"] = {
113
+ "nodes": final.get("node_meta", {}),
114
+ "anchor_passes": final.get("anchor_attempts", 1),
115
+ "wall_clock": round(time.time() - t0, 1),
116
+ }
117
+ with open(out_path, "w", encoding="utf-8") as f:
118
+ json.dump(ev, f, indent=2)
119
+ summary[call_id] = summarize(ev)
120
+ s = summary[call_id]
121
+ print(f" ok in {s['wall_clock']}s | risk={s['risk']} "
122
+ f"(text={s['risk_text']}, ac={s['risk_acoustic']}) | "
123
+ f"wf {s['wf_met']}/{s['wf_total']} met, {s['wf_missed']} missed")
124
+ except Exception as e:
125
+ failures[call_id] = f"{type(e).__name__}: {e}"
126
+ print(f" FAILED: {failures[call_id]}")
127
+ traceback.print_exc(limit=2)
128
+
129
+ with open(os.path.join(out_dir, "batch_summary.json"), "w",
130
+ encoding="utf-8") as f:
131
+ json.dump({"rubric_version": RUBRIC_VERSION_GRAPH,
132
+ "results_dir": args.results_dir,
133
+ "wall_clock_total": round(time.time() - t_start, 1),
134
+ "calls": summary, "failures": failures}, f, indent=2)
135
+
136
+ print("=" * 68)
137
+ print(f"Done: {len(summary)} ok, {len(failures)} failed, "
138
+ f"{round(time.time() - t_start, 1)}s total")
139
+ print(f"Summary -> {os.path.join(out_dir, 'batch_summary.json')}")
140
+
141
+
142
+ if __name__ == "__main__":
143
+ main()
ml-services/evaluation/segment.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Call chaptering / segmentation layer.
3
+
4
+ Turns the role-mapped transcript into coherent CHAPTERS (phases of the call),
5
+ like automatic video chapters: greeting, identity verification, problem
6
+ statement, investigation, resolution, closing, etc.
7
+
8
+ DESIGN -- why this is robust
9
+ ----------------------------
10
+ A naive approach asks the model for mm:ss boundaries, which it can hallucinate
11
+ (timestamps past the call end, overlapping spans, gaps). Instead we:
12
+
13
+ 1. NUMBER every turn and show the model the numbered transcript.
14
+ 2. Ask only for the START TURN INDEX of each chapter (+ label + summary).
15
+ 3. DERIVE all timestamps from the real turns, and REPAIR the result in code:
16
+ - clamp indices into range, dedupe, sort
17
+ - force chapter 1 to start at turn 0
18
+ - each chapter ends exactly where the next begins (contiguous, no gaps)
19
+ - the last chapter ends at the call duration
20
+
21
+ So the output is always valid by construction -- the model only chooses *where*
22
+ topics shift, never the raw numbers.
23
+
24
+ Uses the LiteLLM router by default (Mistral primary). Override with --provider.
25
+
26
+ Usage:
27
+ python segment.py --call_id en_CA_Banking_1592237
28
+ python segment.py --call_id en_CA_Banking_1592237 --provider github
29
+ """
30
+ import os, json, time, argparse
31
+ from typing import List
32
+ from pydantic import BaseModel, Field, ValidationError
33
+
34
+ import paths
35
+ from env_util import load_env
36
+ from assemble import load_manifest, assemble_turns, estimate_duration, mmss
37
+ from llm_client import chat_json
38
+ from extract import strip_fences
39
+
40
+ load_env()
41
+ DATA = str(paths.NA_TESTSET)
42
+
43
+
44
+ # ── Schema ────────────────────────────────────────────────────────────────────
45
+ class Chapter(BaseModel):
46
+ index: int
47
+ label: str
48
+ start_turn: int
49
+ start_time: str # mm:ss (derived)
50
+ end_time: str # mm:ss (derived)
51
+ summary: str
52
+
53
+
54
+ class CallChapters(BaseModel):
55
+ call_id: str
56
+ domain: str
57
+ duration: str
58
+ n_turns: int
59
+ n_chapters: int
60
+ served_by: str
61
+ chapters: List[Chapter]
62
+
63
+
64
+ # ── Prompt ────────────────────────────────────────────────────────────────────
65
+ SYSTEM = """You segment a customer-service phone call into coherent CHAPTERS -- the natural phases of the call, like automatic video chapters.
66
+
67
+ Typical phases (use only those that actually occur, in the order they occur):
68
+ greeting/introduction, identity verification, problem statement, investigation/discussion, options/explanation, resolution & next steps, upsell/offer, closing.
69
+
70
+ RULES:
71
+ - Identify between 3 and 8 chapters. Fewer for short calls, more for long ones.
72
+ - Each chapter is a run of CONSECUTIVE turns covering ONE phase. No overlaps, no gaps.
73
+ - Base boundaries on REAL topic shifts in the transcript, not fixed sizes.
74
+ - The first chapter MUST start at turn 0.
75
+ - For each chapter output: start_turn (the turn index where the phase BEGINS),
76
+ a short label (2-4 words), and a one-sentence summary of what happens in it.
77
+ - Output ONLY JSON. No markdown, no commentary."""
78
+
79
+ SKELETON = """Return EXACTLY this shape:
80
+ {"chapters":[
81
+ {"start_turn":0,"label":"Greeting & Introduction","summary":"Agent greets the caller and identifies themselves and the company."},
82
+ {"start_turn":4,"label":"Identity Verification","summary":"Agent verifies the customer's identity before discussing the account."}
83
+ ]}"""
84
+
85
+
86
+ def numbered_transcript(turns) -> str:
87
+ lines = []
88
+ for i, t in enumerate(turns):
89
+ lines.append(f"[{i}] [{t['speaker']} {mmss(t['start'])}] {t['text']}")
90
+ return "\n".join(lines)
91
+
92
+
93
+ def _call(provider, system, user):
94
+ """Return (raw_json_str, served_model)."""
95
+ if provider == "router":
96
+ from router import chat_json_routed
97
+ return chat_json_routed(system, user, max_tokens=1500, return_meta=True)
98
+ raw = chat_json(provider, system, user, max_tokens=1500)
99
+ return raw, provider
100
+
101
+
102
+ def repair_chapters(seeds, turns, duration_str) -> List[Chapter]:
103
+ """Turn raw model seeds into valid, contiguous, gap-free chapters."""
104
+ n = len(turns)
105
+ cleaned, seen = [], set()
106
+ for s in seeds:
107
+ try:
108
+ st = int(s["start_turn"])
109
+ except (KeyError, ValueError, TypeError):
110
+ continue
111
+ st = max(0, min(st, n - 1))
112
+ if st in seen:
113
+ continue
114
+ seen.add(st)
115
+ cleaned.append({"start_turn": st,
116
+ "label": str(s.get("label", "Untitled")).strip(),
117
+ "summary": str(s.get("summary", "")).strip()})
118
+
119
+ cleaned.sort(key=lambda x: x["start_turn"])
120
+ if not cleaned:
121
+ cleaned = [{"start_turn": 0, "label": "Full Call",
122
+ "summary": "Entire call."}]
123
+ # force coverage from the start
124
+ if cleaned[0]["start_turn"] != 0:
125
+ cleaned[0]["start_turn"] = 0
126
+
127
+ chapters = []
128
+ for i, c in enumerate(cleaned):
129
+ st = c["start_turn"]
130
+ start_time = mmss(turns[st]["start"])
131
+ if i + 1 < len(cleaned):
132
+ end_time = mmss(turns[cleaned[i + 1]["start_turn"]]["start"])
133
+ else:
134
+ end_time = duration_str
135
+ chapters.append(Chapter(index=i + 1, label=c["label"], start_turn=st,
136
+ start_time=start_time, end_time=end_time,
137
+ summary=c["summary"]))
138
+ return chapters
139
+
140
+
141
+ def segment(call_id, provider="router", results_dir="results_channels"):
142
+ manifest = load_manifest()
143
+ meta = manifest[call_id]
144
+ result_path = os.path.join(DATA, results_dir, meta["accent"], call_id + ".json")
145
+ with open(result_path, encoding="utf-8") as f:
146
+ result = json.load(f)
147
+
148
+ turns = assemble_turns(result)
149
+ duration_str = mmss(estimate_duration(result))
150
+ user = f"{numbered_transcript(turns)}\n\n{SKELETON}"
151
+
152
+ t0 = time.time()
153
+ raw, served = _call(provider, SYSTEM, user)
154
+ dt = time.time() - t0
155
+
156
+ data = json.loads(strip_fences(raw))
157
+ seeds = data.get("chapters", [])
158
+ chapters = repair_chapters(seeds, turns, duration_str)
159
+
160
+ cc = CallChapters(
161
+ call_id=call_id, domain=result.get("domain", meta.get("domain", "?")),
162
+ duration=duration_str, n_turns=len(turns), n_chapters=len(chapters),
163
+ served_by=served, chapters=chapters)
164
+ issues = verify_invariants(cc, turns)
165
+ return cc, dt, issues
166
+
167
+
168
+ def verify_invariants(cc: CallChapters, turns) -> List[str]:
169
+ """Return a list of invariant violations (empty = correct)."""
170
+ issues = []
171
+ chs = cc.chapters
172
+ if not chs:
173
+ return ["no chapters produced"]
174
+ if chs[0].start_turn != 0:
175
+ issues.append("chapter 1 does not start at turn 0")
176
+ if chs[0].start_time != mmss(turns[0]["start"]):
177
+ issues.append("first chapter start != call start")
178
+ if chs[-1].end_time != cc.duration:
179
+ issues.append("last chapter end != call duration (coverage gap)")
180
+ for a, b in zip(chs, chs[1:]):
181
+ if a.end_time != b.start_time:
182
+ issues.append(f"gap/overlap between ch{a.index} and ch{b.index}")
183
+ if b.start_turn <= a.start_turn:
184
+ issues.append(f"non-increasing start_turn at ch{b.index}")
185
+ if not (1 <= cc.n_chapters <= 12):
186
+ issues.append(f"chapter count {cc.n_chapters} outside sane range")
187
+ return issues
188
+
189
+
190
+ def print_timeline(cc: CallChapters, dt, issues=None):
191
+ print("\n" + "=" * 70)
192
+ print(f" CALL TIMELINE {cc.call_id} ({cc.domain})")
193
+ print(f" {cc.duration} | {cc.n_turns} turns | {cc.n_chapters} chapters "
194
+ f"| {dt:.1f}s | {cc.served_by}")
195
+ print("=" * 70)
196
+ for ch in cc.chapters:
197
+ print(f"\n {ch.start_time}-{ch.end_time} [{ch.index}] {ch.label}")
198
+ print(f" {ch.summary}")
199
+ print("\n " + "-" * 66)
200
+ if issues is None:
201
+ print(" invariants: (not checked)")
202
+ elif issues:
203
+ print(f" invariants: {len(issues)} VIOLATION(S):")
204
+ for x in issues:
205
+ print(f" - {x}")
206
+ else:
207
+ print(" invariants: OK (contiguous, gap-free, full coverage)")
208
+ print("=" * 70)
209
+
210
+
211
+ def main():
212
+ from llm_client import list_providers
213
+ ap = argparse.ArgumentParser()
214
+ ap.add_argument("--call_id", required=True)
215
+ ap.add_argument("--provider", default="router",
216
+ choices=["router"] + list_providers())
217
+ ap.add_argument("--results_dir", default="results_channels")
218
+ args = ap.parse_args()
219
+
220
+ cc, dt, issues = segment(args.call_id, args.provider, args.results_dir)
221
+
222
+ here = os.path.dirname(os.path.abspath(__file__))
223
+ out_dir = os.path.join(here, "chapters")
224
+ os.makedirs(out_dir, exist_ok=True)
225
+ out = os.path.join(out_dir, f"{args.call_id}.json")
226
+ with open(out, "w", encoding="utf-8") as f:
227
+ json.dump(cc.model_dump(), f, indent=2)
228
+
229
+ print_timeline(cc, dt, issues)
230
+ print(f"\nSaved -> {out}")
231
+
232
+
233
+ if __name__ == "__main__":
234
+ main()
ml-services/evaluation/sentence_segments.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Build sentence-level segments from word-level faster-whisper output.
3
+
4
+ Sentence boundaries are defined by:
5
+ 1. Sentence-ending punctuation (. ? !) on a word — excluding known abbreviations
6
+ 2. Silence gaps >= GAP_SPLIT_S between consecutive words (speaker turn break)
7
+
8
+ Fragments shorter than MIN_DURATION_S are merged into the preceding sentence
9
+ (only when the gap between them is small) so wav2vec2 always receives a clip
10
+ long enough to infer emotion.
11
+
12
+ Input: frontend/transcript_data.json (word-level agent + customer arrays)
13
+ Output: frontend/src/sentence_segments.json
14
+
15
+ Schema per sentence:
16
+ {
17
+ "id": "AGENT_001", # <SPEAKER>_<zero-padded index>
18
+ "seq_id": 1, # global chronological index across both speakers
19
+ "speaker": "AGENT",
20
+ "start": 13.49,
21
+ "end": 20.43,
22
+ "duration": 6.94,
23
+ "text": "Oh, good afternoon. ..."
24
+ }
25
+ """
26
+ import json
27
+ import os
28
+ import re
29
+
30
+ MIN_DURATION_S = 1.5 # merge fragments shorter than this into the previous sentence
31
+ GAP_SPLIT_S = 2.0 # silence gap that always forces a new sentence regardless of punctuation
32
+ MAX_MERGE_GAP = 1.0 # only merge a short fragment if the gap from previous sentence is <= this
33
+
34
+ TRANSCRIPT = os.path.join(
35
+ os.path.dirname(os.path.abspath(__file__)),
36
+ "..", "..", "frontend", "transcript_data.json"
37
+ )
38
+ OUTPUT = os.path.join(
39
+ os.path.dirname(os.path.abspath(__file__)),
40
+ "..", "..", "frontend", "src", "sentence_segments.json"
41
+ )
42
+
43
+ # abbreviations whose trailing period must NOT trigger a sentence split
44
+ ABBREVIATIONS = {
45
+ "mr", "mrs", "ms", "dr", "prof", "sr", "jr", "vs", "st",
46
+ "ave", "blvd", "dept", "est", "approx", "etc", "e.g", "i.e",
47
+ "jan", "feb", "mar", "apr", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
48
+ }
49
+
50
+ SENTENCE_END = re.compile(r"[.?!]$")
51
+
52
+
53
+ def is_sentence_end(word: str) -> bool:
54
+ """True if this word ends a sentence (not an abbreviation period)."""
55
+ if not SENTENCE_END.search(word):
56
+ return False
57
+ # strip trailing punctuation to get the base token
58
+ base = re.sub(r"[.?!,;:]+$", "", word).lower()
59
+ if base in ABBREVIATIONS:
60
+ return False
61
+ # single uppercase letter followed by period → initial, not sentence end
62
+ if re.match(r"^[a-z]$", base):
63
+ return False
64
+ return True
65
+
66
+
67
+ def words_to_sentences(words: list[dict], speaker: str) -> list[dict]:
68
+ """
69
+ Split words into sentences using punctuation + gap heuristics,
70
+ then merge short trailing fragments into their predecessor.
71
+ """
72
+ if not words:
73
+ return []
74
+
75
+ # ── phase 1: split into raw sentence buckets ─────────────────────────────
76
+ raw_sentences = []
77
+ bucket = [words[0]]
78
+
79
+ for prev_w, curr_w in zip(words, words[1:]):
80
+ gap = curr_w["start"] - prev_w["end"]
81
+ if is_sentence_end(prev_w["word"]) or gap >= GAP_SPLIT_S:
82
+ raw_sentences.append(bucket)
83
+ bucket = [curr_w]
84
+ else:
85
+ bucket.append(curr_w)
86
+
87
+ if bucket:
88
+ raw_sentences.append(bucket)
89
+
90
+ # ── phase 2: merge short fragments into predecessor ───────────────────────
91
+ merged = []
92
+ for sent_words in raw_sentences:
93
+ start = sent_words[0]["start"]
94
+ end = sent_words[-1]["end"]
95
+ dur = end - start
96
+ text = " ".join(w["word"] for w in sent_words)
97
+
98
+ # only merge fragments that lack terminal punctuation (trailing word scraps,
99
+ # not real sentences that just happen to be short)
100
+ last_word_has_terminal = SENTENCE_END.search(sent_words[-1]["word"])
101
+ if merged and dur < MIN_DURATION_S and not last_word_has_terminal:
102
+ gap_from_prev = start - merged[-1]["end"]
103
+ if gap_from_prev <= MAX_MERGE_GAP:
104
+ prev = merged[-1]
105
+ prev["text"] = prev["text"] + " " + text
106
+ prev["end"] = end
107
+ prev["duration"] = round(prev["end"] - prev["start"], 3)
108
+ continue
109
+
110
+ merged.append({
111
+ "speaker": speaker,
112
+ "start": round(start, 3),
113
+ "end": round(end, 3),
114
+ "duration": round(dur, 3),
115
+ "text": text,
116
+ })
117
+
118
+ for idx, s in enumerate(merged):
119
+ s["id"] = f"{speaker}_{idx + 1:03d}"
120
+
121
+ return merged
122
+
123
+
124
+ def interleave(agent_sents: list[dict], customer_sents: list[dict]) -> list[dict]:
125
+ """Merge both speaker lists sorted by start time, add global seq_id."""
126
+ combined = sorted(agent_sents + customer_sents, key=lambda s: s["start"])
127
+ for i, s in enumerate(combined):
128
+ s["seq_id"] = i + 1
129
+ return combined
130
+
131
+
132
+ def main():
133
+ with open(TRANSCRIPT, encoding="utf-8") as f:
134
+ data = json.load(f)
135
+
136
+ agent_sents = words_to_sentences(data["agent"], "AGENT")
137
+ customer_sents = words_to_sentences(data["customer"], "CUSTOMER")
138
+ all_sents = interleave(agent_sents, customer_sents)
139
+
140
+ output = {
141
+ "call": data.get("call", "call_1"),
142
+ "model": data.get("model"),
143
+ "total": len(all_sents),
144
+ "agent_sentences": len(agent_sents),
145
+ "customer_sentences": len(customer_sents),
146
+ "sentences": all_sents,
147
+ }
148
+
149
+ os.makedirs(os.path.dirname(OUTPUT), exist_ok=True)
150
+ with open(OUTPUT, "w", encoding="utf-8") as f:
151
+ json.dump(output, f, indent=2)
152
+
153
+ print(f"call_1 | {len(agent_sents)} agent + {len(customer_sents)} customer"
154
+ f" = {len(all_sents)} sentences total")
155
+ print(f"saved -> {os.path.abspath(OUTPUT)}")
156
+
157
+ print("\nfirst 8 sentences:")
158
+ for s in all_sents[:8]:
159
+ print(f" [{s['id']:>12}] seq={s['seq_id']:>3} "
160
+ f"{s['start']:>7.2f}s – {s['end']:>7.2f}s "
161
+ f"({s['duration']:.2f}s) \"{s['text'][:65]}\"")
162
+
163
+
164
+ if __name__ == "__main__":
165
+ main()
ml-services/pipeline/acoustic.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Acoustic sentiment stage (gated).
3
+
4
+ Runs Clara's wav2vec2 call-center emotion model over each sentence segment and
5
+ emits the SIMPLE per-seq_id schema that `fusion.load_acoustic()` joins against
6
+ the segmentation:
7
+
8
+ {call_id, model_version, segments:[{seq_id, sentiment, dominant_emotion,
9
+ escalation_score, processing_status}, ...]}
10
+
11
+ Fidelity note (correctness-critical): fusion's escalation tiers (late_mean >=
12
+ 0.30 review, >= 0.50 escalate) were FITTED on the 22-call batch, which was
13
+ produced by `run_timestamped_sentiment.py` calling `analyze_audio(clip)` PER
14
+ SENTENCE with the default `build_timeline=True`. We reproduce that call exactly
15
+ (default build_timeline) so a fresh call's escalation_score lands on the same
16
+ distribution the thresholds assume. Changing build_timeline or the per-sentence
17
+ granularity would silently bias the tiers.
18
+
19
+ Weights come from the public HF repo (cached by huggingface_hub), overridable
20
+ with ACOUSTIC_MODEL_DIR. Any unavailability raises AcousticUnavailable; the
21
+ orchestrator catches it and lets the graph fuse text-only.
22
+ """
23
+ import os
24
+ import tempfile
25
+ from pathlib import Path
26
+
27
+ DEFAULT_MODEL_REPO = "clarayoussef/wav2vec2-callcenter-emotion-v5"
28
+ MIN_SEG_S = 1.0 # matches run_timestamped_sentiment.py --min-segment-seconds
29
+
30
+
31
+ class AcousticUnavailable(RuntimeError):
32
+ """Model weights, deps, or predictor could not be loaded."""
33
+
34
+
35
+ def _enum(v):
36
+ """Enum -> its string value; pass through plain strings/None."""
37
+ return v.value if hasattr(v, "value") else v
38
+
39
+
40
+ def resolve_model_dir():
41
+ """Local dir override (ACOUSTIC_MODEL_DIR), else download the hub repo."""
42
+ local = os.environ.get("ACOUSTIC_MODEL_DIR")
43
+ if local:
44
+ if not Path(local).exists():
45
+ raise AcousticUnavailable(f"ACOUSTIC_MODEL_DIR not found: {local}")
46
+ return local
47
+ repo = os.environ.get("ACOUSTIC_MODEL_REPO", DEFAULT_MODEL_REPO)
48
+ try:
49
+ from huggingface_hub import snapshot_download
50
+ # HF_TOKEN in the environment is honored automatically if the repo
51
+ # is ever made private; the default repo is public.
52
+ return snapshot_download(repo_id=repo)
53
+ except Exception as e:
54
+ raise AcousticUnavailable(f"could not fetch acoustic model {repo}: {e}")
55
+
56
+
57
+ _PREDICTOR = None
58
+
59
+
60
+ def _get_predictor():
61
+ """Lazy singleton EmotionPredictor (loads weights once per process)."""
62
+ global _PREDICTOR
63
+ if _PREDICTOR is None:
64
+ try:
65
+ from src.inference.emotion_predictor import EmotionPredictor
66
+ except Exception as e: # missing torch/transformers/src
67
+ raise AcousticUnavailable(f"emotion predictor import failed: {e}")
68
+ try:
69
+ _PREDICTOR = EmotionPredictor(model_dir=resolve_model_dir())
70
+ except AcousticUnavailable:
71
+ raise
72
+ except Exception as e:
73
+ raise AcousticUnavailable(f"emotion predictor init failed: {e}")
74
+ return _PREDICTOR
75
+
76
+
77
+ def analyze_call(call_id, segmentation, agent_wav, customer_wav):
78
+ """
79
+ Per-sentence acoustic sentiment for one call.
80
+
81
+ `segmentation` is the `segment_transcript()` dict; each sentence carries
82
+ seq_id, speaker (AGENT/CUSTOMER), start, end (seconds). Slices the matching
83
+ speaker channel per sentence and runs the emotion model. Returns the simple
84
+ fusion-ready dict described in the module docstring.
85
+ """
86
+ import soundfile as sf
87
+ predictor = _get_predictor()
88
+
89
+ # preload each channel once; slice in-memory per sentence
90
+ channels = {}
91
+ for speaker, wav in (("AGENT", agent_wav), ("CUSTOMER", customer_wav)):
92
+ data, sr = sf.read(str(wav), dtype="float32")
93
+ if data.ndim > 1:
94
+ data = data.mean(axis=1)
95
+ channels[speaker] = (data, sr)
96
+
97
+ rows = []
98
+ for s in segmentation["sentences"]:
99
+ seq_id = s["seq_id"]
100
+ speaker = s.get("speaker")
101
+ start, end = float(s["start"]), float(s["end"])
102
+
103
+ if end - start < MIN_SEG_S or speaker not in channels:
104
+ rows.append({"seq_id": seq_id, "processing_status": "skipped_too_short"})
105
+ continue
106
+
107
+ data, sr = channels[speaker]
108
+ clip = data[int(start * sr): int(end * sr)]
109
+ if clip.size == 0:
110
+ rows.append({"seq_id": seq_id, "processing_status": "skipped_too_short"})
111
+ continue
112
+
113
+ tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
114
+ tmp.close()
115
+ try:
116
+ sf.write(tmp.name, clip, sr)
117
+ # default build_timeline=True -- see module fidelity note
118
+ res = predictor.analyze_audio(Path(tmp.name), call_id=call_id)
119
+ rows.append({
120
+ "seq_id": seq_id,
121
+ "sentiment": _enum(res.overall_audio_sentiment),
122
+ "dominant_emotion": _enum(res.dominant_emotion),
123
+ "escalation_score": round(float(res.audio_escalation_score), 4),
124
+ "processing_status": res.processing_status or "success",
125
+ })
126
+ except Exception as e:
127
+ rows.append({"seq_id": seq_id, "processing_status": f"error: {e}"[:120]})
128
+ finally:
129
+ try:
130
+ os.unlink(tmp.name)
131
+ except OSError:
132
+ pass
133
+
134
+ # record the repo id (stable, machine-independent) -- NOT
135
+ # config._name_or_path, which resolves to the local HF cache dir and would
136
+ # leak an absolute user path into an uploaded artifact
137
+ model_version = os.environ.get("ACOUSTIC_MODEL_REPO", DEFAULT_MODEL_REPO)
138
+
139
+ return {"call_id": call_id, "model_version": model_version, "segments": rows}
ml-services/pipeline/audio_io.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Audio ingest helpers built on ffmpeg/ffprobe.
3
+
4
+ A fresh call arrives either as one stereo file (agent = ch0, customer = ch1,
5
+ the apptek convention) or as two mono files already split by channel. The
6
+ pipeline downstream of here always works on two mono 16 kHz WAVs (one per
7
+ speaker), so this module normalizes both shapes to that.
8
+ """
9
+ import json
10
+ import subprocess
11
+ from pathlib import Path
12
+
13
+ SAMPLE_RATE = 16000 # wav2vec2 + faster-whisper canonical rate
14
+
15
+
16
+ class AudioError(RuntimeError):
17
+ pass
18
+
19
+
20
+ def _run(cmd):
21
+ proc = subprocess.run(cmd, capture_output=True, text=True)
22
+ if proc.returncode != 0:
23
+ raise AudioError(f"{cmd[0]} failed:\n{proc.stderr[-800:]}")
24
+ return proc
25
+
26
+
27
+ def probe_channels(path):
28
+ """Number of audio channels in the first audio stream (via ffprobe)."""
29
+ proc = _run([
30
+ "ffprobe", "-v", "error", "-select_streams", "a:0",
31
+ "-show_entries", "stream=channels", "-of", "json", str(path),
32
+ ])
33
+ info = json.loads(proc.stdout)
34
+ streams = info.get("streams") or []
35
+ if not streams:
36
+ raise AudioError(f"no audio stream in {path}")
37
+ return int(streams[0]["channels"])
38
+
39
+
40
+ def to_mono_16k(src, out):
41
+ """Downmix/resample any input to a single-channel 16 kHz WAV."""
42
+ out = Path(out)
43
+ out.parent.mkdir(parents=True, exist_ok=True)
44
+ _run(["ffmpeg", "-y", "-i", str(src),
45
+ "-ac", "1", "-ar", str(SAMPLE_RATE), str(out)])
46
+ return str(out)
47
+
48
+
49
+ def split_stereo(src, out_agent, out_customer):
50
+ """
51
+ Split a 2-channel file into two mono 16 kHz WAVs: ch0 -> agent, ch1 ->
52
+ customer (apptek stereo attribution). Returns (agent_path, customer_path).
53
+ """
54
+ out_agent, out_customer = Path(out_agent), Path(out_customer)
55
+ for p in (out_agent, out_customer):
56
+ p.parent.mkdir(parents=True, exist_ok=True)
57
+ _run([
58
+ "ffmpeg", "-y", "-i", str(src),
59
+ "-filter_complex", "[0:a]channelsplit=channel_layout=stereo[l][r]",
60
+ "-map", "[l]", "-ac", "1", "-ar", str(SAMPLE_RATE), str(out_agent),
61
+ "-map", "[r]", "-ac", "1", "-ar", str(SAMPLE_RATE), str(out_customer),
62
+ ])
63
+ return str(out_agent), str(out_customer)
64
+
65
+
66
+ def prepare_channels(primary, out_agent, out_customer, secondary=None):
67
+ """
68
+ Normalize an upload to (agent_wav, customer_wav) mono 16 kHz.
69
+
70
+ - two files given -> primary=agent, secondary=customer (resampled)
71
+ - one 2-channel file -> channel-split
72
+ - one 1-channel file, no 2nd -> ambiguous, caller must reject (raises)
73
+ """
74
+ if secondary is not None:
75
+ return (to_mono_16k(primary, out_agent),
76
+ to_mono_16k(secondary, out_customer))
77
+ ch = probe_channels(primary)
78
+ if ch >= 2:
79
+ return split_stereo(primary, out_agent, out_customer)
80
+ raise AudioError(
81
+ "single mono file has no separable speaker channels; upload a stereo "
82
+ "file or two per-channel files")
ml-services/pipeline/orchestrator.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Single-call orchestrator: fresh call in -> full dashboard artifacts out.
3
+
4
+ Runs the same chain the offline CLIs run, but for ONE call, in-process, with a
5
+ progress callback so a web worker can report stage transitions:
6
+
7
+ transcribing -> registering -> segmenting -> (acoustic) -> evaluating -> exporting
8
+
9
+ Every stage reuses the existing modules (no logic forked): faster-whisper via
10
+ transcribe_channels, segmentation via batch_sentence_segments.segment_transcript,
11
+ the LangGraph evaluation via graph.evaluate_call, and the frontend export via
12
+ export_frontend.export_call_artifacts. The acoustic stage is gated (ENABLE_ACOUSTIC
13
+ or the enable_acoustic arg) and degrades to text-only fusion on any failure.
14
+
15
+ Artifacts land in the same places the batch flow uses, plus the flat
16
+ frontend/public/{sentence_segments,sentiment}/ files CallDetail fetches.
17
+ """
18
+ import os
19
+ import sys
20
+ import json
21
+ import shutil
22
+ from pathlib import Path
23
+
24
+ # make the flat evaluation/scripts modules and the src package importable
25
+ # regardless of the caller's cwd (mirrors run_batch.py's sys.path shim)
26
+ _ML = Path(__file__).resolve().parents[1] # ml-services/
27
+ _HERE = Path(__file__).resolve().parent # ml-services/pipeline/
28
+ for _p in (_HERE, _ML / "evaluation", _ML / "scripts", _ML):
29
+ if str(_p) not in sys.path:
30
+ sys.path.insert(0, str(_p))
31
+
32
+ import paths # noqa: E402
33
+ from assemble import load_manifest # noqa: E402
34
+ from batch_sentence_segments import segment_transcript # noqa: E402
35
+ from graph import evaluate_call # noqa: E402
36
+ from export_frontend import export_call_artifacts, summarize # noqa: E402
37
+ from prompts_domain import prompt_for # noqa: E402
38
+
39
+ RESULTS_DIR_NAME = "results" # graph reads na_testset/results/{accent}/{id}.json
40
+ EVAL_RESULTS = _ML / "evaluation" / "results"
41
+
42
+
43
+ def _noop(*_a, **_k):
44
+ pass
45
+
46
+
47
+ _WHISPER = None
48
+
49
+
50
+ def _whisper_model():
51
+ """Lazy singleton faster-whisper model (loaded once per process)."""
52
+ global _WHISPER
53
+ if _WHISPER is None:
54
+ from faster_whisper import WhisperModel
55
+ name = os.environ.get("WHISPER_MODEL", "small.en")
56
+ _WHISPER = WhisperModel(name, device="cpu", compute_type="int8")
57
+ return _WHISPER
58
+
59
+
60
+ def _acoustic_enabled(explicit):
61
+ if explicit is not None:
62
+ return bool(explicit)
63
+ return os.environ.get("ENABLE_ACOUSTIC", "0").strip().lower() not in ("", "0", "false", "no")
64
+
65
+
66
+ def _canonical_wavs(call_id, accent, agent_wav, customer_wav):
67
+ """Ensure per-channel WAVs sit at the dataset convention path the export +
68
+ manifest consumers expect: NA_TESTSET/{accent}/{call_id}_{role}.wav.
69
+ Copies only if the source is elsewhere (fresh uploads); manifest calls are
70
+ already in place."""
71
+ dst_dir = paths.NA_TESTSET / accent
72
+ dst_dir.mkdir(parents=True, exist_ok=True)
73
+ out = {}
74
+ for role, src in (("agent", agent_wav), ("customer", customer_wav)):
75
+ dst = dst_dir / f"{call_id}_{role}.wav"
76
+ if os.path.abspath(src) != os.path.abspath(str(dst)):
77
+ shutil.copyfile(src, dst)
78
+ out[role] = str(dst)
79
+ return out["agent"], out["customer"]
80
+
81
+
82
+ def _register(call_id, accent, domain):
83
+ """Add/replace this call in the runtime manifest so load_manifest() (and
84
+ thus every eval/export consumer) finds it. Lean entry -- the hosted path
85
+ reads only accent/domain/call_id; reference transcripts aren't needed."""
86
+ rm = paths.RUNTIME_MANIFEST
87
+ entries = json.loads(rm.read_text(encoding="utf-8")) if rm.exists() else []
88
+ entries = [e for e in entries if e.get("call_id") != call_id]
89
+ entries.append({
90
+ "call_id": call_id, "accent": accent, "domain": domain,
91
+ "agent_wav": f"{accent}/{call_id}_agent.wav",
92
+ "customer_wav": f"{accent}/{call_id}_customer.wav",
93
+ })
94
+ rm.parent.mkdir(parents=True, exist_ok=True)
95
+ rm.write_text(json.dumps(entries, indent=2), encoding="utf-8")
96
+
97
+
98
+ def _write(path, obj):
99
+ path = Path(path)
100
+ path.parent.mkdir(parents=True, exist_ok=True)
101
+ path.write_text(json.dumps(obj, indent=2), encoding="utf-8")
102
+
103
+
104
+ def _publish_index(call_id, summary):
105
+ """Upsert one row into frontend/public/calls_index.json (dashboard list)."""
106
+ idx = paths.FRONTEND_PUBLIC / "calls_index.json"
107
+ index = json.loads(idx.read_text(encoding="utf-8")) if idx.exists() else []
108
+ index = [e for e in index if e.get("call_id") != call_id]
109
+ index.append(summary)
110
+ _write(idx, index)
111
+
112
+
113
+ def process_call(spec, *, progress=_noop, enable_acoustic=None):
114
+ """
115
+ Run the full pipeline for one call.
116
+
117
+ spec: {call_id, domain, accent, agent_wav, customer_wav} (wav paths absolute).
118
+ progress: called with a stage name string at each transition.
119
+ Returns an artifacts dict describing what was written (paths + index row).
120
+ """
121
+ call_id = spec["call_id"]
122
+ accent = spec["accent"]
123
+ domain = spec["domain"]
124
+ do_acoustic = _acoustic_enabled(enable_acoustic)
125
+
126
+ agent_wav, customer_wav = _canonical_wavs(
127
+ call_id, accent, spec["agent_wav"], spec["customer_wav"])
128
+
129
+ artifacts = {"call_id": call_id, "domain": domain, "accent": accent,
130
+ "acoustic": False, "paths": {}}
131
+
132
+ # 1. transcribe -----------------------------------------------------------
133
+ # idempotent: an existing non-empty transcript is reused, so a job retried
134
+ # after a mid-pipeline crash skips the expensive re-transcription (fresh
135
+ # uploads carry unique call_ids, so this never wrongly reuses another call)
136
+ progress("transcribing")
137
+ transcript_path = paths.NA_TESTSET / RESULTS_DIR_NAME / accent / f"{call_id}.json"
138
+ if transcript_path.exists() and transcript_path.stat().st_size > 0:
139
+ print(f" [transcribe] reusing existing transcript {transcript_path.name}")
140
+ result = json.loads(transcript_path.read_text(encoding="utf-8"))
141
+ else:
142
+ from transcribe_channels import transcribe_call
143
+ model = _whisper_model()
144
+ agent_words, customer_words, _segs, _dur = transcribe_call(
145
+ model, agent_wav, customer_wav,
146
+ decode={"initial_prompt": prompt_for(domain)})
147
+ result = {
148
+ "model": f"faster-whisper {os.environ.get('WHISPER_MODEL', 'small.en')}/int8 + per-channel",
149
+ "call_id": call_id, "accent": accent, "domain": domain,
150
+ "agent": agent_words, "customer": customer_words,
151
+ }
152
+ _write(transcript_path, result)
153
+ artifacts["paths"]["transcript"] = str(transcript_path)
154
+
155
+ # 2. register -------------------------------------------------------------
156
+ progress("registering")
157
+ _register(call_id, accent, domain)
158
+
159
+ # 3. segment --------------------------------------------------------------
160
+ progress("segmenting")
161
+ seg = segment_transcript(result)
162
+ _write(paths.SENTENCE_SEG_ROOT / domain.lower() / f"{call_id}.json", seg)
163
+ # flat, frontend-shaped copy CallDetail fetches (it reads only .sentences)
164
+ _write(paths.FRONTEND_PUBLIC / "sentence_segments" / f"{call_id}.json", {
165
+ "call": call_id, "model": seg.get("model", ""),
166
+ "total": seg["total"], "agent_sentences": seg["agent_sentences"],
167
+ "customer_sentences": seg["customer_sentences"], "sentences": seg["sentences"],
168
+ })
169
+ artifacts["paths"]["sentence_segments"] = str(
170
+ paths.FRONTEND_PUBLIC / "sentence_segments" / f"{call_id}.json")
171
+
172
+ # 4. acoustic (gated) -----------------------------------------------------
173
+ if do_acoustic:
174
+ progress("acoustic")
175
+ try:
176
+ import acoustic
177
+ sentiment = acoustic.analyze_call(call_id, seg, agent_wav, customer_wav)
178
+ sdir = paths.SENTIMENT_ROOT / domain.lower()
179
+ _write(sdir / f"{call_id}.json", sentiment) # fusion reads here
180
+ _write(sdir / f"{call_id}_segments.json", seg) # fusion join
181
+ _write(paths.FRONTEND_PUBLIC / "sentiment" / f"{call_id}.json", sentiment)
182
+ artifacts["acoustic"] = True
183
+ artifacts["paths"]["sentiment"] = str(
184
+ paths.FRONTEND_PUBLIC / "sentiment" / f"{call_id}.json")
185
+ except Exception as e:
186
+ print(f" [acoustic] unavailable -> text-only fusion: "
187
+ f"{type(e).__name__}: {e}")
188
+ else:
189
+ print(" [acoustic] disabled (ENABLE_ACOUSTIC=0) -> text-only fusion")
190
+
191
+ # 5. evaluate -------------------------------------------------------------
192
+ progress("evaluating")
193
+ ev = evaluate_call(call_id, results_dir=RESULTS_DIR_NAME)
194
+ _write(EVAL_RESULTS / f"{call_id}_graph.json", ev)
195
+ artifacts["paths"]["evaluation"] = str(EVAL_RESULTS / f"{call_id}_graph.json")
196
+
197
+ # 6. export ---------------------------------------------------------------
198
+ progress("exporting")
199
+ meta = load_manifest()[call_id]
200
+ calls_out = str(paths.FRONTEND_PUBLIC / "calls")
201
+ audio_out = str(paths.FRONTEND_PUBLIC / "audio")
202
+ out = export_call_artifacts(call_id, meta, calls_out, audio_out)
203
+ summary = summarize(call_id, out)
204
+ _publish_index(call_id, summary)
205
+ artifacts["paths"]["call_json"] = os.path.join(calls_out, f"{call_id}.json")
206
+ artifacts["paths"]["audio"] = os.path.join(audio_out, f"{call_id}.mp3")
207
+ artifacts["index_summary"] = summary
208
+
209
+ progress("done")
210
+ return artifacts
ml-services/pipeline/prompts_domain.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Whisper decode hints per call domain.
3
+
4
+ Shared by the offline batch transcriber (scripts/run_batch.py) and the
5
+ single-call orchestrator (pipeline/orchestrator.py) so both feed identical
6
+ initial_prompt context to faster-whisper. Domains without an explicit entry
7
+ fall back to FALLBACK_PROMPT -- the prompt is only a soft decoding bias, so a
8
+ generic hint is safe for the domains outside the original three.
9
+ """
10
+
11
+ DOMAIN_PROMPTS = {
12
+ "banking": (
13
+ "Banking and customer service call between an agent and a customer. "
14
+ "Topics include accounts, transfers, payments, balances, credit cards, "
15
+ "loans, account numbers, and online banking."
16
+ ),
17
+ "health": (
18
+ "Healthcare customer service call between an agent and a patient or caller. "
19
+ "Topics include appointments, medical records, prescriptions, insurance "
20
+ "coverage, billing, referrals, and health plan benefits."
21
+ ),
22
+ "telecom": (
23
+ "Telecommunications customer service call between an agent and a customer. "
24
+ "Topics include mobile plans, internet service, data usage, billing, "
25
+ "account management, and technical support."
26
+ ),
27
+ }
28
+
29
+ FALLBACK_PROMPT = "Customer service call between an agent and a customer."
30
+
31
+
32
+ def prompt_for(domain):
33
+ """Decode hint for a domain (case-insensitive), FALLBACK_PROMPT if unknown."""
34
+ return DOMAIN_PROMPTS.get((domain or "").lower(), FALLBACK_PROMPT)
ml-services/pipeline/run_one.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CLI: run the single-call orchestrator for one call_id from the manifest.
3
+
4
+ python run_one.py --call_id en_CA_Aviation_1586888 # env ENABLE_ACOUSTIC decides
5
+ python run_one.py --call_id en_CA_Aviation_1586888 --no-acoustic
6
+ python run_one.py --call_id en_CA_Aviation_1586888 --acoustic
7
+
8
+ Builds the CallSpec from the manifest (base + runtime) and streams stage
9
+ timings. The heavy lifting lives in orchestrator.process_call.
10
+ """
11
+ import sys
12
+ import json
13
+ import time
14
+ import argparse
15
+ from pathlib import Path
16
+
17
+ _ML = Path(__file__).resolve().parents[1]
18
+ for _p in (Path(__file__).resolve().parent, _ML / "evaluation", _ML / "scripts", _ML):
19
+ if str(_p) not in sys.path:
20
+ sys.path.insert(0, str(_p))
21
+
22
+ import paths # noqa: E402
23
+ from assemble import load_manifest # noqa: E402
24
+ from orchestrator import process_call # noqa: E402
25
+
26
+
27
+ def main():
28
+ ap = argparse.ArgumentParser()
29
+ ap.add_argument("--call_id", required=True)
30
+ g = ap.add_mutually_exclusive_group()
31
+ g.add_argument("--acoustic", dest="acoustic", action="store_true",
32
+ help="force the acoustic stage on")
33
+ g.add_argument("--no-acoustic", dest="acoustic", action="store_false",
34
+ help="force the acoustic stage off")
35
+ ap.set_defaults(acoustic=None) # None -> ENABLE_ACOUSTIC env decides
36
+ args = ap.parse_args()
37
+
38
+ manifest = load_manifest()
39
+ if args.call_id not in manifest:
40
+ raise SystemExit(f"call_id '{args.call_id}' not in manifest")
41
+ m = manifest[args.call_id]
42
+ spec = {
43
+ "call_id": args.call_id,
44
+ "domain": m["domain"],
45
+ "accent": m["accent"],
46
+ "agent_wav": str(paths.NA_TESTSET / m["agent_wav"].replace("\\", "/")),
47
+ "customer_wav": str(paths.NA_TESTSET / m["customer_wav"].replace("\\", "/")),
48
+ }
49
+
50
+ t0 = time.time()
51
+ print(f"Processing {args.call_id} "
52
+ f"(domain={m['domain']}, accent={m['accent']}, acoustic={args.acoustic})")
53
+ print("-" * 68)
54
+
55
+ def progress(stage):
56
+ print(f" [{time.time() - t0:6.1f}s] {stage}")
57
+
58
+ artifacts = process_call(spec, progress=progress, enable_acoustic=args.acoustic)
59
+
60
+ print("-" * 68)
61
+ print(f"Done in {time.time() - t0:.1f}s")
62
+ print(json.dumps(artifacts, indent=2))
63
+
64
+
65
+ if __name__ == "__main__":
66
+ main()
ml-services/scripts/audio_preprocess.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Phase 2 audio preprocessing: high-pass filter + loudness normalization.
3
+
4
+ Applied PER CHANNEL, before transcription. Both operations are zero-phase or
5
+ gain-only, so word-level timestamps are unaffected (critical -- we worked hard
6
+ for accurate timestamps and won't shift them here).
7
+
8
+ High-pass filter
9
+ ----------------
10
+ Zero-phase Butterworth high-pass (sosfiltfilt -> no group delay -> no timestamp
11
+ shift). Removes DC offset, sub-bass rumble, and mains hum below the cutoff.
12
+
13
+ NOTE: spectral analysis of this dataset showed ~0% energy below 80 Hz -- these
14
+ recordings were already high-passed upstream. So on THIS data the filter is
15
+ near-neutral. It is kept because (a) it is correct, robust practice, (b) it is
16
+ cheap insurance for any future recording that does contain rumble, and (c) it
17
+ removes any residual DC before the loudness measurement.
18
+
19
+ Loudness normalization
20
+ ----------------------
21
+ The real lever. Active-RMS normalization: estimate the speech level from
22
+ speech frames only (frames within `rel_db` of the loudest frame, so the estimate
23
+ is immune to the channel's silence fraction), then apply a single gain to hit
24
+ `target_dbfs`. Quiet channels (one was at -37 dBFS active RMS) get boosted toward
25
+ the level Whisper's VAD and encoder expect; already-loud channels barely move.
26
+
27
+ Safety: gain is capped (`max_gain_db`) so a near-silent channel is not blown up,
28
+ and the output peak is limited to `peak_ceiling` to prevent clipping.
29
+
30
+ We deliberately do NOT use pyloudnorm/LUFS (extra dependency, and gated LUFS is
31
+ overkill for mono speech) nor spectral denoising (Whisper is trained on noisy
32
+ audio; aggressive denoising tends to hurt -- that is a separate, measured
33
+ experiment, not a default).
34
+ """
35
+
36
+ import numpy as np
37
+ from scipy import signal
38
+
39
+ SR = 16000
40
+
41
+
42
+ def highpass(audio, sr=SR, cutoff=80.0, order=2):
43
+ """Zero-phase Butterworth high-pass. No timestamp shift."""
44
+ if len(audio) < 32:
45
+ return audio
46
+ sos = signal.butter(order, cutoff, btype="high", fs=sr, output="sos")
47
+ return signal.sosfiltfilt(sos, audio).astype(np.float32)
48
+
49
+
50
+ def active_rms(audio, sr=SR, frame_ms=20, rel_db=25.0):
51
+ """
52
+ Speech-level estimate: RMS over frames within `rel_db` of the loudest frame.
53
+ Relative threshold -> robust to how much of the channel is silence.
54
+ """
55
+ fl = int(frame_ms / 1000 * sr)
56
+ if len(audio) < fl:
57
+ return float(np.sqrt(np.mean(audio ** 2) + 1e-12))
58
+ n = len(audio) // fl
59
+ fr = audio[: n * fl].reshape(n, fl)
60
+ fe = np.sqrt((fr ** 2).mean(axis=1) + 1e-12)
61
+ thr = fe.max() * (10 ** (-rel_db / 20))
62
+ speech = fe >= thr
63
+ if not speech.any():
64
+ return float(np.sqrt(np.mean(audio ** 2) + 1e-12))
65
+ return float(np.sqrt((fr[speech] ** 2).mean() + 1e-12))
66
+
67
+
68
+ def soft_limit(x, knee=0.85, ceiling=0.98):
69
+ """
70
+ Soft knee limiter. Samples below `knee` pass through linearly (the bulk of
71
+ speech); samples above are tanh-compressed toward `ceiling`. This saturates
72
+ only the rare transient peaks instead of scaling the whole signal down --
73
+ so a quiet channel with one click/pop can still reach target loudness.
74
+ """
75
+ ax = np.abs(x)
76
+ over = ax > knee
77
+ if not over.any():
78
+ return x
79
+ out = x.astype(np.float32).copy()
80
+ s = np.sign(x[over])
81
+ out[over] = s * (knee + (ceiling - knee) * np.tanh((ax[over] - knee) / (ceiling - knee)))
82
+ return out
83
+
84
+
85
+ def loudness_normalize(audio, sr=SR, target_dbfs=-20.0, max_gain_db=25.0):
86
+ """Scale to target active-RMS level (gain-capped), then soft-limit peaks."""
87
+ lvl = active_rms(audio, sr)
88
+ if lvl <= 1e-9:
89
+ return audio
90
+ gain_db = min(target_dbfs - 20 * np.log10(lvl), max_gain_db)
91
+ out = audio * (10 ** (gain_db / 20))
92
+ return soft_limit(out).astype(np.float32)
93
+
94
+
95
+ def preprocess(audio, sr=SR, do_highpass=True, do_loudness=True,
96
+ hp_cutoff=80.0, target_dbfs=-20.0, loudness_gate_db=-26.0):
97
+ """
98
+ High-pass then conditional loudness normalization.
99
+
100
+ Loudness normalization is only applied when the channel's active RMS is
101
+ below `loudness_gate_db` (default -26 dBFS). Channels already at reasonable
102
+ levels are left untouched, avoiding transcription regressions caused by
103
+ over-amplifying carefully-spoken digits or quiet call openings.
104
+
105
+ Order matters: high-pass first to remove DC/rumble before measuring level.
106
+ """
107
+ if do_highpass:
108
+ audio = highpass(audio, sr, hp_cutoff)
109
+ if do_loudness:
110
+ lvl = 20 * np.log10(active_rms(audio, sr) + 1e-12)
111
+ if lvl < loudness_gate_db:
112
+ audio = loudness_normalize(audio, sr, target_dbfs)
113
+ return audio
114
+
115
+
116
+ if __name__ == "__main__":
117
+ import os, json, soundfile as sf
118
+ DATA = r"d:\Desktop\ai-ml-capstone\data\na_testset"
119
+ with open(os.path.join(DATA, "manifest.json")) as f:
120
+ man = {m["call_id"]: m for m in json.load(f)}
121
+ print("Self-test (active RMS dBFS, before -> after preprocess):")
122
+ for cid in ["en_US_General_Health_1587175", "en_CA_Banking_1588683", "en_CA_Banking_1586889"]:
123
+ m = man[cid]
124
+ for role, key in [("agent", "agent_wav"), ("customer", "customer_wav")]:
125
+ a, sr = sf.read(os.path.join(DATA, m[key]), dtype="float32")
126
+ if a.ndim > 1:
127
+ a = a.mean(axis=1)
128
+ before = 20 * np.log10(active_rms(a, sr) + 1e-12)
129
+ out = preprocess(a, sr)
130
+ after = 20 * np.log10(active_rms(out, sr) + 1e-12)
131
+ peak_after = 20 * np.log10(float(np.abs(out).max()) + 1e-12)
132
+ print(f" {cid:<30} {role:<9} {before:6.1f} -> {after:6.1f} dBFS (peak {peak_after:5.1f})")
ml-services/scripts/build_na_testset.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Build a North-American-accent test set from apptek_callcenter_dialogues.
3
+
4
+ Downloads the parquet index, filters to the requested accents (all domains),
5
+ pairs channel1 (agent) + channel2 (customer) into calls, downloads the per-channel
6
+ WAVs, and writes a manifest.json the batch runner / evaluator consume.
7
+
8
+ Resumable: skips WAVs already on disk.
9
+
10
+ Usage:
11
+ python build_na_testset.py --accents en-CA en-US_General
12
+ python build_na_testset.py --accents en-CA en-US_General --limit-per-accent 3 # pilot
13
+ """
14
+
15
+ import os
16
+ import re
17
+ import json
18
+ import time
19
+ import argparse
20
+ import urllib.request
21
+ from collections import defaultdict
22
+
23
+ import pyarrow.parquet as pq
24
+
25
+ REPO = "apptek-com/apptek_callcenter_dialogues"
26
+ PARQUET_URL = (
27
+ "https://huggingface.co/datasets/apptek-com/apptek_callcenter_dialogues"
28
+ "/resolve/refs%2Fconvert%2Fparquet/default/test/0000.parquet"
29
+ )
30
+ OUT_DIR = r"d:\Desktop\ai-ml-capstone\data\na_testset"
31
+ INDEX_PARQUET = os.path.join(OUT_DIR, "_index.parquet")
32
+ MANIFEST = os.path.join(OUT_DIR, "manifest.json")
33
+ UA = {"User-Agent": "Mozilla/5.0"}
34
+
35
+
36
+ def fetch(url, dest, retries=3):
37
+ for attempt in range(1, retries + 1):
38
+ try:
39
+ req = urllib.request.Request(url, headers=UA)
40
+ with urllib.request.urlopen(req, timeout=120) as r, open(dest, "wb") as f:
41
+ f.write(r.read())
42
+ return True
43
+ except Exception as e:
44
+ print(f" attempt {attempt}/{retries} failed: {e}")
45
+ time.sleep(2 * attempt)
46
+ return False
47
+
48
+
49
+ def path_to_url(hf_path):
50
+ # hf://datasets/<repo>@<rev>/test/<accent>/audio/<file>.wav
51
+ m = re.search(r"@([0-9a-f]+)/(.*)$", hf_path)
52
+ rev, path_in_repo = m.group(1), m.group(2)
53
+ return f"https://huggingface.co/datasets/{REPO}/resolve/{rev}/{path_in_repo}"
54
+
55
+
56
+ def call_id_of(hf_path):
57
+ fname = hf_path.split("/")[-1] # en_CA_Agriculture_1586885_channel1.wav
58
+ return re.sub(r"_channel[12]\.wav$", "", fname) # en_CA_Agriculture_1586885
59
+
60
+
61
+ def main():
62
+ ap = argparse.ArgumentParser()
63
+ ap.add_argument("--accents", nargs="+", default=["en-CA", "en-US_General"])
64
+ ap.add_argument("--limit-per-accent", type=int, default=None,
65
+ help="cap calls per accent (for a pilot run)")
66
+ args = ap.parse_args()
67
+
68
+ os.makedirs(OUT_DIR, exist_ok=True)
69
+
70
+ # 1. Index
71
+ if not os.path.exists(INDEX_PARQUET):
72
+ print("Downloading parquet index...")
73
+ if not fetch(PARQUET_URL, INDEX_PARQUET):
74
+ raise SystemExit("Failed to download parquet index")
75
+ df = pq.read_table(INDEX_PARQUET).to_pandas()
76
+ df["path"] = df["audio"].apply(lambda a: a["path"])
77
+
78
+ # 2. Filter + pair channels into calls
79
+ manifest = []
80
+ for accent in args.accents:
81
+ sub = df[df["accent"] == accent]
82
+ calls = defaultdict(dict)
83
+ for _, row in sub.iterrows():
84
+ cid = call_id_of(row["path"])
85
+ ch = "agent" if row["path"].endswith("channel1.wav") else "customer"
86
+ calls[cid][ch] = row
87
+
88
+ complete = {cid: v for cid, v in calls.items() if "agent" in v and "customer" in v}
89
+ cids = sorted(complete)
90
+ if args.limit_per_accent:
91
+ cids = cids[: args.limit_per_accent]
92
+ print(f"\n{accent}: {len(complete)} complete calls"
93
+ f"{f' (using first {len(cids)})' if args.limit_per_accent else ''}")
94
+
95
+ adir = os.path.join(OUT_DIR, accent)
96
+ os.makedirs(adir, exist_ok=True)
97
+
98
+ for i, cid in enumerate(cids, 1):
99
+ a_row, c_row = complete[cid]["agent"], complete[cid]["customer"]
100
+ a_wav = os.path.join(adir, f"{cid}_agent.wav")
101
+ c_wav = os.path.join(adir, f"{cid}_customer.wav")
102
+
103
+ for wav, row in [(a_wav, a_row), (c_wav, c_row)]:
104
+ if os.path.exists(wav) and os.path.getsize(wav) > 0:
105
+ continue
106
+ print(f" [{i}/{len(cids)}] {os.path.basename(wav)}")
107
+ if not fetch(path_to_url(row["path"]), wav):
108
+ print(f" SKIP (download failed)")
109
+
110
+ if os.path.exists(a_wav) and os.path.exists(c_wav):
111
+ manifest.append({
112
+ "call_id": cid,
113
+ "accent": accent,
114
+ "domain": a_row["domain"],
115
+ "agent_wav": os.path.relpath(a_wav, OUT_DIR),
116
+ "customer_wav": os.path.relpath(c_wav, OUT_DIR),
117
+ "agent_gender": a_row["gender"],
118
+ "customer_gender": c_row["gender"],
119
+ "agent_transcript": a_row["text"],
120
+ "customer_transcript": c_row["text"],
121
+ })
122
+
123
+ with open(MANIFEST, "w", encoding="utf-8") as f:
124
+ json.dump(manifest, f, indent=2)
125
+
126
+ by_accent = defaultdict(int)
127
+ for m in manifest:
128
+ by_accent[m["accent"]] += 1
129
+ print(f"\nManifest written: {MANIFEST}")
130
+ print(f"Total calls: {len(manifest)} " + " ".join(f"{k}={v}" for k, v in by_accent.items()))
131
+
132
+
133
+ if __name__ == "__main__":
134
+ main()
ml-services/scripts/compare_models.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Standalone model comparison over the 12-call probe. NO API calls, NO transcription
3
+ -- pure evaluation of whatever result dirs exist on disk. Run anytime.
4
+
5
+ Reads (when present):
6
+ results_channels small.en (words: agent/customer lists)
7
+ results_channels_medium medium.en (words)
8
+ results_channels_large large-v3 (words)
9
+ results_api_vad whisper-1 API + VAD trim (agent_text/customer_text)
10
+ results_channels_llm small.en + LLM cleanup (agent_text/customer_text)
11
+
12
+ Prints: per-call table, OVERALL, per-tier averages, and the banking-only cut
13
+ (banking is the project's target domain). Flags whether each model clears the
14
+ 93% goal with cushion.
15
+ """
16
+ import os, json, jiwer
17
+ from collections import defaultdict
18
+ from eval_common import normalise
19
+
20
+ DATA = r"d:\Desktop\ai-ml-capstone\data\na_testset"
21
+ MANIFEST = os.path.join(DATA, "manifest.json")
22
+ PROBE_SET = os.path.join(DATA, "probe_set.json")
23
+
24
+ MODELS = [
25
+ ("small", "results_channels", "words"),
26
+ ("medium", "results_channels_medium", "words"),
27
+ ("large", "results_channels_large", "words"),
28
+ ("API", "results_api_vad", "text"),
29
+ ("LLM", "results_channels_llm", "text"),
30
+ ]
31
+
32
+
33
+ def acc(ref, hyp):
34
+ r, h = normalise(ref), normalise(hyp)
35
+ n = len(r.split())
36
+ return (1 - jiwer.wer(r, h)) * 100 if n else 100.0, n
37
+
38
+
39
+ def load_call(model_dir, fmt, accent, cid):
40
+ """Return (agent_str, customer_str) or None if not present."""
41
+ path = os.path.join(DATA, model_dir, accent, cid + ".json")
42
+ if not os.path.exists(path) or os.path.getsize(path) == 0:
43
+ return None
44
+ with open(path, encoding="utf-8") as f:
45
+ d = json.load(f)
46
+ if fmt == "words":
47
+ return (" ".join(w["word"] for w in d["agent"]),
48
+ " ".join(w["word"] for w in d["customer"]))
49
+ return d["agent_text"], d["customer_text"]
50
+
51
+
52
+ def main():
53
+ with open(MANIFEST, encoding="utf-8") as f:
54
+ manifest = {m["call_id"]: m for m in json.load(f)}
55
+ with open(PROBE_SET, encoding="utf-8") as f:
56
+ probe = json.load(f)["calls"]
57
+
58
+ # which models actually have data
59
+ present = []
60
+ for name, d, fmt in MODELS:
61
+ if os.path.isdir(os.path.join(DATA, d)):
62
+ present.append((name, d, fmt))
63
+
64
+ # per-call overall accuracy per model
65
+ rows = [] # (cid, tier, domain, {model: (acc, words, n_done)})
66
+ agg = {n: [0.0, 0, 0] for n, _, _ in present} # [acc*words, words, n_calls]
67
+ tier_agg = defaultdict(lambda: {n: [0.0, 0] for n, _, _ in present})
68
+ bank_agg = {n: [0.0, 0] for n, _, _ in present}
69
+
70
+ for p in probe:
71
+ cid, accent, tier = p["call_id"], p["accent"], p["tier"]
72
+ m = manifest[cid]
73
+ domain = m["domain"]
74
+ cell = {}
75
+ for name, d, fmt in present:
76
+ loaded = load_call(d, fmt, accent, cid)
77
+ if loaded is None:
78
+ cell[name] = None
79
+ continue
80
+ ag, cu = loaded
81
+ aa, na = acc(m["agent_transcript"], ag)
82
+ ac, nc = acc(m["customer_transcript"], cu)
83
+ o = (aa*na + ac*nc) / (na+nc)
84
+ cell[name] = (o, na+nc)
85
+ agg[name][0] += o*(na+nc); agg[name][1] += na+nc; agg[name][2] += 1
86
+ tier_agg[tier][name][0] += o; tier_agg[tier][name][1] += 1
87
+ if domain.lower() == "banking":
88
+ bank_agg[name][0] += o; bank_agg[name][1] += 1
89
+ rows.append((cid, tier, domain, cell))
90
+
91
+ names = [n for n, _, _ in present]
92
+ hdr = " {:<32} {:<13}".format("call_id", "tier") + "".join(f"{n:>8}" for n in names)
93
+ print(hdr)
94
+ print(" " + "-" * (len(hdr)-2))
95
+ for cid, tier, domain, cell in rows:
96
+ line = " {:<32} {:<13}".format(cid, tier)
97
+ for n in names:
98
+ v = cell[n]
99
+ line += f"{v[0]:>7.1f}%" if v else f"{'--':>8}"
100
+ print(line)
101
+ print(" " + "-" * (len(hdr)-2))
102
+
103
+ # overall
104
+ line = " {:<32} {:<13}".format("OVERALL", "")
105
+ overall = {}
106
+ for n in names:
107
+ if agg[n][1]:
108
+ o = agg[n][0]/agg[n][1]; overall[n] = (o, agg[n][2])
109
+ line += f"{o:>7.1f}%"
110
+ else:
111
+ line += f"{'--':>8}"
112
+ print(line)
113
+ # n calls done per model
114
+ line = " {:<32} {:<13}".format("(calls scored)", "")
115
+ for n in names:
116
+ line += f"{agg[n][2]:>7}/12" if agg[n][2] else f"{'--':>8}"
117
+ print(line.replace("/12 ", "/12"))
118
+
119
+ # per-tier
120
+ print("\n Per-tier average accuracy:")
121
+ tiers = ["catastrophic", "bad_banking", "mid_banking", "good_banking", "good_other"]
122
+ print(" {:<14}".format("tier") + "".join(f"{n:>8}" for n in names))
123
+ for t in tiers:
124
+ if t not in tier_agg: continue
125
+ line = " {:<14}".format(t)
126
+ for n in names:
127
+ s, c = tier_agg[t][n]
128
+ line += f"{s/c:>7.1f}%" if c else f"{'--':>8}"
129
+ print(line)
130
+
131
+ # banking cut
132
+ print("\n BANKING-ONLY (target domain):")
133
+ line = " {:<14}".format("banking avg")
134
+ for n in names:
135
+ s, c = bank_agg[n]
136
+ line += f"{s/c:>7.1f}%" if c else f"{'--':>8}"
137
+ print(line)
138
+
139
+ # goal check
140
+ print("\n Goal = >=93.0% overall with cushion:")
141
+ for n in names:
142
+ if n in overall and overall[n][1] == 12:
143
+ o = overall[n][0]
144
+ tag = "CLEARS +cushion" if o >= 93.5 else "clears (thin)" if o >= 93.0 else "short"
145
+ print(f" {n:<8} {o:5.1f}% ({12} calls) -> {tag}")
146
+ elif n in overall:
147
+ print(f" {n:<8} {overall[n][0]:5.1f}% ({overall[n][1]}/12 calls -- partial)")
148
+
149
+
150
+ if __name__ == "__main__":
151
+ main()
ml-services/scripts/compare_probe.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ A/B comparison on the frozen probe set. Compares two result dirs without
3
+ re-transcribing. Default: per-channel (A) vs per-channel+preprocess (B).
4
+ The 'base' column is the static mono-mix baseline_acc recorded in probe_set.json.
5
+
6
+ Also reports:
7
+ - no_speech_prob diagnostics per dir (mean, #high-ns segments, total words) to
8
+ show whether preprocessing reduced Whisper's silence-hallucination signal
9
+ - a confidence-filter experiment on B (drop words with segment ns > threshold)
10
+
11
+ Usage:
12
+ python compare_probe.py # chan vs chan+pp
13
+ python compare_probe.py --a-dir results --a-label mix --b-dir results_channels --b-label chan
14
+ """
15
+
16
+ import os
17
+ import json
18
+ import argparse
19
+ import jiwer
20
+ from eval_common import normalise
21
+
22
+ DATA_DIR = r"d:\Desktop\ai-ml-capstone\data\na_testset"
23
+ MANIFEST = os.path.join(DATA_DIR, "manifest.json")
24
+ PROBE_SET = os.path.join(DATA_DIR, "probe_set.json")
25
+
26
+
27
+ def load_result(root, accent, cid):
28
+ p = os.path.join(DATA_DIR, root, accent, cid + ".json")
29
+ if not os.path.exists(p):
30
+ return None
31
+ with open(p, encoding="utf-8") as f:
32
+ return json.load(f)
33
+
34
+
35
+ def text_of(words, ns_max=None):
36
+ if ns_max is None:
37
+ return " ".join(w["word"] for w in words)
38
+ return " ".join(w["word"] for w in words if w.get("ns", 0.0) <= ns_max)
39
+
40
+
41
+ def score_call(ref_a, ref_c, hyp_a, hyp_c):
42
+ ra, rc = normalise(ref_a), normalise(ref_c)
43
+ ha, hc = normalise(hyp_a), normalise(hyp_c)
44
+ na, nc = len(ra.split()), len(rc.split())
45
+ if na + nc == 0:
46
+ return None, 0
47
+ wa = jiwer.wer(ra, ha) if na else 0.0
48
+ wc = jiwer.wer(rc, hc) if nc else 0.0
49
+ return (wa * na + wc * nc) / (na + nc), na + nc
50
+
51
+
52
+ def acc(wer):
53
+ return None if wer is None else (1 - wer) * 100
54
+
55
+
56
+ def ns_diag(result):
57
+ """(#high-ns segments, total segments, total hyp words) from captured segments."""
58
+ if not result or "segments" not in result:
59
+ return None
60
+ segs = result["segments"].get("agent", []) + result["segments"].get("customer", [])
61
+ hi = sum(1 for s in segs if s.get("no_speech_prob", 0) > 0.6)
62
+ words = len(result.get("agent", [])) + len(result.get("customer", []))
63
+ return hi, len(segs), words
64
+
65
+
66
+ def main():
67
+ ap = argparse.ArgumentParser()
68
+ ap.add_argument("--a-dir", default="results_channels"); ap.add_argument("--a-label", default="chan")
69
+ ap.add_argument("--b-dir", default="results_channels_pp"); ap.add_argument("--b-label", default="chan+pp")
70
+ ap.add_argument("--ns-thresholds", nargs="*", type=float, default=[0.6, 0.8])
71
+ args = ap.parse_args()
72
+
73
+ with open(MANIFEST, encoding="utf-8") as f:
74
+ manifest = {m["call_id"]: m for m in json.load(f)}
75
+ with open(PROBE_SET, encoding="utf-8") as f:
76
+ probe = json.load(f)["calls"]
77
+
78
+ print(f"{'tier':<13} {'call_id':<32} {'base':>6} {args.a_label:>7} {args.b_label:>8} {'delta':>7}")
79
+ print("-" * 80)
80
+
81
+ agg = {"a": [0.0, 0], "b": [0.0, 0]}
82
+ agg_ns = {t: [0.0, 0] for t in args.ns_thresholds}
83
+ nsA = [0, 0, 0]; nsB = [0, 0, 0] # hi, segs, words
84
+ missing = []
85
+
86
+ for p in probe:
87
+ cid, accent = p["call_id"], p["accent"]
88
+ m = manifest[cid]
89
+ ra_, rc_ = m["agent_transcript"], m["customer_transcript"]
90
+ A = load_result(args.a_dir, accent, cid)
91
+ B = load_result(args.b_dir, accent, cid)
92
+ if A is None or B is None:
93
+ missing.append((cid, args.a_dir if A is None else args.b_dir))
94
+ continue
95
+
96
+ a_wer, n = score_call(ra_, rc_, text_of(A["agent"]), text_of(A["customer"]))
97
+ b_wer, _ = score_call(ra_, rc_, text_of(B["agent"]), text_of(B["customer"]))
98
+ agg["a"][0] += a_wer * n; agg["a"][1] += n
99
+ agg["b"][0] += b_wer * n; agg["b"][1] += n
100
+
101
+ for t in args.ns_thresholds:
102
+ w, _ = score_call(ra_, rc_, text_of(B["agent"], t), text_of(B["customer"], t))
103
+ agg_ns[t][0] += w * n; agg_ns[t][1] += n
104
+
105
+ for store, res in [(nsA, A), (nsB, B)]:
106
+ d = ns_diag(res)
107
+ if d:
108
+ store[0] += d[0]; store[1] += d[1]; store[2] += d[2]
109
+
110
+ delta = acc(b_wer) - acc(a_wer)
111
+ flag = " <<" if delta >= 3 else (" !!" if delta <= -3 else "")
112
+ print(f"{p['tier']:<13} {cid:<32} {p['baseline_acc']*100:>5.1f}% "
113
+ f"{acc(a_wer):>6.1f}% {acc(b_wer):>7.1f}% {delta:>+6.1f}{flag}")
114
+
115
+ print("-" * 80)
116
+ a_acc = acc(agg["a"][0] / agg["a"][1])
117
+ b_acc = acc(agg["b"][0] / agg["b"][1])
118
+ print(f"{'OVERALL (word-weighted)':<46} {a_acc:>6.1f}% {b_acc:>7.1f}% {b_acc - a_acc:>+6.1f}")
119
+
120
+ print(f"\nno_speech_prob diagnostics (hallucination signal):")
121
+ print(f" {args.a_label:<10} high-ns segs: {nsA[0]:>4} / {nsA[1]:<5} segments | total hyp words: {nsA[2]}")
122
+ print(f" {args.b_label:<10} high-ns segs: {nsB[0]:>4} / {nsB[1]:<5} segments | total hyp words: {nsB[2]}")
123
+
124
+ print(f"\nConfidence filter on {args.b_label} (drop words where segment no_speech_prob > t):")
125
+ print(f" no filter : {b_acc:5.1f}%")
126
+ for t in args.ns_thresholds:
127
+ a = acc(agg_ns[t][0] / agg_ns[t][1])
128
+ print(f" ns <= {t:<4} : {a:5.1f}% ({a - b_acc:+.1f})")
129
+
130
+ if missing:
131
+ print("\nMissing results:")
132
+ for cid, which in missing:
133
+ print(f" {cid} ({which})")
134
+
135
+
136
+ if __name__ == "__main__":
137
+ main()