Spaces:
Runtime error
Runtime error
feat: add processing service runtime
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- app.py +42 -0
- backend/app/__init__.py +1 -0
- backend/app/agents/extraction.py +12 -0
- backend/app/agents/llm_client.py +18 -0
- backend/app/agents/pipeline.py +12 -0
- backend/app/agents/workflow_agent.py +11 -0
- backend/app/api/.gitkeep +1 -0
- backend/app/database.py +39 -0
- backend/app/main.py +83 -0
- backend/app/models.py +113 -0
- backend/app/pipeline_bridge.py +39 -0
- backend/app/routers/__init__.py +0 -0
- backend/app/routers/artifacts.py +61 -0
- backend/app/routers/calls.py +325 -0
- backend/app/routers/sentiment.py +138 -0
- backend/app/routing.py +32 -0
- backend/app/services/alignment.py +8 -0
- backend/app/services/batch_service.py +11 -0
- backend/app/space.py +55 -0
- backend/app/storage.py +125 -0
- backend/app/worker.py +246 -0
- ml-services/evaluation/assemble.py +148 -0
- ml-services/evaluation/batch_sentence_segments.py +207 -0
- ml-services/evaluation/benchmark.py +280 -0
- ml-services/evaluation/consistency.py +171 -0
- ml-services/evaluation/env_util.py +7 -0
- ml-services/evaluation/eval_call_data.py +140 -0
- ml-services/evaluation/export_frontend.py +215 -0
- ml-services/evaluation/extract.py +193 -0
- ml-services/evaluation/fusion.py +256 -0
- ml-services/evaluation/gen_call_data.py +115 -0
- ml-services/evaluation/graph.py +353 -0
- ml-services/evaluation/llm_client.py +243 -0
- ml-services/evaluation/nodes.py +517 -0
- ml-services/evaluation/paths.py +56 -0
- ml-services/evaluation/prompts.py +477 -0
- ml-services/evaluation/router.py +143 -0
- ml-services/evaluation/rubric.py +265 -0
- ml-services/evaluation/run_graph_batch.py +143 -0
- ml-services/evaluation/segment.py +234 -0
- ml-services/evaluation/sentence_segments.py +165 -0
- ml-services/pipeline/acoustic.py +139 -0
- ml-services/pipeline/audio_io.py +82 -0
- ml-services/pipeline/orchestrator.py +210 -0
- ml-services/pipeline/prompts_domain.py +34 -0
- ml-services/pipeline/run_one.py +66 -0
- ml-services/scripts/audio_preprocess.py +132 -0
- ml-services/scripts/build_na_testset.py +134 -0
- ml-services/scripts/compare_models.py +151 -0
- 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()
|