aniketqxp's picture
feat: add processing service runtime
f1ef7e2
Raw
History Blame Contribute Delete
9.02 kB
"""
Call chaptering / segmentation layer.
Turns the role-mapped transcript into coherent CHAPTERS (phases of the call),
like automatic video chapters: greeting, identity verification, problem
statement, investigation, resolution, closing, etc.
DESIGN -- why this is robust
----------------------------
A naive approach asks the model for mm:ss boundaries, which it can hallucinate
(timestamps past the call end, overlapping spans, gaps). Instead we:
1. NUMBER every turn and show the model the numbered transcript.
2. Ask only for the START TURN INDEX of each chapter (+ label + summary).
3. DERIVE all timestamps from the real turns, and REPAIR the result in code:
- clamp indices into range, dedupe, sort
- force chapter 1 to start at turn 0
- each chapter ends exactly where the next begins (contiguous, no gaps)
- the last chapter ends at the call duration
So the output is always valid by construction -- the model only chooses *where*
topics shift, never the raw numbers.
Uses the LiteLLM router by default (Mistral primary). Override with --provider.
Usage:
python segment.py --call_id en_CA_Banking_1592237
python segment.py --call_id en_CA_Banking_1592237 --provider github
"""
import os, json, time, argparse
from typing import List
from pydantic import BaseModel, Field, ValidationError
import paths
from env_util import load_env
from assemble import load_manifest, assemble_turns, estimate_duration, mmss
from llm_client import chat_json
from extract import strip_fences
load_env()
DATA = str(paths.NA_TESTSET)
# ── Schema ────────────────────────────────────────────────────────────────────
class Chapter(BaseModel):
index: int
label: str
start_turn: int
start_time: str # mm:ss (derived)
end_time: str # mm:ss (derived)
summary: str
class CallChapters(BaseModel):
call_id: str
domain: str
duration: str
n_turns: int
n_chapters: int
served_by: str
chapters: List[Chapter]
# ── Prompt ────────────────────────────────────────────────────────────────────
SYSTEM = """You segment a customer-service phone call into coherent CHAPTERS -- the natural phases of the call, like automatic video chapters.
Typical phases (use only those that actually occur, in the order they occur):
greeting/introduction, identity verification, problem statement, investigation/discussion, options/explanation, resolution & next steps, upsell/offer, closing.
RULES:
- Identify between 3 and 8 chapters. Fewer for short calls, more for long ones.
- Each chapter is a run of CONSECUTIVE turns covering ONE phase. No overlaps, no gaps.
- Base boundaries on REAL topic shifts in the transcript, not fixed sizes.
- The first chapter MUST start at turn 0.
- For each chapter output: start_turn (the turn index where the phase BEGINS),
a short label (2-4 words), and a one-sentence summary of what happens in it.
- Output ONLY JSON. No markdown, no commentary."""
SKELETON = """Return EXACTLY this shape:
{"chapters":[
{"start_turn":0,"label":"Greeting & Introduction","summary":"Agent greets the caller and identifies themselves and the company."},
{"start_turn":4,"label":"Identity Verification","summary":"Agent verifies the customer's identity before discussing the account."}
]}"""
def numbered_transcript(turns) -> str:
lines = []
for i, t in enumerate(turns):
lines.append(f"[{i}] [{t['speaker']} {mmss(t['start'])}] {t['text']}")
return "\n".join(lines)
def _call(provider, system, user):
"""Return (raw_json_str, served_model)."""
if provider == "router":
from router import chat_json_routed
return chat_json_routed(system, user, max_tokens=1500, return_meta=True)
raw = chat_json(provider, system, user, max_tokens=1500)
return raw, provider
def repair_chapters(seeds, turns, duration_str) -> List[Chapter]:
"""Turn raw model seeds into valid, contiguous, gap-free chapters."""
n = len(turns)
cleaned, seen = [], set()
for s in seeds:
try:
st = int(s["start_turn"])
except (KeyError, ValueError, TypeError):
continue
st = max(0, min(st, n - 1))
if st in seen:
continue
seen.add(st)
cleaned.append({"start_turn": st,
"label": str(s.get("label", "Untitled")).strip(),
"summary": str(s.get("summary", "")).strip()})
cleaned.sort(key=lambda x: x["start_turn"])
if not cleaned:
cleaned = [{"start_turn": 0, "label": "Full Call",
"summary": "Entire call."}]
# force coverage from the start
if cleaned[0]["start_turn"] != 0:
cleaned[0]["start_turn"] = 0
chapters = []
for i, c in enumerate(cleaned):
st = c["start_turn"]
start_time = mmss(turns[st]["start"])
if i + 1 < len(cleaned):
end_time = mmss(turns[cleaned[i + 1]["start_turn"]]["start"])
else:
end_time = duration_str
chapters.append(Chapter(index=i + 1, label=c["label"], start_turn=st,
start_time=start_time, end_time=end_time,
summary=c["summary"]))
return chapters
def segment(call_id, provider="router", results_dir="results_channels"):
manifest = load_manifest()
meta = manifest[call_id]
result_path = os.path.join(DATA, results_dir, meta["accent"], call_id + ".json")
with open(result_path, encoding="utf-8") as f:
result = json.load(f)
turns = assemble_turns(result)
duration_str = mmss(estimate_duration(result))
user = f"{numbered_transcript(turns)}\n\n{SKELETON}"
t0 = time.time()
raw, served = _call(provider, SYSTEM, user)
dt = time.time() - t0
data = json.loads(strip_fences(raw))
seeds = data.get("chapters", [])
chapters = repair_chapters(seeds, turns, duration_str)
cc = CallChapters(
call_id=call_id, domain=result.get("domain", meta.get("domain", "?")),
duration=duration_str, n_turns=len(turns), n_chapters=len(chapters),
served_by=served, chapters=chapters)
issues = verify_invariants(cc, turns)
return cc, dt, issues
def verify_invariants(cc: CallChapters, turns) -> List[str]:
"""Return a list of invariant violations (empty = correct)."""
issues = []
chs = cc.chapters
if not chs:
return ["no chapters produced"]
if chs[0].start_turn != 0:
issues.append("chapter 1 does not start at turn 0")
if chs[0].start_time != mmss(turns[0]["start"]):
issues.append("first chapter start != call start")
if chs[-1].end_time != cc.duration:
issues.append("last chapter end != call duration (coverage gap)")
for a, b in zip(chs, chs[1:]):
if a.end_time != b.start_time:
issues.append(f"gap/overlap between ch{a.index} and ch{b.index}")
if b.start_turn <= a.start_turn:
issues.append(f"non-increasing start_turn at ch{b.index}")
if not (1 <= cc.n_chapters <= 12):
issues.append(f"chapter count {cc.n_chapters} outside sane range")
return issues
def print_timeline(cc: CallChapters, dt, issues=None):
print("\n" + "=" * 70)
print(f" CALL TIMELINE {cc.call_id} ({cc.domain})")
print(f" {cc.duration} | {cc.n_turns} turns | {cc.n_chapters} chapters "
f"| {dt:.1f}s | {cc.served_by}")
print("=" * 70)
for ch in cc.chapters:
print(f"\n {ch.start_time}-{ch.end_time} [{ch.index}] {ch.label}")
print(f" {ch.summary}")
print("\n " + "-" * 66)
if issues is None:
print(" invariants: (not checked)")
elif issues:
print(f" invariants: {len(issues)} VIOLATION(S):")
for x in issues:
print(f" - {x}")
else:
print(" invariants: OK (contiguous, gap-free, full coverage)")
print("=" * 70)
def main():
from llm_client import list_providers
ap = argparse.ArgumentParser()
ap.add_argument("--call_id", required=True)
ap.add_argument("--provider", default="router",
choices=["router"] + list_providers())
ap.add_argument("--results_dir", default="results_channels")
args = ap.parse_args()
cc, dt, issues = segment(args.call_id, args.provider, args.results_dir)
here = os.path.dirname(os.path.abspath(__file__))
out_dir = os.path.join(here, "chapters")
os.makedirs(out_dir, exist_ok=True)
out = os.path.join(out_dir, f"{args.call_id}.json")
with open(out, "w", encoding="utf-8") as f:
json.dump(cc.model_dump(), f, indent=2)
print_timeline(cc, dt, issues)
print(f"\nSaved -> {out}")
if __name__ == "__main__":
main()