Spaces:
Sleeping
Sleeping
prianshujha commited on
Commit Β·
9a02b57
0
Parent(s):
added pipeline file
Browse files- __pycache__/config.cpython-312.pyc +0 -0
- __pycache__/db.cpython-312.pyc +0 -0
- app.py +371 -0
- config.py +71 -0
- data/calls.db +0 -0
- db.py +227 -0
- pipeline/__pycache__/evaluater.cpython-312.pyc +0 -0
- pipeline/__pycache__/intent_parser.cpython-312.pyc +0 -0
- pipeline/__pycache__/orchestrator.cpython-312.pyc +0 -0
- pipeline/__pycache__/transcriber.cpython-312.pyc +0 -0
- pipeline/__pycache__/vad_listener.cpython-312.pyc +0 -0
- pipeline/evaluater.py +456 -0
- pipeline/intent_parser.py +461 -0
- pipeline/orchestrator.py +441 -0
- pipeline/transcriber.py +369 -0
- pipeline/vad_listener.py +302 -0
- requirements.txt +34 -0
__pycache__/config.cpython-312.pyc
ADDED
|
Binary file (2.13 kB). View file
|
|
|
__pycache__/db.cpython-312.pyc
ADDED
|
Binary file (10.4 kB). View file
|
|
|
app.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py β Telecalling Agent β Gradio 6 UI
|
| 3 |
+
|
| 4 |
+
Layout
|
| 5 |
+
ββββββ
|
| 6 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 7 |
+
β π AI Telecalling Agent [status badge] β
|
| 8 |
+
ββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββ€
|
| 9 |
+
β π€ LIVE CALL β π EXTRACTED DATA β
|
| 10 |
+
β ββββββββββββββββββ β [intent markdown table] β
|
| 11 |
+
β β Audio stream β β β
|
| 12 |
+
β ββββββββββββββββββ ββββββββββββββββββββββββββββββββ€
|
| 13 |
+
β [Start] [End] β π€ AGENT RESPONSE β
|
| 14 |
+
β ββββββββββββββββββ β [spoken response box] β
|
| 15 |
+
β β Transcript β β β
|
| 16 |
+
β ββββββββββββββββββ β β
BOOKING CONFIRMED β
|
| 17 |
+
β β [booking details box] β
|
| 18 |
+
ββββββββββββββββββββββββ΄βββββββββββββββββββββββββββββββ€
|
| 19 |
+
β π CALL LOG β
|
| 20 |
+
β [dataframe β recent calls] β
|
| 21 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import logging
|
| 25 |
+
import os
|
| 26 |
+
import json
|
| 27 |
+
|
| 28 |
+
import gradio as gr
|
| 29 |
+
import numpy as np
|
| 30 |
+
|
| 31 |
+
from config import APP_TITLE, APP_DESCRIPTION, SERVER_PORT, SERVER_NAME
|
| 32 |
+
from pipeline.orchestrator import CallSession, PipelineUpdate
|
| 33 |
+
from db import init_db
|
| 34 |
+
|
| 35 |
+
# Load HuggingFace config and set token early
|
| 36 |
+
try:
|
| 37 |
+
with open("hf_config.json", "r") as f:
|
| 38 |
+
hf_cfg = json.load(f)
|
| 39 |
+
hf_token = hf_cfg.get("huggingface", {}).get("hub", {}).get("token", "")
|
| 40 |
+
if hf_token and hf_token != "${HF_TOKEN}":
|
| 41 |
+
os.environ["HF_TOKEN"] = hf_token
|
| 42 |
+
except (FileNotFoundError, json.JSONDecodeError) as e:
|
| 43 |
+
pass # hf_config.json not found or invalid, use env var if set
|
| 44 |
+
|
| 45 |
+
logging.basicConfig(
|
| 46 |
+
level=logging.INFO,
|
| 47 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 48 |
+
)
|
| 49 |
+
logger = logging.getLogger(__name__)
|
| 50 |
+
|
| 51 |
+
# Initialize database on startup
|
| 52 |
+
init_db()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ββ CSS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 56 |
+
|
| 57 |
+
CSS = """
|
| 58 |
+
/* Global */
|
| 59 |
+
.gradio-container { font-family: 'Inter', sans-serif; max-width: 1200px; }
|
| 60 |
+
|
| 61 |
+
/* Status badge */
|
| 62 |
+
#status-badge textarea {
|
| 63 |
+
font-size: 0.9rem;
|
| 64 |
+
font-weight: 600;
|
| 65 |
+
text-align: center;
|
| 66 |
+
border-radius: 20px;
|
| 67 |
+
padding: 4px 12px;
|
| 68 |
+
background: #f0fdf4;
|
| 69 |
+
border: 1px solid #86efac;
|
| 70 |
+
color: #166534;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/* Agent response */
|
| 74 |
+
#agent-box textarea {
|
| 75 |
+
font-size: 1.05rem;
|
| 76 |
+
font-style: italic;
|
| 77 |
+
background: #eff6ff;
|
| 78 |
+
border: 1px solid #93c5fd;
|
| 79 |
+
border-radius: 8px;
|
| 80 |
+
color: #1e3a5f;
|
| 81 |
+
min-height: 80px;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/* Booking confirmed */
|
| 85 |
+
#booking-box textarea {
|
| 86 |
+
background: #f0fdf4;
|
| 87 |
+
border: 1px solid #4ade80;
|
| 88 |
+
border-radius: 8px;
|
| 89 |
+
color: #14532d;
|
| 90 |
+
font-weight: 500;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/* Transcript */
|
| 94 |
+
#transcript-box textarea {
|
| 95 |
+
font-family: monospace;
|
| 96 |
+
font-size: 0.85rem;
|
| 97 |
+
background: #1e1e2e;
|
| 98 |
+
color: #cdd6f4;
|
| 99 |
+
border-radius: 8px;
|
| 100 |
+
min-height: 180px;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/* VAD indicator dot */
|
| 104 |
+
#vad-dot {
|
| 105 |
+
text-align: center;
|
| 106 |
+
font-size: 1.2rem;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
/* Call buttons */
|
| 110 |
+
.call-btn-start { background: #16a34a !important; color: white !important; }
|
| 111 |
+
.call-btn-end { background: #dc2626 !important; color: white !important; }
|
| 112 |
+
.call-btn-reset { background: #6b7280 !important; color: white !important; }
|
| 113 |
+
|
| 114 |
+
/* Intent table inside Markdown */
|
| 115 |
+
#intent-panel table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
|
| 116 |
+
#intent-panel th, #intent-panel td {
|
| 117 |
+
padding: 5px 10px;
|
| 118 |
+
border: 1px solid #e2e8f0;
|
| 119 |
+
text-align: left;
|
| 120 |
+
}
|
| 121 |
+
#intent-panel tr:nth-child(even) { background: #f8fafc; }
|
| 122 |
+
|
| 123 |
+
/* Call log */
|
| 124 |
+
#call-log { font-size: 0.82rem; }
|
| 125 |
+
"""
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# ββ UI helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 129 |
+
|
| 130 |
+
def _format_transcript(lines: list[str]) -> str:
|
| 131 |
+
if not lines:
|
| 132 |
+
return "(waiting for speechβ¦)"
|
| 133 |
+
return "\n".join(f"[{i+1}] {l}" for i, l in enumerate(lines))
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _format_booking(info: dict | None) -> str:
|
| 137 |
+
if not info:
|
| 138 |
+
return ""
|
| 139 |
+
return (
|
| 140 |
+
f"β
Booking #{info['booking_id']} confirmed!\n"
|
| 141 |
+
f" π
{info['date']} π {info['time']} "
|
| 142 |
+
f"({info['duration']} min)\n"
|
| 143 |
+
f" π€ {info['caller']} π {info['type'].replace('_', ' ').title()}"
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _call_log_rows(records: list[dict]) -> list[list]:
|
| 148 |
+
rows = []
|
| 149 |
+
for r in records:
|
| 150 |
+
ts = r.get("timestamp", "")[:16].replace("T", " ")
|
| 151 |
+
rows.append([
|
| 152 |
+
r.get("id", ""),
|
| 153 |
+
ts,
|
| 154 |
+
r.get("caller_name") or "β",
|
| 155 |
+
r.get("intent") or "β",
|
| 156 |
+
r.get("decision") or "β",
|
| 157 |
+
r.get("status") or "β",
|
| 158 |
+
])
|
| 159 |
+
return rows
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ββ Gradio App ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 163 |
+
|
| 164 |
+
def build_app() -> gr.Blocks:
|
| 165 |
+
|
| 166 |
+
with gr.Blocks(css=CSS, title=APP_TITLE, theme=gr.themes.Soft()) as demo:
|
| 167 |
+
|
| 168 |
+
# ββ Per-session state ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 169 |
+
# gr.State holds one CallSession object per browser tab.
|
| 170 |
+
session_state = gr.State(value=None)
|
| 171 |
+
|
| 172 |
+
# ββ Header βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 173 |
+
gr.Markdown(f"# {APP_TITLE}\n_{APP_DESCRIPTION}_")
|
| 174 |
+
|
| 175 |
+
status_badge = gr.Textbox(
|
| 176 |
+
value = "π’ Ready β press Start Call",
|
| 177 |
+
label = "",
|
| 178 |
+
interactive = False,
|
| 179 |
+
elem_id = "status-badge",
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
# ββ Main row βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 183 |
+
with gr.Row():
|
| 184 |
+
|
| 185 |
+
# ββ Left column: call controls + transcript ββββββββββββββββββββ
|
| 186 |
+
with gr.Column(scale=1):
|
| 187 |
+
gr.Markdown("### π€ Live Call")
|
| 188 |
+
|
| 189 |
+
audio_input = gr.Audio(
|
| 190 |
+
sources = ["microphone"],
|
| 191 |
+
streaming = True,
|
| 192 |
+
type = "numpy",
|
| 193 |
+
label = "Microphone (auto-detecting speech)",
|
| 194 |
+
interactive= True,
|
| 195 |
+
elem_id = "audio-input",
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
vad_dot = gr.Markdown("β« _mic idle_", elem_id="vad-dot")
|
| 199 |
+
|
| 200 |
+
with gr.Row():
|
| 201 |
+
btn_start = gr.Button(
|
| 202 |
+
"π Start Call", variant="primary",
|
| 203 |
+
elem_classes=["call-btn-start"],
|
| 204 |
+
)
|
| 205 |
+
btn_end = gr.Button(
|
| 206 |
+
"π΅ End Call", variant="stop",
|
| 207 |
+
elem_classes=["call-btn-end"],
|
| 208 |
+
)
|
| 209 |
+
btn_reset = gr.Button(
|
| 210 |
+
"π Reset", variant="secondary",
|
| 211 |
+
elem_classes=["call-btn-reset"],
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
transcript_box = gr.Textbox(
|
| 215 |
+
label = "π Live Transcript",
|
| 216 |
+
value = "(waiting for speechβ¦)",
|
| 217 |
+
lines = 8,
|
| 218 |
+
max_lines = 20,
|
| 219 |
+
interactive = False,
|
| 220 |
+
elem_id = "transcript-box",
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
# ββ Right column: intent + agent response + booking ββββββββββββ
|
| 224 |
+
with gr.Column(scale=1):
|
| 225 |
+
gr.Markdown("### π Extracted Data")
|
| 226 |
+
|
| 227 |
+
intent_panel = gr.Markdown(
|
| 228 |
+
"_No data yet β waiting for first utteranceβ¦_",
|
| 229 |
+
elem_id = "intent-panel",
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
gr.Markdown("### π€ Agent Response")
|
| 233 |
+
|
| 234 |
+
agent_box = gr.Textbox(
|
| 235 |
+
value = "",
|
| 236 |
+
label = "",
|
| 237 |
+
lines = 3,
|
| 238 |
+
interactive = False,
|
| 239 |
+
elem_id = "agent-box",
|
| 240 |
+
placeholder = "Agent will respond hereβ¦",
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
booking_box = gr.Textbox(
|
| 244 |
+
value = "",
|
| 245 |
+
label = "π
Booking Status",
|
| 246 |
+
lines = 3,
|
| 247 |
+
interactive = False,
|
| 248 |
+
elem_id = "booking-box",
|
| 249 |
+
visible = False,
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
# ββ Call log βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 253 |
+
gr.Markdown("### π Call Log")
|
| 254 |
+
|
| 255 |
+
call_log_table = gr.Dataframe(
|
| 256 |
+
headers = ["ID", "Timestamp", "Caller", "Intent", "Decision", "Status"],
|
| 257 |
+
datatype = ["number", "str", "str", "str", "str", "str"],
|
| 258 |
+
value = [],
|
| 259 |
+
interactive = False,
|
| 260 |
+
elem_id = "call-log",
|
| 261 |
+
row_count = (5, "dynamic"),
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
# ββ Helper: unpack PipelineUpdate β tuple of component values βββββ
|
| 265 |
+
def _unpack(u: PipelineUpdate):
|
| 266 |
+
"""Return values in the exact order of outputs lists below."""
|
| 267 |
+
vad_label = "π΄ _Speakingβ¦_" if u.vad_speaking else "β« _mic idle_"
|
| 268 |
+
booking_text = _format_booking(u.booking_confirmed)
|
| 269 |
+
booking_visible = bool(booking_text)
|
| 270 |
+
return (
|
| 271 |
+
u.status, # status_badge
|
| 272 |
+
vad_label, # vad_dot
|
| 273 |
+
_format_transcript(u.transcript_lines), # transcript_box
|
| 274 |
+
u.intent_md, # intent_panel
|
| 275 |
+
u.agent_response, # agent_box
|
| 276 |
+
booking_text, # booking_box value
|
| 277 |
+
gr.update(visible=booking_visible), # booking_box visible
|
| 278 |
+
_call_log_rows(u.call_log), # call_log_table
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
# ββ All output components in one list (matches _unpack order) βββββ
|
| 282 |
+
ALL_OUTPUTS = [
|
| 283 |
+
status_badge,
|
| 284 |
+
vad_dot,
|
| 285 |
+
transcript_box,
|
| 286 |
+
intent_panel,
|
| 287 |
+
agent_box,
|
| 288 |
+
booking_box,
|
| 289 |
+
booking_box, # second entry β gr.update(visible=β¦)
|
| 290 |
+
call_log_table,
|
| 291 |
+
]
|
| 292 |
+
|
| 293 |
+
# ββ Session factory ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 294 |
+
def _get_or_create_session(state):
|
| 295 |
+
if state is None:
|
| 296 |
+
state = CallSession()
|
| 297 |
+
return state
|
| 298 |
+
|
| 299 |
+
# ββ Button callbacks βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 300 |
+
|
| 301 |
+
def on_start(state):
|
| 302 |
+
state = _get_or_create_session(state)
|
| 303 |
+
update = state.start_call()
|
| 304 |
+
return (state, *_unpack(update))
|
| 305 |
+
|
| 306 |
+
def on_end(state):
|
| 307 |
+
state = _get_or_create_session(state)
|
| 308 |
+
update = state.end_call()
|
| 309 |
+
return (state, *_unpack(update))
|
| 310 |
+
|
| 311 |
+
def on_reset(state):
|
| 312 |
+
state = _get_or_create_session(state)
|
| 313 |
+
update = state.reset()
|
| 314 |
+
return (state, *_unpack(update))
|
| 315 |
+
|
| 316 |
+
BTN_OUTPUTS = [session_state] + ALL_OUTPUTS
|
| 317 |
+
|
| 318 |
+
btn_start.click(on_start, inputs=[session_state], outputs=BTN_OUTPUTS)
|
| 319 |
+
btn_end.click (on_end, inputs=[session_state], outputs=BTN_OUTPUTS)
|
| 320 |
+
btn_reset.click(on_reset, inputs=[session_state], outputs=BTN_OUTPUTS)
|
| 321 |
+
|
| 322 |
+
# ββ Audio streaming callback βββββββββββββββββββββββββββββββββββββββ
|
| 323 |
+
# Fires every `stream_every` seconds with (sample_rate, np.ndarray).
|
| 324 |
+
# We pass the current session state in and get it back (updated).
|
| 325 |
+
|
| 326 |
+
def on_audio_stream(audio_chunk, state):
|
| 327 |
+
"""
|
| 328 |
+
Called by Gradio every 0.5 s while the mic is active.
|
| 329 |
+
audio_chunk: (sample_rate: int, data: np.ndarray) | None
|
| 330 |
+
"""
|
| 331 |
+
state = _get_or_create_session(state)
|
| 332 |
+
|
| 333 |
+
if not state.call_active:
|
| 334 |
+
# Return current state without processing
|
| 335 |
+
u = state._build_update()
|
| 336 |
+
return (state, *_unpack(u))
|
| 337 |
+
|
| 338 |
+
if audio_chunk is None:
|
| 339 |
+
u = state._build_update()
|
| 340 |
+
return (state, *_unpack(u))
|
| 341 |
+
|
| 342 |
+
sample_rate, audio_np = audio_chunk
|
| 343 |
+
|
| 344 |
+
# Ensure float32 mono
|
| 345 |
+
audio_np = np.array(audio_np, dtype=np.float32)
|
| 346 |
+
if audio_np.ndim == 2:
|
| 347 |
+
audio_np = audio_np.mean(axis=1)
|
| 348 |
+
|
| 349 |
+
update = state.process_audio_chunk(sample_rate, audio_np)
|
| 350 |
+
return (state, *_unpack(update))
|
| 351 |
+
|
| 352 |
+
audio_input.stream(
|
| 353 |
+
fn = on_audio_stream,
|
| 354 |
+
inputs = [audio_input, session_state],
|
| 355 |
+
outputs = [session_state] + ALL_OUTPUTS,
|
| 356 |
+
stream_every = 0.5, # seconds β half-second chunks
|
| 357 |
+
time_limit = 3600, # allow up to 1-hour calls
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
return demo
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
# ββ Entry point βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 364 |
+
|
| 365 |
+
if __name__ == "__main__":
|
| 366 |
+
app = build_app()
|
| 367 |
+
app.launch(
|
| 368 |
+
server_name = SERVER_NAME,
|
| 369 |
+
server_port = SERVER_PORT,
|
| 370 |
+
show_error = True,
|
| 371 |
+
)
|
config.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Central configuration for the telecalling agent.
|
| 3 |
+
Edit the values in this file to match your local setup.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
# ββ Project root ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 10 |
+
ROOT_DIR = Path(__file__).parent
|
| 11 |
+
DATA_DIR = ROOT_DIR / "data"
|
| 12 |
+
MODELS_DIR = ROOT_DIR / "models"
|
| 13 |
+
|
| 14 |
+
DATA_DIR.mkdir(exist_ok=True)
|
| 15 |
+
MODELS_DIR.mkdir(exist_ok=True)
|
| 16 |
+
|
| 17 |
+
# ββ Hugging Face ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 18 |
+
# Required: Cohere Transcribe is gated β you must accept the license at
|
| 19 |
+
# https://huggingface.co/CohereLabs/cohere-transcribe-03-2026
|
| 20 |
+
# then set your HF token here or in the environment variable HF_TOKEN.
|
| 21 |
+
HF_TOKEN = os.environ.get("HF_TOKEN", "")
|
| 22 |
+
|
| 23 |
+
# ββ ASR: Cohere Transcribe ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 24 |
+
TRANSCRIBE_MODEL_ID = "CohereLabs/cohere-transcribe-03-2026"
|
| 25 |
+
TRANSCRIBE_LANGUAGE = "en" # ISO 639-1 β English only for now
|
| 26 |
+
TRANSCRIBE_DEVICE = "cuda:0" # falls back to "cpu" automatically in code
|
| 27 |
+
|
| 28 |
+
# ββ Intent Parser: Qwen2.5-7B-Instruct (GGUF via llama-cpp-python) ββββββββββββ
|
| 29 |
+
# Download command (run once):
|
| 30 |
+
# huggingface-cli download Qwen/Qwen2.5-7B-Instruct-GGUF \
|
| 31 |
+
# --include "qwen2.5-7b-instruct-q4_k_m.gguf" \
|
| 32 |
+
# --local-dir ./models
|
| 33 |
+
QWEN_GGUF_PATH = MODELS_DIR / "qwen2.5-7b-instruct-q4_k_m.gguf"
|
| 34 |
+
QWEN_N_GPU_LAYERS = 20 # offload 20 transformer layers to GPU (~0.8 GB VRAM)
|
| 35 |
+
# remaining ~15 layers run on CPU RAM
|
| 36 |
+
QWEN_N_CTX = 4096 # context window β sufficient for a call transcript
|
| 37 |
+
QWEN_MAX_TOKENS = 512 # max tokens for the structured JSON response
|
| 38 |
+
QWEN_TEMPERATURE = 0.1 # near-deterministic for structured output
|
| 39 |
+
|
| 40 |
+
# ββ Evaluator: MiniCPM3-4B (CPU, bitsandbytes 4-bit) βββββββββββββββββββββββββ
|
| 41 |
+
MINICPM_MODEL_ID = "openbmb/MiniCPM3-4B"
|
| 42 |
+
MINICPM_DEVICE = "cpu" # runs after Qwen is done β no VRAM conflict
|
| 43 |
+
MINICPM_MAX_TOKENS = 256
|
| 44 |
+
|
| 45 |
+
# ββ VAD: Silero VAD (ONNX, CPU) βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 46 |
+
VAD_SAMPLE_RATE = 16000 # Hz β Silero and Cohere both want 16kHz
|
| 47 |
+
VAD_CHUNK_MS = 250 # ms per audio chunk fed to VAD
|
| 48 |
+
VAD_CHUNK_SAMPLES = int(VAD_SAMPLE_RATE * VAD_CHUNK_MS / 1000) # 4000
|
| 49 |
+
VAD_SILENCE_THRESHOLD = 0.5 # Silero confidence below this β silence
|
| 50 |
+
VAD_SILENCE_DURATION_S = 0.8 # seconds of silence before utterance is flushed
|
| 51 |
+
VAD_MIN_SPEECH_S = 0.5 # ignore utterances shorter than this (noise)
|
| 52 |
+
|
| 53 |
+
# ββ SQLite database βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 54 |
+
DB_PATH = DATA_DIR / "calls.db"
|
| 55 |
+
|
| 56 |
+
# ββ Scheduling rules (evaluated by MiniCPM) ββββββββββββββββββββββββββββββββββ
|
| 57 |
+
# These are injected into MiniCPM's system prompt so it can reason about them.
|
| 58 |
+
SCHEDULING_RULES = """
|
| 59 |
+
1. Meetings can only be booked MondayβFriday, 09:00β18:00.
|
| 60 |
+
2. Minimum meeting duration is 15 minutes; maximum is 120 minutes.
|
| 61 |
+
3. Back-to-back meetings are not allowed β require a 15-minute gap between slots.
|
| 62 |
+
4. If the caller does not provide a date or time, ask for one before confirming.
|
| 63 |
+
5. If the requested slot is already booked, suggest the next available slot.
|
| 64 |
+
6. Always confirm the caller's name before booking.
|
| 65 |
+
"""
|
| 66 |
+
|
| 67 |
+
# ββ Gradio UI βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 68 |
+
APP_TITLE = "π AI Telecalling Agent"
|
| 69 |
+
APP_DESCRIPTION = "Speak naturally β the agent will schedule your meeting automatically."
|
| 70 |
+
SERVER_PORT = 7860
|
| 71 |
+
SERVER_NAME = "0.0.0.0" # bind to all interfaces for HF Spaces
|
data/calls.db
ADDED
|
Binary file (24.6 kB). View file
|
|
|
db.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database layer β SQLite via Python's built-in sqlite3.
|
| 3 |
+
Handles schema creation and all CRUD operations for the telecalling agent.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import sqlite3
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from contextlib import contextmanager
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
from config import DB_PATH
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ββ Schema ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 19 |
+
|
| 20 |
+
SCHEMA = """
|
| 21 |
+
CREATE TABLE IF NOT EXISTS calls (
|
| 22 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 23 |
+
timestamp TEXT NOT NULL, -- ISO-8601 UTC
|
| 24 |
+
session_id TEXT NOT NULL, -- UUID per Gradio session
|
| 25 |
+
raw_transcript TEXT NOT NULL DEFAULT '',
|
| 26 |
+
intent TEXT, -- book_meeting | reschedule | cancel | ...
|
| 27 |
+
caller_name TEXT,
|
| 28 |
+
preferred_date TEXT, -- YYYY-MM-DD
|
| 29 |
+
preferred_time TEXT, -- HH:MM (24h)
|
| 30 |
+
duration_mins INTEGER,
|
| 31 |
+
participants TEXT DEFAULT '[]', -- JSON array of strings
|
| 32 |
+
meeting_type TEXT, -- phone | video | in_person
|
| 33 |
+
notes TEXT,
|
| 34 |
+
confidence REAL, -- 0.0β1.0 from Qwen
|
| 35 |
+
decision TEXT, -- schedule | ask_followup | reject
|
| 36 |
+
reasoning TEXT, -- MiniCPM's explanation
|
| 37 |
+
status TEXT NOT NULL DEFAULT 'open' -- open | confirmed | cancelled
|
| 38 |
+
);
|
| 39 |
+
|
| 40 |
+
CREATE TABLE IF NOT EXISTS bookings (
|
| 41 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 42 |
+
call_id INTEGER NOT NULL REFERENCES calls(id),
|
| 43 |
+
booked_date TEXT NOT NULL, -- YYYY-MM-DD
|
| 44 |
+
booked_time TEXT NOT NULL, -- HH:MM
|
| 45 |
+
duration_mins INTEGER NOT NULL,
|
| 46 |
+
caller_name TEXT NOT NULL,
|
| 47 |
+
meeting_type TEXT,
|
| 48 |
+
created_at TEXT NOT NULL
|
| 49 |
+
);
|
| 50 |
+
|
| 51 |
+
CREATE INDEX IF NOT EXISTS idx_calls_session ON calls(session_id);
|
| 52 |
+
CREATE INDEX IF NOT EXISTS idx_bookings_date ON bookings(booked_date);
|
| 53 |
+
"""
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ββ Connection helper βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 57 |
+
|
| 58 |
+
@contextmanager
|
| 59 |
+
def get_conn():
|
| 60 |
+
"""Context manager β always commits or rolls back cleanly."""
|
| 61 |
+
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
| 62 |
+
conn.row_factory = sqlite3.Row
|
| 63 |
+
conn.execute("PRAGMA journal_mode=WAL") # safe for concurrent Gradio threads
|
| 64 |
+
try:
|
| 65 |
+
yield conn
|
| 66 |
+
conn.commit()
|
| 67 |
+
except Exception:
|
| 68 |
+
conn.rollback()
|
| 69 |
+
raise
|
| 70 |
+
finally:
|
| 71 |
+
conn.close()
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def init_db():
|
| 75 |
+
"""Create tables if they don't exist. Safe to call on every startup."""
|
| 76 |
+
with get_conn() as conn:
|
| 77 |
+
conn.executescript(SCHEMA)
|
| 78 |
+
logger.info(f"Database ready at {DB_PATH}")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ββ Call record helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 82 |
+
|
| 83 |
+
def create_call(session_id: str) -> int:
|
| 84 |
+
"""
|
| 85 |
+
Insert a bare call record at the start of a session.
|
| 86 |
+
Returns the new call id so downstream steps can update it.
|
| 87 |
+
"""
|
| 88 |
+
with get_conn() as conn:
|
| 89 |
+
cur = conn.execute(
|
| 90 |
+
"INSERT INTO calls (timestamp, session_id, raw_transcript) VALUES (?, ?, '')",
|
| 91 |
+
(datetime.utcnow().isoformat(), session_id)
|
| 92 |
+
)
|
| 93 |
+
return cur.lastrowid
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def append_transcript(call_id: int, new_text: str):
|
| 97 |
+
"""Append a transcribed utterance to the running transcript."""
|
| 98 |
+
with get_conn() as conn:
|
| 99 |
+
conn.execute(
|
| 100 |
+
"""UPDATE calls
|
| 101 |
+
SET raw_transcript = raw_transcript || '\n' || ?
|
| 102 |
+
WHERE id = ?""",
|
| 103 |
+
(new_text.strip(), call_id)
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def update_call_intent(call_id: int, parsed: dict):
|
| 108 |
+
"""
|
| 109 |
+
Write Qwen's structured JSON output into the call record.
|
| 110 |
+
`parsed` is expected to match the scheduling JSON schema from config.
|
| 111 |
+
"""
|
| 112 |
+
with get_conn() as conn:
|
| 113 |
+
conn.execute(
|
| 114 |
+
"""UPDATE calls SET
|
| 115 |
+
intent = :intent,
|
| 116 |
+
caller_name = :caller_name,
|
| 117 |
+
preferred_date = :preferred_date,
|
| 118 |
+
preferred_time = :preferred_time,
|
| 119 |
+
duration_mins = :duration_minutes,
|
| 120 |
+
participants = :participants,
|
| 121 |
+
meeting_type = :meeting_type,
|
| 122 |
+
notes = :notes,
|
| 123 |
+
confidence = :confidence
|
| 124 |
+
WHERE id = :id""",
|
| 125 |
+
{
|
| 126 |
+
"intent": parsed.get("intent"),
|
| 127 |
+
"caller_name": parsed.get("caller_name"),
|
| 128 |
+
"preferred_date": parsed.get("preferred_date"),
|
| 129 |
+
"preferred_time": parsed.get("preferred_time"),
|
| 130 |
+
"duration_minutes": parsed.get("duration_minutes"),
|
| 131 |
+
"participants": json.dumps(parsed.get("participants", [])),
|
| 132 |
+
"meeting_type": parsed.get("meeting_type"),
|
| 133 |
+
"notes": parsed.get("notes"),
|
| 134 |
+
"confidence": parsed.get("confidence"),
|
| 135 |
+
"id": call_id,
|
| 136 |
+
}
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def update_call_decision(call_id: int, decision: str, reasoning: str):
|
| 141 |
+
"""Write MiniCPM's evaluation result back to the call record."""
|
| 142 |
+
with get_conn() as conn:
|
| 143 |
+
conn.execute(
|
| 144 |
+
"UPDATE calls SET decision = ?, reasoning = ? WHERE id = ?",
|
| 145 |
+
(decision, reasoning, call_id)
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def confirm_booking(call_id: int, parsed: dict) -> int:
|
| 150 |
+
"""
|
| 151 |
+
Insert a confirmed booking row and mark the call as confirmed.
|
| 152 |
+
Returns the booking id.
|
| 153 |
+
"""
|
| 154 |
+
with get_conn() as conn:
|
| 155 |
+
cur = conn.execute(
|
| 156 |
+
"""INSERT INTO bookings
|
| 157 |
+
(call_id, booked_date, booked_time, duration_mins,
|
| 158 |
+
caller_name, meeting_type, created_at)
|
| 159 |
+
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
| 160 |
+
(
|
| 161 |
+
call_id,
|
| 162 |
+
parsed["preferred_date"],
|
| 163 |
+
parsed["preferred_time"],
|
| 164 |
+
parsed.get("duration_minutes", 30),
|
| 165 |
+
parsed.get("caller_name", "Unknown"),
|
| 166 |
+
parsed.get("meeting_type", "phone"),
|
| 167 |
+
datetime.utcnow().isoformat(),
|
| 168 |
+
)
|
| 169 |
+
)
|
| 170 |
+
conn.execute(
|
| 171 |
+
"UPDATE calls SET status = 'confirmed' WHERE id = ?", (call_id,)
|
| 172 |
+
)
|
| 173 |
+
return cur.lastrowid
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ββ Availability check ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 177 |
+
|
| 178 |
+
def is_slot_available(date: str, time: str, duration_mins: int = 30) -> bool:
|
| 179 |
+
"""
|
| 180 |
+
Returns True if no existing booking overlaps with the requested slot
|
| 181 |
+
(including the mandatory 15-minute buffer).
|
| 182 |
+
date: YYYY-MM-DD, time: HH:MM
|
| 183 |
+
"""
|
| 184 |
+
from datetime import datetime, timedelta
|
| 185 |
+
|
| 186 |
+
try:
|
| 187 |
+
start = datetime.strptime(f"{date} {time}", "%Y-%m-%d %H:%M")
|
| 188 |
+
end = start + timedelta(minutes=duration_mins + 15) # +15 min buffer
|
| 189 |
+
except ValueError:
|
| 190 |
+
return False
|
| 191 |
+
|
| 192 |
+
with get_conn() as conn:
|
| 193 |
+
rows = conn.execute(
|
| 194 |
+
"SELECT booked_time, duration_mins FROM bookings WHERE booked_date = ?",
|
| 195 |
+
(date,)
|
| 196 |
+
).fetchall()
|
| 197 |
+
|
| 198 |
+
for row in rows:
|
| 199 |
+
existing_start = datetime.strptime(f"{date} {row['booked_time']}", "%Y-%m-%d %H:%M")
|
| 200 |
+
existing_end = existing_start + timedelta(minutes=row["duration_mins"] + 15)
|
| 201 |
+
# overlap check
|
| 202 |
+
if start < existing_end and end > existing_start:
|
| 203 |
+
return False
|
| 204 |
+
|
| 205 |
+
return True
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def get_booked_slots(date: str) -> list[dict]:
|
| 209 |
+
"""Return all bookings for a given date for display in the UI."""
|
| 210 |
+
with get_conn() as conn:
|
| 211 |
+
rows = conn.execute(
|
| 212 |
+
"""SELECT booked_time, duration_mins, caller_name, meeting_type
|
| 213 |
+
FROM bookings WHERE booked_date = ? ORDER BY booked_time""",
|
| 214 |
+
(date,)
|
| 215 |
+
).fetchall()
|
| 216 |
+
return [dict(r) for r in rows]
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def get_recent_calls(limit: int = 20) -> list[dict]:
|
| 220 |
+
"""Fetch the most recent call records for the call log panel."""
|
| 221 |
+
with get_conn() as conn:
|
| 222 |
+
rows = conn.execute(
|
| 223 |
+
"""SELECT id, timestamp, caller_name, intent, decision, status
|
| 224 |
+
FROM calls ORDER BY id DESC LIMIT ?""",
|
| 225 |
+
(limit,)
|
| 226 |
+
).fetchall()
|
| 227 |
+
return [dict(r) for r in rows]
|
pipeline/__pycache__/evaluater.cpython-312.pyc
ADDED
|
Binary file (17.9 kB). View file
|
|
|
pipeline/__pycache__/intent_parser.cpython-312.pyc
ADDED
|
Binary file (19.9 kB). View file
|
|
|
pipeline/__pycache__/orchestrator.cpython-312.pyc
ADDED
|
Binary file (19.2 kB). View file
|
|
|
pipeline/__pycache__/transcriber.cpython-312.pyc
ADDED
|
Binary file (15.6 kB). View file
|
|
|
pipeline/__pycache__/vad_listener.cpython-312.pyc
ADDED
|
Binary file (12.2 kB). View file
|
|
|
pipeline/evaluater.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
pipeline/evaluator.py
|
| 3 |
+
|
| 4 |
+
MiniCPM3-4B scheduling evaluator.
|
| 5 |
+
|
| 6 |
+
Responsibilities
|
| 7 |
+
ββββββββββββββββ
|
| 8 |
+
Takes a validated SchedulingIntent from the intent parser and decides:
|
| 9 |
+
- "schedule" β all fields present, slot is free β confirm booking
|
| 10 |
+
- "ask_followup" β fields missing OR slot taken β ask caller a question
|
| 11 |
+
- "reject" β invalid request (weekend, out of hours, cancel confirmed)
|
| 12 |
+
- "end_call" β caller said goodbye
|
| 13 |
+
|
| 14 |
+
Also generates a natural spoken response the agent will display
|
| 15 |
+
(and optionally TTS in future) back to the caller.
|
| 16 |
+
|
| 17 |
+
Memory strategy for RTX 2050
|
| 18 |
+
βββββββββββββββββββββββββββββ
|
| 19 |
+
MiniCPM3-4B runs AFTER the transcriber finishes each utterance.
|
| 20 |
+
They never overlap. MiniCPM3 loads in 4-bit (INT4) via bitsandbytes
|
| 21 |
+
on CPU β no VRAM needed β freeing the full 4 GB for Cohere Transcribe.
|
| 22 |
+
|
| 23 |
+
If bitsandbytes is unavailable (e.g. first-time setup) we fall back to
|
| 24 |
+
plain float32 on CPU. Slower but always works.
|
| 25 |
+
|
| 26 |
+
Why MiniCPM3-4B for this role
|
| 27 |
+
βββββββββββββββββββββββββββββββ
|
| 28 |
+
- Optimised for deep reasoning with structured JSON output
|
| 29 |
+
- 4B params β ~2.5 GB RAM in INT4; ~8 GB in float32
|
| 30 |
+
- Strong at rule-following (our scheduling rules inject cleanly)
|
| 31 |
+
- Fast enough on CPU for the non-latency-critical decision step
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
import json
|
| 35 |
+
import logging
|
| 36 |
+
import threading
|
| 37 |
+
import time
|
| 38 |
+
from dataclasses import dataclass
|
| 39 |
+
from typing import Optional
|
| 40 |
+
|
| 41 |
+
import torch
|
| 42 |
+
|
| 43 |
+
from config import (
|
| 44 |
+
MINICPM_MODEL_ID,
|
| 45 |
+
MINICPM_DEVICE,
|
| 46 |
+
MINICPM_MAX_TOKENS,
|
| 47 |
+
SCHEDULING_RULES,
|
| 48 |
+
)
|
| 49 |
+
from pipeline.intent_parser import SchedulingIntent
|
| 50 |
+
|
| 51 |
+
logger = logging.getLogger(__name__)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ββ Output type βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 55 |
+
|
| 56 |
+
@dataclass
|
| 57 |
+
class EvaluationResult:
|
| 58 |
+
decision: str # schedule | ask_followup | reject | end_call
|
| 59 |
+
spoken_response: str # what the agent says back to the caller
|
| 60 |
+
reasoning: str # internal chain-of-thought (for DB + debug)
|
| 61 |
+
suggested_date: Optional[str] # YYYY-MM-DD if rescheduling to next free slot
|
| 62 |
+
suggested_time: Optional[str] # HH:MM if rescheduling
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ββ Prompts βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 66 |
+
|
| 67 |
+
_SYSTEM_PROMPT = """You are an AI scheduling assistant on a live phone call.
|
| 68 |
+
|
| 69 |
+
SCHEDULING RULES:
|
| 70 |
+
{rules}
|
| 71 |
+
|
| 72 |
+
Your job: given the caller's current intent data and the conversation so far,
|
| 73 |
+
decide what to do next and generate a SHORT, natural spoken response.
|
| 74 |
+
|
| 75 |
+
Respond ONLY with a JSON object β no prose outside the JSON:
|
| 76 |
+
{{
|
| 77 |
+
"decision": "schedule" | "ask_followup" | "reject" | "end_call",
|
| 78 |
+
"spoken_response": "what you say to the caller (1-2 sentences, conversational)",
|
| 79 |
+
"reasoning": "brief internal note explaining your decision",
|
| 80 |
+
"suggested_date": "YYYY-MM-DD or null",
|
| 81 |
+
"suggested_time": "HH:MM or null"
|
| 82 |
+
}}
|
| 83 |
+
|
| 84 |
+
Guidelines:
|
| 85 |
+
- spoken_response must sound natural on a phone call. No bullet points.
|
| 86 |
+
- If asking a follow-up, ask for ONE missing field at a time.
|
| 87 |
+
- If the slot is taken, suggest the next available slot from slot_conflicts.
|
| 88 |
+
- If intent is "end_call" or caller says goodbye, set decision to "end_call".
|
| 89 |
+
- Keep spoken_response under 30 words.
|
| 90 |
+
"""
|
| 91 |
+
|
| 92 |
+
_USER_PROMPT = """Intent data:
|
| 93 |
+
{intent_json}
|
| 94 |
+
|
| 95 |
+
Slot available: {slot_available}
|
| 96 |
+
Slot conflicts (booked slots on that date): {slot_conflicts}
|
| 97 |
+
Conversation history (last 6 utterances):
|
| 98 |
+
{history}
|
| 99 |
+
|
| 100 |
+
JSON decision:"""
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ββ Evaluator βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 104 |
+
|
| 105 |
+
class Evaluator:
|
| 106 |
+
"""
|
| 107 |
+
Lazy-loading MiniCPM3-4B evaluator. CPU-only, INT4 quantised.
|
| 108 |
+
"""
|
| 109 |
+
|
| 110 |
+
def __init__(self):
|
| 111 |
+
self._model = None
|
| 112 |
+
self._tokenizer = None
|
| 113 |
+
self._lock = threading.Lock()
|
| 114 |
+
self._loaded = False
|
| 115 |
+
|
| 116 |
+
# ββ Public ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 117 |
+
|
| 118 |
+
def evaluate(
|
| 119 |
+
self,
|
| 120 |
+
intent: SchedulingIntent,
|
| 121 |
+
utterances: list[str],
|
| 122 |
+
slot_available: bool = True,
|
| 123 |
+
slot_conflicts: list[dict] = None,
|
| 124 |
+
) -> EvaluationResult:
|
| 125 |
+
"""
|
| 126 |
+
Evaluate a scheduling intent and generate a spoken response.
|
| 127 |
+
|
| 128 |
+
Parameters
|
| 129 |
+
----------
|
| 130 |
+
intent : validated SchedulingIntent from intent_parser
|
| 131 |
+
utterances : full list of transcript strings so far
|
| 132 |
+
slot_available : result of db.is_slot_available()
|
| 133 |
+
slot_conflicts : result of db.get_booked_slots() for that date
|
| 134 |
+
|
| 135 |
+
Returns
|
| 136 |
+
-------
|
| 137 |
+
EvaluationResult β never raises, falls back gracefully on error
|
| 138 |
+
"""
|
| 139 |
+
# Fast-path: handle terminal intents without model inference
|
| 140 |
+
fast = self._fast_path(intent)
|
| 141 |
+
if fast:
|
| 142 |
+
return fast
|
| 143 |
+
|
| 144 |
+
self._ensure_loaded()
|
| 145 |
+
|
| 146 |
+
prompt = self._build_prompt(intent, utterances, slot_available, slot_conflicts or [])
|
| 147 |
+
|
| 148 |
+
try:
|
| 149 |
+
t0 = time.perf_counter()
|
| 150 |
+
raw = self._generate(prompt)
|
| 151 |
+
elapsed = time.perf_counter() - t0
|
| 152 |
+
logger.info(f"MiniCPM3 inference in {elapsed:.2f}s β raw: {raw[:120]}")
|
| 153 |
+
return self._parse_response(raw, intent)
|
| 154 |
+
|
| 155 |
+
except Exception as exc:
|
| 156 |
+
logger.error(f"Evaluator.evaluate failed: {exc}", exc_info=True)
|
| 157 |
+
return self._fallback_result(intent)
|
| 158 |
+
|
| 159 |
+
def unload(self):
|
| 160 |
+
with self._lock:
|
| 161 |
+
if self._loaded:
|
| 162 |
+
del self._model, self._tokenizer
|
| 163 |
+
self._model = None
|
| 164 |
+
self._tokenizer = None
|
| 165 |
+
self._loaded = False
|
| 166 |
+
logger.info("Evaluator unloaded.")
|
| 167 |
+
|
| 168 |
+
@property
|
| 169 |
+
def is_loaded(self) -> bool:
|
| 170 |
+
return self._loaded
|
| 171 |
+
|
| 172 |
+
# ββ Internal ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 173 |
+
|
| 174 |
+
def _fast_path(self, intent: SchedulingIntent) -> Optional[EvaluationResult]:
|
| 175 |
+
"""
|
| 176 |
+
Handle clear-cut cases without burning inference time.
|
| 177 |
+
"""
|
| 178 |
+
if intent.intent == "end_call":
|
| 179 |
+
return EvaluationResult(
|
| 180 |
+
decision = "end_call",
|
| 181 |
+
spoken_response = "Thank you for calling. Goodbye!",
|
| 182 |
+
reasoning = "Caller indicated end of call.",
|
| 183 |
+
suggested_date = None,
|
| 184 |
+
suggested_time = None,
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
if intent.intent == "unclear" or intent.confidence < 0.15:
|
| 188 |
+
return EvaluationResult(
|
| 189 |
+
decision = "ask_followup",
|
| 190 |
+
spoken_response = (
|
| 191 |
+
"I'm sorry, I didn't quite catch that. "
|
| 192 |
+
"Could you tell me what you'd like to schedule?"
|
| 193 |
+
),
|
| 194 |
+
reasoning = f"Intent unclear or confidence too low ({intent.confidence:.2f}).",
|
| 195 |
+
suggested_date = None,
|
| 196 |
+
suggested_time = None,
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
return None # fall through to model inference
|
| 200 |
+
|
| 201 |
+
def _ensure_loaded(self):
|
| 202 |
+
if self._loaded:
|
| 203 |
+
return
|
| 204 |
+
with self._lock:
|
| 205 |
+
if self._loaded:
|
| 206 |
+
return
|
| 207 |
+
self._load()
|
| 208 |
+
|
| 209 |
+
def _load(self):
|
| 210 |
+
from transformers import AutoTokenizer, AutoModelForCausalLM
|
| 211 |
+
|
| 212 |
+
logger.info(f"Loading MiniCPM3-4B ({MINICPM_MODEL_ID}) on CPUβ¦")
|
| 213 |
+
t0 = time.perf_counter()
|
| 214 |
+
|
| 215 |
+
self._tokenizer = AutoTokenizer.from_pretrained(
|
| 216 |
+
MINICPM_MODEL_ID,
|
| 217 |
+
trust_remote_code=True,
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
# Try INT4 via bitsandbytes first; fall back to float32
|
| 221 |
+
try:
|
| 222 |
+
from transformers import BitsAndBytesConfig
|
| 223 |
+
bnb_config = BitsAndBytesConfig(
|
| 224 |
+
load_in_4bit = True,
|
| 225 |
+
bnb_4bit_compute_dtype = torch.float32,
|
| 226 |
+
bnb_4bit_use_double_quant = True,
|
| 227 |
+
bnb_4bit_quant_type = "nf4",
|
| 228 |
+
)
|
| 229 |
+
self._model = AutoModelForCausalLM.from_pretrained(
|
| 230 |
+
MINICPM_MODEL_ID,
|
| 231 |
+
quantization_config = bnb_config,
|
| 232 |
+
trust_remote_code = True,
|
| 233 |
+
device_map = "cpu",
|
| 234 |
+
low_cpu_mem_usage = True,
|
| 235 |
+
)
|
| 236 |
+
logger.info("MiniCPM3 loaded in INT4 (bitsandbytes NF4)")
|
| 237 |
+
|
| 238 |
+
except (ImportError, Exception) as e:
|
| 239 |
+
logger.warning(f"bitsandbytes INT4 failed ({e}) β loading float32 on CPU")
|
| 240 |
+
self._model = AutoModelForCausalLM.from_pretrained(
|
| 241 |
+
MINICPM_MODEL_ID,
|
| 242 |
+
torch_dtype = torch.float32,
|
| 243 |
+
trust_remote_code = True,
|
| 244 |
+
low_cpu_mem_usage = True,
|
| 245 |
+
)
|
| 246 |
+
self._model.to("cpu")
|
| 247 |
+
|
| 248 |
+
self._model.eval()
|
| 249 |
+
|
| 250 |
+
elapsed = time.perf_counter() - t0
|
| 251 |
+
logger.info(f"MiniCPM3 ready in {elapsed:.1f}s")
|
| 252 |
+
self._loaded = True
|
| 253 |
+
|
| 254 |
+
def _build_prompt(
|
| 255 |
+
self,
|
| 256 |
+
intent: SchedulingIntent,
|
| 257 |
+
utterances: list[str],
|
| 258 |
+
slot_available: bool,
|
| 259 |
+
slot_conflicts: list[dict],
|
| 260 |
+
) -> str:
|
| 261 |
+
"""
|
| 262 |
+
MiniCPM3 uses ChatML format identical to Qwen:
|
| 263 |
+
<|im_start|>system β¦ <|im_end|>
|
| 264 |
+
<|im_start|>user β¦ <|im_end|>
|
| 265 |
+
<|im_start|>assistant
|
| 266 |
+
"""
|
| 267 |
+
# Serialise intent β exclude heavy fields for prompt brevity
|
| 268 |
+
intent_dict = intent.model_dump(
|
| 269 |
+
exclude={"participants", "notes", "missing_fields"}
|
| 270 |
+
)
|
| 271 |
+
intent_dict["missing_fields"] = intent.missing_fields # keep for context
|
| 272 |
+
|
| 273 |
+
history = "\n".join(
|
| 274 |
+
f" [{i+1}] {u}" for i, u in enumerate(utterances[-6:])
|
| 275 |
+
) or " (none yet)"
|
| 276 |
+
|
| 277 |
+
conflicts_str = (
|
| 278 |
+
json.dumps(slot_conflicts, indent=2)
|
| 279 |
+
if slot_conflicts
|
| 280 |
+
else "none"
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
system = _SYSTEM_PROMPT.format(rules=SCHEDULING_RULES.strip())
|
| 284 |
+
user = _USER_PROMPT.format(
|
| 285 |
+
intent_json = json.dumps(intent_dict, indent=2),
|
| 286 |
+
slot_available = str(slot_available),
|
| 287 |
+
slot_conflicts = conflicts_str,
|
| 288 |
+
history = history,
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
return (
|
| 292 |
+
f"<|im_start|>system\n{system}<|im_end|>\n"
|
| 293 |
+
f"<|im_start|>user\n{user}<|im_end|>\n"
|
| 294 |
+
f"<|im_start|>assistant\n"
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
def _generate(self, prompt: str) -> str:
|
| 298 |
+
inputs = self._tokenizer(
|
| 299 |
+
prompt,
|
| 300 |
+
return_tensors = "pt",
|
| 301 |
+
truncation = True,
|
| 302 |
+
max_length = 3072,
|
| 303 |
+
).to("cpu")
|
| 304 |
+
|
| 305 |
+
with torch.no_grad():
|
| 306 |
+
output_ids = self._model.generate(
|
| 307 |
+
**inputs,
|
| 308 |
+
max_new_tokens = MINICPM_MAX_TOKENS,
|
| 309 |
+
temperature = 0.2,
|
| 310 |
+
do_sample = True,
|
| 311 |
+
top_p = 0.9,
|
| 312 |
+
repetition_penalty = 1.1,
|
| 313 |
+
pad_token_id = self._tokenizer.eos_token_id,
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
# Decode only the new tokens (skip the prompt)
|
| 317 |
+
new_ids = output_ids[0][inputs["input_ids"].shape[1]:]
|
| 318 |
+
return self._tokenizer.decode(new_ids, skip_special_tokens=True).strip()
|
| 319 |
+
|
| 320 |
+
def _parse_response(
|
| 321 |
+
self, raw: str, intent: SchedulingIntent
|
| 322 |
+
) -> EvaluationResult:
|
| 323 |
+
raw = raw.strip().lstrip("```json").lstrip("```").rstrip("```").strip()
|
| 324 |
+
|
| 325 |
+
# Extract JSON block if model added preamble despite instructions
|
| 326 |
+
if "{" in raw:
|
| 327 |
+
raw = raw[raw.index("{") : raw.rindex("}") + 1]
|
| 328 |
+
|
| 329 |
+
try:
|
| 330 |
+
data = json.loads(raw)
|
| 331 |
+
decision = data.get("decision", "ask_followup")
|
| 332 |
+
|
| 333 |
+
# Sanity-check decision against intent state
|
| 334 |
+
if decision == "schedule" and intent.missing_fields:
|
| 335 |
+
logger.warning(
|
| 336 |
+
"Model said 'schedule' but fields are missing "
|
| 337 |
+
f"({intent.missing_fields}) β overriding to ask_followup"
|
| 338 |
+
)
|
| 339 |
+
decision = "ask_followup"
|
| 340 |
+
|
| 341 |
+
return EvaluationResult(
|
| 342 |
+
decision = decision,
|
| 343 |
+
spoken_response = data.get("spoken_response", self._default_response(intent)),
|
| 344 |
+
reasoning = data.get("reasoning", ""),
|
| 345 |
+
suggested_date = data.get("suggested_date"),
|
| 346 |
+
suggested_time = data.get("suggested_time"),
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
except Exception as exc:
|
| 350 |
+
logger.warning(f"Evaluator JSON parse failed: {exc} β raw: {raw[:200]}")
|
| 351 |
+
return self._fallback_result(intent)
|
| 352 |
+
|
| 353 |
+
@staticmethod
|
| 354 |
+
def _default_response(intent: SchedulingIntent) -> str:
|
| 355 |
+
if intent.missing_fields:
|
| 356 |
+
field_map = {
|
| 357 |
+
"caller_name": "Could I get your name please?",
|
| 358 |
+
"preferred_date": "What date would you like to schedule this for?",
|
| 359 |
+
"preferred_time": "And what time works best for you?",
|
| 360 |
+
}
|
| 361 |
+
return field_map.get(
|
| 362 |
+
intent.missing_fields[0],
|
| 363 |
+
"Could you give me a bit more detail?"
|
| 364 |
+
)
|
| 365 |
+
return "Let me check availability for you."
|
| 366 |
+
|
| 367 |
+
@staticmethod
|
| 368 |
+
def _fallback_result(intent: SchedulingIntent) -> EvaluationResult:
|
| 369 |
+
return EvaluationResult(
|
| 370 |
+
decision = "ask_followup",
|
| 371 |
+
spoken_response = "I'm sorry, could you repeat that?",
|
| 372 |
+
reasoning = "Evaluator fallback β parse error.",
|
| 373 |
+
suggested_date = None,
|
| 374 |
+
suggested_time = None,
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
# ββ Module singleton ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 379 |
+
|
| 380 |
+
_evaluator: Optional[Evaluator] = None
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def get_evaluator() -> Evaluator:
|
| 384 |
+
global _evaluator
|
| 385 |
+
if _evaluator is None:
|
| 386 |
+
_evaluator = Evaluator()
|
| 387 |
+
return _evaluator
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
# ββ Offline smoke test ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 391 |
+
|
| 392 |
+
def _smoke_test_offline():
|
| 393 |
+
logging.basicConfig(level=logging.INFO)
|
| 394 |
+
logger.info("Running Evaluator offline smoke testβ¦")
|
| 395 |
+
|
| 396 |
+
from pipeline.intent_parser import SchedulingIntent
|
| 397 |
+
|
| 398 |
+
# 1. end_call fast-path
|
| 399 |
+
intent_end = SchedulingIntent(intent="end_call", confidence=0.9)
|
| 400 |
+
ev = Evaluator()
|
| 401 |
+
result = ev._fast_path(intent_end)
|
| 402 |
+
assert result is not None
|
| 403 |
+
assert result.decision == "end_call"
|
| 404 |
+
logger.info(" β end_call fast-path")
|
| 405 |
+
|
| 406 |
+
# 2. unclear fast-path
|
| 407 |
+
intent_unclear = SchedulingIntent(intent="unclear", confidence=0.05)
|
| 408 |
+
result = ev._fast_path(intent_unclear)
|
| 409 |
+
assert result is not None and result.decision == "ask_followup"
|
| 410 |
+
logger.info(" β unclear fast-path")
|
| 411 |
+
|
| 412 |
+
# 3. Normal intent β no fast-path
|
| 413 |
+
intent_book = SchedulingIntent(
|
| 414 |
+
intent="book_meeting", caller_name="Priya",
|
| 415 |
+
preferred_date="2026-06-10", preferred_time="14:00",
|
| 416 |
+
confidence=0.9,
|
| 417 |
+
).compute_missing()
|
| 418 |
+
result = ev._fast_path(intent_book)
|
| 419 |
+
assert result is None # should fall through to model
|
| 420 |
+
logger.info(" β valid booking intent passes through to model")
|
| 421 |
+
|
| 422 |
+
# 4. Safety override β model says 'schedule' but fields missing
|
| 423 |
+
intent_partial = SchedulingIntent(
|
| 424 |
+
intent="book_meeting", caller_name="Raj", confidence=0.5
|
| 425 |
+
).compute_missing()
|
| 426 |
+
raw_json = json.dumps({
|
| 427 |
+
"decision": "schedule", # model hallucinated this
|
| 428 |
+
"spoken_response": "Booked!",
|
| 429 |
+
"reasoning": "test",
|
| 430 |
+
"suggested_date": None,
|
| 431 |
+
"suggested_time": None,
|
| 432 |
+
})
|
| 433 |
+
result = ev._parse_response(raw_json, intent_partial)
|
| 434 |
+
assert result.decision == "ask_followup", (
|
| 435 |
+
f"Safety override failed β got {result.decision}"
|
| 436 |
+
)
|
| 437 |
+
logger.info(" β safety override: 'schedule' with missing fields β ask_followup")
|
| 438 |
+
|
| 439 |
+
# 5. Prompt build sanity
|
| 440 |
+
prompt = ev._build_prompt(intent_book, ["Hi I want to book a call"], True, [])
|
| 441 |
+
assert "<|im_start|>system" in prompt
|
| 442 |
+
assert "SCHEDULING RULES" in prompt
|
| 443 |
+
assert "slot_available" in prompt or "Slot available" in prompt
|
| 444 |
+
logger.info(" β prompt structure correct")
|
| 445 |
+
|
| 446 |
+
# 6. Singleton
|
| 447 |
+
e1 = get_evaluator()
|
| 448 |
+
e2 = get_evaluator()
|
| 449 |
+
assert e1 is e2
|
| 450 |
+
logger.info(" β module singleton")
|
| 451 |
+
|
| 452 |
+
logger.info("\nOffline smoke test PASSED β")
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
if __name__ == "__main__":
|
| 456 |
+
_smoke_test_offline()
|
pipeline/intent_parser.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
pipeline/intent_parser.py
|
| 3 |
+
|
| 4 |
+
Qwen2.5-7B-Instruct (Q4_K_M GGUF) intent & entity extractor.
|
| 5 |
+
|
| 6 |
+
Responsibilities
|
| 7 |
+
ββββββββββββββββ
|
| 8 |
+
Takes a raw transcript string from Cohere Transcribe and returns a
|
| 9 |
+
validated SchedulingIntent object β structured data the evaluator and
|
| 10 |
+
DB layer can act on directly.
|
| 11 |
+
|
| 12 |
+
Why GGUF + llama-cpp-python
|
| 13 |
+
ββββββββββββββββββββββββββββ
|
| 14 |
+
- Qwen2.5-7B-Instruct in Q4_K_M needs ~4.5 GB total:
|
| 15 |
+
20 layers on RTX 2050 GPU β ~0.8 GB VRAM
|
| 16 |
+
remaining ~15 layers on CPU RAM β ~3.7 GB RAM
|
| 17 |
+
- llama-cpp-python's grammar feature forces output to be valid JSON
|
| 18 |
+
with no post-processing hacks β zero hallucinated keys.
|
| 19 |
+
|
| 20 |
+
GBNF Grammar
|
| 21 |
+
ββββββββββββ
|
| 22 |
+
llama.cpp supports GBNF (Generative BNF) grammars that constrain the
|
| 23 |
+
token sampling so the model physically cannot produce invalid JSON.
|
| 24 |
+
We define a tight grammar matching exactly our SchedulingIntent schema.
|
| 25 |
+
|
| 26 |
+
Download the GGUF before running (one-time, ~4.5 GB):
|
| 27 |
+
huggingface-cli download Qwen/Qwen2.5-7B-Instruct-GGUF \\
|
| 28 |
+
--include "qwen2.5-7b-instruct-q4_k_m.gguf" \\
|
| 29 |
+
--local-dir ./models
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
import json
|
| 33 |
+
import logging
|
| 34 |
+
import threading
|
| 35 |
+
import time
|
| 36 |
+
from typing import Optional
|
| 37 |
+
|
| 38 |
+
from pydantic import BaseModel, Field, field_validator
|
| 39 |
+
|
| 40 |
+
from config import (
|
| 41 |
+
QWEN_GGUF_PATH,
|
| 42 |
+
QWEN_N_GPU_LAYERS,
|
| 43 |
+
QWEN_N_CTX,
|
| 44 |
+
QWEN_MAX_TOKENS,
|
| 45 |
+
QWEN_TEMPERATURE,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
logger = logging.getLogger(__name__)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ββ Output schema βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
+
|
| 53 |
+
class SchedulingIntent(BaseModel):
|
| 54 |
+
"""
|
| 55 |
+
Structured output from Qwen2.5. Every field is Optional so partial
|
| 56 |
+
information (e.g. caller gave name but no date yet) is represented
|
| 57 |
+
cleanly rather than hallucinated.
|
| 58 |
+
"""
|
| 59 |
+
intent: str = Field(
|
| 60 |
+
description=(
|
| 61 |
+
"One of: book_meeting | reschedule | cancel | "
|
| 62 |
+
"check_availability | provide_info | end_call | unclear"
|
| 63 |
+
)
|
| 64 |
+
)
|
| 65 |
+
caller_name: Optional[str] = Field(None, description="Full name of the caller")
|
| 66 |
+
preferred_date: Optional[str] = Field(None, description="YYYY-MM-DD or null")
|
| 67 |
+
preferred_time: Optional[str] = Field(None, description="HH:MM 24-hour or null")
|
| 68 |
+
duration_minutes: Optional[int] = Field(None, description="Meeting length in minutes")
|
| 69 |
+
participants: list[str] = Field(default_factory=list)
|
| 70 |
+
meeting_type: Optional[str] = Field(None, description="phone | video | in_person")
|
| 71 |
+
notes: Optional[str] = Field(None, description="Any extra context")
|
| 72 |
+
confidence: float = Field(0.0, description="0.0β1.0 extraction confidence")
|
| 73 |
+
missing_fields: list[str] = Field(
|
| 74 |
+
default_factory=list,
|
| 75 |
+
description="Fields still needed to complete booking"
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
@field_validator("intent")
|
| 79 |
+
@classmethod
|
| 80 |
+
def validate_intent(cls, v: str) -> str:
|
| 81 |
+
valid = {
|
| 82 |
+
"book_meeting", "reschedule", "cancel",
|
| 83 |
+
"check_availability", "provide_info", "end_call", "unclear"
|
| 84 |
+
}
|
| 85 |
+
return v if v in valid else "unclear"
|
| 86 |
+
|
| 87 |
+
@field_validator("preferred_date")
|
| 88 |
+
@classmethod
|
| 89 |
+
def validate_date(cls, v: Optional[str]) -> Optional[str]:
|
| 90 |
+
if v is None:
|
| 91 |
+
return None
|
| 92 |
+
import re
|
| 93 |
+
if re.match(r"^\d{4}-\d{2}-\d{2}$", v):
|
| 94 |
+
return v
|
| 95 |
+
return None # reject malformed dates
|
| 96 |
+
|
| 97 |
+
@field_validator("preferred_time")
|
| 98 |
+
@classmethod
|
| 99 |
+
def validate_time(cls, v: Optional[str]) -> Optional[str]:
|
| 100 |
+
if v is None:
|
| 101 |
+
return None
|
| 102 |
+
import re
|
| 103 |
+
if re.match(r"^\d{2}:\d{2}$", v):
|
| 104 |
+
return v
|
| 105 |
+
return None
|
| 106 |
+
|
| 107 |
+
@field_validator("confidence")
|
| 108 |
+
@classmethod
|
| 109 |
+
def clamp_confidence(cls, v: float) -> float:
|
| 110 |
+
return max(0.0, min(1.0, float(v)))
|
| 111 |
+
|
| 112 |
+
def compute_missing(self) -> "SchedulingIntent":
|
| 113 |
+
"""
|
| 114 |
+
Populate missing_fields based on what a booking actually needs.
|
| 115 |
+
Returns self for chaining.
|
| 116 |
+
"""
|
| 117 |
+
needed = []
|
| 118 |
+
if self.intent == "book_meeting":
|
| 119 |
+
if not self.caller_name: needed.append("caller_name")
|
| 120 |
+
if not self.preferred_date: needed.append("preferred_date")
|
| 121 |
+
if not self.preferred_time: needed.append("preferred_time")
|
| 122 |
+
self.missing_fields = needed
|
| 123 |
+
return self
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ββ GBNF grammar ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 127 |
+
# Constrains Qwen to produce ONLY valid JSON matching our schema.
|
| 128 |
+
# llama.cpp samples tokens that satisfy this grammar at every step.
|
| 129 |
+
|
| 130 |
+
_SCHEDULING_GRAMMAR = r"""
|
| 131 |
+
root ::= object
|
| 132 |
+
value ::= object | array | string | number | bool | null
|
| 133 |
+
|
| 134 |
+
object ::=
|
| 135 |
+
"{" ws
|
| 136 |
+
"\"intent\"" ws ":" ws intent-val ws "," ws
|
| 137 |
+
"\"caller_name\"" ws ":" ws str-or-null ws "," ws
|
| 138 |
+
"\"preferred_date\"" ws ":" ws str-or-null ws "," ws
|
| 139 |
+
"\"preferred_time\"" ws ":" ws str-or-null ws "," ws
|
| 140 |
+
"\"duration_minutes\"" ws ":" ws int-or-null ws "," ws
|
| 141 |
+
"\"participants\"" ws ":" ws str-array ws "," ws
|
| 142 |
+
"\"meeting_type\"" ws ":" ws meeting-type-val ws "," ws
|
| 143 |
+
"\"notes\"" ws ":" ws str-or-null ws "," ws
|
| 144 |
+
"\"confidence\"" ws ":" ws confidence-val ws "," ws
|
| 145 |
+
"\"missing_fields\"" ws ":" ws str-array
|
| 146 |
+
ws "}"
|
| 147 |
+
|
| 148 |
+
intent-val ::= "\"book_meeting\"" | "\"reschedule\"" | "\"cancel\"" |
|
| 149 |
+
"\"check_availability\"" | "\"provide_info\"" |
|
| 150 |
+
"\"end_call\"" | "\"unclear\""
|
| 151 |
+
|
| 152 |
+
meeting-type-val ::= "\"phone\"" | "\"video\"" | "\"in_person\"" | "null"
|
| 153 |
+
|
| 154 |
+
str-or-null ::= string | "null"
|
| 155 |
+
int-or-null ::= integer | "null"
|
| 156 |
+
|
| 157 |
+
confidence-val ::= "0" | "1" | ("0" "." [0-9]+) | ("1" "." "0"*)
|
| 158 |
+
|
| 159 |
+
str-array ::= "[" ws "]" | "[" ws string (ws "," ws string)* ws "]"
|
| 160 |
+
|
| 161 |
+
string ::= "\"" ([^"\\] | "\\" .)* "\""
|
| 162 |
+
integer ::= "-"? [0-9]+
|
| 163 |
+
number ::= integer ("." [0-9]+)?
|
| 164 |
+
bool ::= "true" | "false"
|
| 165 |
+
null ::= "null"
|
| 166 |
+
array ::= "[" ws (value (ws "," ws value)*)? ws "]"
|
| 167 |
+
ws ::= [ \t\n\r]*
|
| 168 |
+
"""
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# ββ System prompt ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 172 |
+
|
| 173 |
+
_SYSTEM_PROMPT = """You are a scheduling assistant that extracts structured information from call transcripts.
|
| 174 |
+
|
| 175 |
+
Extract ONLY what the caller explicitly said. Never invent information.
|
| 176 |
+
Return a single JSON object with these exact fields:
|
| 177 |
+
- intent: what the caller wants (book_meeting/reschedule/cancel/check_availability/provide_info/end_call/unclear)
|
| 178 |
+
- caller_name: their name if given, else null
|
| 179 |
+
- preferred_date: YYYY-MM-DD if mentioned, else null. Resolve relative dates (e.g. "tomorrow", "next Monday") to absolute dates based on today being {today}.
|
| 180 |
+
- preferred_time: HH:MM 24-hour if mentioned, else null. Convert "3pm" β "15:00", "9:30 in the morning" β "09:30".
|
| 181 |
+
- duration_minutes: integer if mentioned, else null. Default 30 if they say "quick meeting".
|
| 182 |
+
- participants: list of names mentioned besides the caller
|
| 183 |
+
- meeting_type: "phone", "video", "in_person", or null
|
| 184 |
+
- notes: any other relevant context, else null
|
| 185 |
+
- confidence: 0.0β1.0 reflecting how complete the information is
|
| 186 |
+
- missing_fields: list of field names still needed for a complete booking
|
| 187 |
+
"""
|
| 188 |
+
|
| 189 |
+
_USER_PROMPT_TEMPLATE = """Transcript:
|
| 190 |
+
\"\"\"{transcript}\"\"\"
|
| 191 |
+
|
| 192 |
+
JSON:"""
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
# ββ Parser class ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 196 |
+
|
| 197 |
+
class IntentParser:
|
| 198 |
+
"""
|
| 199 |
+
Lazy-loading Qwen2.5-7B-Instruct GGUF intent extractor.
|
| 200 |
+
Thread-safe singleton pattern β one model instance for the whole app.
|
| 201 |
+
"""
|
| 202 |
+
|
| 203 |
+
def __init__(self):
|
| 204 |
+
self._llm = None
|
| 205 |
+
self._grammar = None
|
| 206 |
+
self._lock = threading.Lock()
|
| 207 |
+
self._loaded = False
|
| 208 |
+
|
| 209 |
+
# ββ Public ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 210 |
+
|
| 211 |
+
def parse(self, transcript: str) -> SchedulingIntent:
|
| 212 |
+
"""
|
| 213 |
+
Extract scheduling intent from a transcript string.
|
| 214 |
+
|
| 215 |
+
Parameters
|
| 216 |
+
----------
|
| 217 |
+
transcript : str
|
| 218 |
+
Raw text from Cohere Transcribe (one or more utterances joined).
|
| 219 |
+
|
| 220 |
+
Returns
|
| 221 |
+
-------
|
| 222 |
+
SchedulingIntent
|
| 223 |
+
Validated Pydantic model. Falls back to intent="unclear" on
|
| 224 |
+
any parse failure so the pipeline never crashes.
|
| 225 |
+
"""
|
| 226 |
+
if not transcript or not transcript.strip():
|
| 227 |
+
return self._fallback("Empty transcript")
|
| 228 |
+
|
| 229 |
+
self._ensure_loaded()
|
| 230 |
+
|
| 231 |
+
prompt = self._build_prompt(transcript)
|
| 232 |
+
|
| 233 |
+
try:
|
| 234 |
+
t0 = time.perf_counter()
|
| 235 |
+
|
| 236 |
+
response = self._llm(
|
| 237 |
+
prompt,
|
| 238 |
+
max_tokens = QWEN_MAX_TOKENS,
|
| 239 |
+
temperature = QWEN_TEMPERATURE,
|
| 240 |
+
stop = ["\n}\n", "```"], # belt-and-suspenders stop
|
| 241 |
+
grammar = self._grammar,
|
| 242 |
+
echo = False,
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
elapsed = time.perf_counter() - t0
|
| 246 |
+
raw_text = response["choices"][0]["text"].strip()
|
| 247 |
+
|
| 248 |
+
logger.info(f"Qwen inference in {elapsed:.2f}s β raw: {raw_text[:120]}β¦")
|
| 249 |
+
|
| 250 |
+
return self._parse_response(raw_text, transcript)
|
| 251 |
+
|
| 252 |
+
except Exception as exc:
|
| 253 |
+
logger.error(f"IntentParser.parse failed: {exc}", exc_info=True)
|
| 254 |
+
return self._fallback(str(exc))
|
| 255 |
+
|
| 256 |
+
def parse_accumulated(self, utterances: list[str]) -> SchedulingIntent:
|
| 257 |
+
"""
|
| 258 |
+
Parse the full conversation so far (list of utterance strings).
|
| 259 |
+
Joins them with newlines and runs a single inference pass.
|
| 260 |
+
Use this after each new utterance to get an updated intent state.
|
| 261 |
+
"""
|
| 262 |
+
full_transcript = "\n".join(u for u in utterances if u.strip())
|
| 263 |
+
return self.parse(full_transcript)
|
| 264 |
+
|
| 265 |
+
def unload(self):
|
| 266 |
+
"""Free memory β model reloads lazily on next call."""
|
| 267 |
+
with self._lock:
|
| 268 |
+
if self._loaded:
|
| 269 |
+
del self._llm
|
| 270 |
+
self._llm = None
|
| 271 |
+
self._loaded = False
|
| 272 |
+
logger.info("IntentParser unloaded.")
|
| 273 |
+
|
| 274 |
+
@property
|
| 275 |
+
def is_loaded(self) -> bool:
|
| 276 |
+
return self._loaded
|
| 277 |
+
|
| 278 |
+
# ββ Internal ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 279 |
+
|
| 280 |
+
def _ensure_loaded(self):
|
| 281 |
+
if self._loaded:
|
| 282 |
+
return
|
| 283 |
+
with self._lock:
|
| 284 |
+
if self._loaded:
|
| 285 |
+
return
|
| 286 |
+
self._load()
|
| 287 |
+
|
| 288 |
+
def _load(self):
|
| 289 |
+
try:
|
| 290 |
+
from llama_cpp import Llama, LlamaGrammar
|
| 291 |
+
except ImportError:
|
| 292 |
+
raise RuntimeError(
|
| 293 |
+
"llama-cpp-python is not installed.\n"
|
| 294 |
+
"Install with CUDA support:\n"
|
| 295 |
+
" Windows: set CMAKE_ARGS=-DGGML_CUDA=on && "
|
| 296 |
+
"pip install llama-cpp-python --no-cache-dir\n"
|
| 297 |
+
" Linux: CMAKE_ARGS='-DGGML_CUDA=on' "
|
| 298 |
+
"pip install llama-cpp-python --no-cache-dir"
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
if not QWEN_GGUF_PATH.exists():
|
| 302 |
+
raise FileNotFoundError(
|
| 303 |
+
f"GGUF not found at {QWEN_GGUF_PATH}\n"
|
| 304 |
+
"Download with:\n"
|
| 305 |
+
" huggingface-cli download Qwen/Qwen2.5-7B-Instruct-GGUF \\\n"
|
| 306 |
+
" --include 'qwen2.5-7b-instruct-q4_k_m.gguf' \\\n"
|
| 307 |
+
" --local-dir ./models"
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
logger.info(
|
| 311 |
+
f"Loading Qwen2.5-7B Q4_K_M β "
|
| 312 |
+
f"{QWEN_N_GPU_LAYERS} layers on GPU, rest on CPUβ¦"
|
| 313 |
+
)
|
| 314 |
+
t0 = time.perf_counter()
|
| 315 |
+
|
| 316 |
+
self._llm = Llama(
|
| 317 |
+
model_path = str(QWEN_GGUF_PATH),
|
| 318 |
+
n_gpu_layers = QWEN_N_GPU_LAYERS, # 20 β ~0.8 GB VRAM
|
| 319 |
+
n_ctx = QWEN_N_CTX, # 4096 tokens
|
| 320 |
+
n_threads = 6, # leave 2 cores for Gradio
|
| 321 |
+
n_batch = 512,
|
| 322 |
+
verbose = False,
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
self._grammar = LlamaGrammar.from_string(_SCHEDULING_GRAMMAR)
|
| 326 |
+
|
| 327 |
+
elapsed = time.perf_counter() - t0
|
| 328 |
+
logger.info(f"Qwen2.5 ready in {elapsed:.1f}s")
|
| 329 |
+
self._loaded = True
|
| 330 |
+
|
| 331 |
+
def _build_prompt(self, transcript: str) -> str:
|
| 332 |
+
"""
|
| 333 |
+
Qwen2.5-Instruct uses ChatML format:
|
| 334 |
+
<|im_start|>system\nβ¦<|im_end|>\n
|
| 335 |
+
<|im_start|>user\nβ¦<|im_end|>\n
|
| 336 |
+
<|im_start|>assistant\n
|
| 337 |
+
The grammar then forces the assistant turn to be valid JSON.
|
| 338 |
+
"""
|
| 339 |
+
from datetime import date
|
| 340 |
+
today = date.today().strftime("%Y-%m-%d")
|
| 341 |
+
|
| 342 |
+
system = _SYSTEM_PROMPT.format(today=today)
|
| 343 |
+
user = _USER_PROMPT_TEMPLATE.format(transcript=transcript.strip())
|
| 344 |
+
|
| 345 |
+
return (
|
| 346 |
+
f"<|im_start|>system\n{system}<|im_end|>\n"
|
| 347 |
+
f"<|im_start|>user\n{user}<|im_end|>\n"
|
| 348 |
+
f"<|im_start|>assistant\n"
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
def _parse_response(self, raw: str, original_transcript: str) -> SchedulingIntent:
|
| 352 |
+
"""
|
| 353 |
+
Parse and validate Qwen's JSON output.
|
| 354 |
+
The grammar guarantees structural validity; Pydantic validates values.
|
| 355 |
+
"""
|
| 356 |
+
# Strip any accidental markdown fences
|
| 357 |
+
raw = raw.strip().lstrip("```json").lstrip("```").rstrip("```").strip()
|
| 358 |
+
|
| 359 |
+
try:
|
| 360 |
+
data = json.loads(raw)
|
| 361 |
+
intent = SchedulingIntent(**data).compute_missing()
|
| 362 |
+
logger.info(
|
| 363 |
+
f"Parsed intent={intent.intent} "
|
| 364 |
+
f"name={intent.caller_name} "
|
| 365 |
+
f"date={intent.preferred_date} "
|
| 366 |
+
f"time={intent.preferred_time} "
|
| 367 |
+
f"confidence={intent.confidence:.2f} "
|
| 368 |
+
f"missing={intent.missing_fields}"
|
| 369 |
+
)
|
| 370 |
+
return intent
|
| 371 |
+
|
| 372 |
+
except Exception as exc:
|
| 373 |
+
logger.warning(f"JSON parse/validation failed: {exc} β raw was: {raw[:200]}")
|
| 374 |
+
return self._fallback(str(exc))
|
| 375 |
+
|
| 376 |
+
@staticmethod
|
| 377 |
+
def _fallback(reason: str) -> SchedulingIntent:
|
| 378 |
+
logger.warning(f"Returning fallback intent β reason: {reason}")
|
| 379 |
+
return SchedulingIntent(
|
| 380 |
+
intent = "unclear",
|
| 381 |
+
confidence = 0.0,
|
| 382 |
+
).compute_missing()
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
# ββ Module singleton ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 386 |
+
|
| 387 |
+
_parser: Optional[IntentParser] = None
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def get_intent_parser() -> IntentParser:
|
| 391 |
+
global _parser
|
| 392 |
+
if _parser is None:
|
| 393 |
+
_parser = IntentParser()
|
| 394 |
+
return _parser
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
# ββ Offline smoke test ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 398 |
+
|
| 399 |
+
def _smoke_test_offline():
|
| 400 |
+
"""Tests schema, validators, and prompt building without loading the model."""
|
| 401 |
+
logging.basicConfig(level=logging.INFO)
|
| 402 |
+
logger.info("Running IntentParser offline smoke testβ¦")
|
| 403 |
+
|
| 404 |
+
# 1. Valid full intent
|
| 405 |
+
intent = SchedulingIntent(
|
| 406 |
+
intent = "book_meeting",
|
| 407 |
+
caller_name = "Priya Sharma",
|
| 408 |
+
preferred_date = "2026-06-10",
|
| 409 |
+
preferred_time = "14:00",
|
| 410 |
+
duration_minutes = 30,
|
| 411 |
+
participants = ["Priya Sharma"],
|
| 412 |
+
meeting_type = "video",
|
| 413 |
+
notes = None,
|
| 414 |
+
confidence = 0.95,
|
| 415 |
+
).compute_missing()
|
| 416 |
+
assert intent.missing_fields == [], f"Expected no missing fields, got {intent.missing_fields}"
|
| 417 |
+
logger.info(" β Full booking intent β no missing fields")
|
| 418 |
+
|
| 419 |
+
# 2. Partial intent β date and time missing
|
| 420 |
+
partial = SchedulingIntent(
|
| 421 |
+
intent = "book_meeting",
|
| 422 |
+
caller_name = "Raj",
|
| 423 |
+
confidence = 0.4,
|
| 424 |
+
).compute_missing()
|
| 425 |
+
assert "preferred_date" in partial.missing_fields
|
| 426 |
+
assert "preferred_time" in partial.missing_fields
|
| 427 |
+
logger.info(f" β Partial intent missing fields: {partial.missing_fields}")
|
| 428 |
+
|
| 429 |
+
# 3. Invalid intent string β coerced to "unclear"
|
| 430 |
+
coerced = SchedulingIntent(intent="nonsense", confidence=0.1)
|
| 431 |
+
assert coerced.intent == "unclear"
|
| 432 |
+
logger.info(" β Invalid intent string coerced to 'unclear'")
|
| 433 |
+
|
| 434 |
+
# 4. Malformed date β None
|
| 435 |
+
bad_date = SchedulingIntent(intent="book_meeting", preferred_date="June 10th")
|
| 436 |
+
assert bad_date.preferred_date is None
|
| 437 |
+
logger.info(" β Malformed date rejected β None")
|
| 438 |
+
|
| 439 |
+
# 5. Confidence clamping
|
| 440 |
+
clamped = SchedulingIntent(intent="unclear", confidence=999.0)
|
| 441 |
+
assert clamped.confidence == 1.0
|
| 442 |
+
logger.info(" β Confidence clamped to 1.0")
|
| 443 |
+
|
| 444 |
+
# 6. Prompt build
|
| 445 |
+
parser = IntentParser()
|
| 446 |
+
prompt = parser._build_prompt("Hi I want to book a meeting tomorrow at 3pm")
|
| 447 |
+
assert "<|im_start|>system" in prompt
|
| 448 |
+
assert "2026-" in prompt # today injected
|
| 449 |
+
assert "tomorrow" in prompt
|
| 450 |
+
logger.info(" β Prompt format correct (ChatML)")
|
| 451 |
+
|
| 452 |
+
# 7. Fallback
|
| 453 |
+
fallback = IntentParser._fallback("test")
|
| 454 |
+
assert fallback.intent == "unclear"
|
| 455 |
+
logger.info(" β Fallback returns 'unclear' intent")
|
| 456 |
+
|
| 457 |
+
logger.info("\nOffline smoke test PASSED β")
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
if __name__ == "__main__":
|
| 461 |
+
_smoke_test_offline()
|
pipeline/orchestrator.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
pipeline/orchestrator.py
|
| 3 |
+
|
| 4 |
+
Orchestrator for the telecalling agent pipeline.
|
| 5 |
+
|
| 6 |
+
Coordinates the end-to-end flow:
|
| 7 |
+
1. VAD listener β detects speech boundaries
|
| 8 |
+
2. Transcriber β audio-to-text (Cohere)
|
| 9 |
+
3. Intent parser β structured intent extraction (Qwen2.5)
|
| 10 |
+
4. Evaluator β scheduling decision + spoken response (MiniCPM3)
|
| 11 |
+
5. Database updates β persist call state
|
| 12 |
+
|
| 13 |
+
This module is the glue between Gradio's audio stream and the ML models.
|
| 14 |
+
It maintains call state across multiple audio chunks and orchestrates
|
| 15 |
+
lazy-loaded model lifecycle.
|
| 16 |
+
|
| 17 |
+
Usage
|
| 18 |
+
βββββ
|
| 19 |
+
session = CallSession()
|
| 20 |
+
session.start_call()
|
| 21 |
+
|
| 22 |
+
# Called by Gradio on each audio chunk:
|
| 23 |
+
update = session.process_audio_chunk(sample_rate, audio_chunk)
|
| 24 |
+
# use update to refresh UI
|
| 25 |
+
|
| 26 |
+
session.end_call()
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
import logging
|
| 30 |
+
from dataclasses import dataclass, field
|
| 31 |
+
from typing import Optional
|
| 32 |
+
|
| 33 |
+
import numpy as np
|
| 34 |
+
|
| 35 |
+
from config import DB_PATH
|
| 36 |
+
from db import (
|
| 37 |
+
create_call,
|
| 38 |
+
append_transcript,
|
| 39 |
+
update_call_intent,
|
| 40 |
+
update_call_decision,
|
| 41 |
+
is_slot_available,
|
| 42 |
+
get_booked_slots,
|
| 43 |
+
get_recent_calls,
|
| 44 |
+
)
|
| 45 |
+
from pipeline.transcriber import get_transcriber
|
| 46 |
+
from pipeline.intent_parser import get_intent_parser
|
| 47 |
+
from pipeline.evaluater import get_evaluator
|
| 48 |
+
|
| 49 |
+
logger = logging.getLogger(__name__)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ββ Mock Transcriber β fallback when PyTorch/dependencies missing ββββββββββββββ
|
| 53 |
+
|
| 54 |
+
class MockTranscriber:
|
| 55 |
+
"""Fallback mock transcriber for testing without PyTorch."""
|
| 56 |
+
|
| 57 |
+
def __init__(self):
|
| 58 |
+
self.is_loaded = True
|
| 59 |
+
self.device = "mock"
|
| 60 |
+
self._call_count = 0
|
| 61 |
+
|
| 62 |
+
def transcribe(self, sample_rate: int, audio: np.ndarray) -> str:
|
| 63 |
+
"""Return placeholder transcriptions for testing."""
|
| 64 |
+
self._call_count += 1
|
| 65 |
+
responses = [
|
| 66 |
+
"I'd like to book a meeting",
|
| 67 |
+
"Can we schedule for next Monday at 2 PM",
|
| 68 |
+
"Yes, that works for me",
|
| 69 |
+
"Please confirm my booking",
|
| 70 |
+
"Thank you, goodbye",
|
| 71 |
+
]
|
| 72 |
+
return responses[self._call_count % len(responses)]
|
| 73 |
+
|
| 74 |
+
def transcribe_batch(self, utterances: list) -> list[str]:
|
| 75 |
+
return [self.transcribe(sr, audio) for sr, audio in utterances]
|
| 76 |
+
|
| 77 |
+
def unload(self):
|
| 78 |
+
pass
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def get_mock_transcriber() -> MockTranscriber:
|
| 82 |
+
"""Get mock transcriber instance."""
|
| 83 |
+
return MockTranscriber()
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ββ MockVADListener β always available as fallback ββββββββββββββββββββββββββββ
|
| 87 |
+
|
| 88 |
+
class MockVADListener:
|
| 89 |
+
"""Fallback mock VAD for testing without onnxruntime or torch/numpy issues."""
|
| 90 |
+
|
| 91 |
+
def __init__(self):
|
| 92 |
+
self.is_speaking = False
|
| 93 |
+
self._buffer = []
|
| 94 |
+
self._chunk_count = 0
|
| 95 |
+
|
| 96 |
+
def process_chunk(self, sample_rate: int, audio):
|
| 97 |
+
"""Simulate speech detection using simple chunk counting."""
|
| 98 |
+
if audio is None or len(audio) == 0:
|
| 99 |
+
return
|
| 100 |
+
|
| 101 |
+
try:
|
| 102 |
+
audio_np = np.asarray(audio, dtype=np.float32)
|
| 103 |
+
except Exception:
|
| 104 |
+
return
|
| 105 |
+
|
| 106 |
+
has_energy = float(np.max(np.abs(audio_np))) > 0.01
|
| 107 |
+
|
| 108 |
+
if has_energy:
|
| 109 |
+
self.is_speaking = True
|
| 110 |
+
self._buffer.append(audio_np)
|
| 111 |
+
self._chunk_count += 1
|
| 112 |
+
|
| 113 |
+
if self._chunk_count >= 8:
|
| 114 |
+
try:
|
| 115 |
+
utterance = np.concatenate(self._buffer, axis=0)
|
| 116 |
+
self._buffer = []
|
| 117 |
+
self._chunk_count = 0
|
| 118 |
+
self.is_speaking = False
|
| 119 |
+
yield (sample_rate, utterance)
|
| 120 |
+
except Exception as e:
|
| 121 |
+
logger.debug(f"MockVAD concatenate failed: {e}")
|
| 122 |
+
self._buffer = []
|
| 123 |
+
self._chunk_count = 0
|
| 124 |
+
else:
|
| 125 |
+
if self._buffer:
|
| 126 |
+
try:
|
| 127 |
+
utterance = np.concatenate(self._buffer, axis=0)
|
| 128 |
+
self._buffer = []
|
| 129 |
+
self._chunk_count = 0
|
| 130 |
+
self.is_speaking = False
|
| 131 |
+
yield (sample_rate, utterance)
|
| 132 |
+
except Exception as e:
|
| 133 |
+
logger.debug(f"MockVAD flush failed: {e}")
|
| 134 |
+
self._buffer = []
|
| 135 |
+
self._chunk_count = 0
|
| 136 |
+
|
| 137 |
+
def reset(self):
|
| 138 |
+
self._buffer = []
|
| 139 |
+
self._chunk_count = 0
|
| 140 |
+
self.is_speaking = False
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ββ Try to import real VADListener; fallback to mock if needed βββββββββββββββββ
|
| 144 |
+
|
| 145 |
+
try:
|
| 146 |
+
from pipeline.vad_listener import VADListener
|
| 147 |
+
except (ImportError, ModuleNotFoundError) as e:
|
| 148 |
+
logger.warning(f"VADListener import failed ({e}) β will use MockVADListener")
|
| 149 |
+
VADListener = MockVADListener
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# ββ Output types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 153 |
+
|
| 154 |
+
@dataclass
|
| 155 |
+
class PipelineUpdate:
|
| 156 |
+
"""Update to display in the Gradio UI after each processing step."""
|
| 157 |
+
status: str # status badge text
|
| 158 |
+
vad_speaking: bool # True if mic active
|
| 159 |
+
transcript_lines: list[str] # accumulated utterances
|
| 160 |
+
intent_md: str # markdown table of intent
|
| 161 |
+
agent_response: str # what the agent said
|
| 162 |
+
booking_confirmed: Optional[dict] # booking details if confirmed
|
| 163 |
+
call_log: list[dict] # recent call records
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
@dataclass
|
| 167 |
+
class _OrchestrationResponse:
|
| 168 |
+
"""Internal response from pipeline processing."""
|
| 169 |
+
spoken_text: str # text to say back to caller
|
| 170 |
+
call_id: int # database row id
|
| 171 |
+
is_terminal: bool # True if call should end
|
| 172 |
+
interim_intent: Optional[str] = None # intent extracted so far
|
| 173 |
+
booking_info: Optional[dict] = None # booking details if confirmed
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ββ Session orchestrator ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 177 |
+
|
| 178 |
+
class CallSession:
|
| 179 |
+
"""
|
| 180 |
+
Stateful session for one telecalling interaction in Gradio.
|
| 181 |
+
|
| 182 |
+
Lifecycle
|
| 183 |
+
βββββββββ
|
| 184 |
+
1. __init__() β create empty, not yet active
|
| 185 |
+
2. start_call() β activates VAD listener, creates database record
|
| 186 |
+
3. process_audio_chunk() β repeatedly as Gradio streams audio
|
| 187 |
+
4. end_call() β cleanup and reset for next call
|
| 188 |
+
5. reset() β full session reset
|
| 189 |
+
|
| 190 |
+
All methods return PipelineUpdate to refresh the UI.
|
| 191 |
+
"""
|
| 192 |
+
|
| 193 |
+
def __init__(self):
|
| 194 |
+
"""Initialize an inactive session."""
|
| 195 |
+
self._call_active = False
|
| 196 |
+
self._call_id = None
|
| 197 |
+
self._vad = None
|
| 198 |
+
|
| 199 |
+
# Try to load real transcriber; fall back to mock on import error
|
| 200 |
+
try:
|
| 201 |
+
self._transcriber = get_transcriber()
|
| 202 |
+
except (ImportError, ModuleNotFoundError) as e:
|
| 203 |
+
logger.warning(f"Transcriber import failed ({e}) β using MockTranscriber")
|
| 204 |
+
self._transcriber = get_mock_transcriber()
|
| 205 |
+
|
| 206 |
+
try:
|
| 207 |
+
self._parser = get_intent_parser()
|
| 208 |
+
except (ImportError, ModuleNotFoundError) as e:
|
| 209 |
+
logger.warning(f"Parser import failed ({e})")
|
| 210 |
+
self._parser = None
|
| 211 |
+
|
| 212 |
+
try:
|
| 213 |
+
self._evaluator = get_evaluator()
|
| 214 |
+
except (ImportError, ModuleNotFoundError) as e:
|
| 215 |
+
logger.warning(f"Evaluator import failed ({e})")
|
| 216 |
+
self._evaluator = None
|
| 217 |
+
|
| 218 |
+
self._utterances = []
|
| 219 |
+
self._current_intent = None
|
| 220 |
+
self._last_response = ""
|
| 221 |
+
self._booking_info = None
|
| 222 |
+
|
| 223 |
+
logger.info("CallSession created (inactive)")
|
| 224 |
+
|
| 225 |
+
@property
|
| 226 |
+
def call_active(self) -> bool:
|
| 227 |
+
"""Whether a call is currently in progress."""
|
| 228 |
+
return self._call_active
|
| 229 |
+
|
| 230 |
+
# ββ Lifecycle methods ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 231 |
+
|
| 232 |
+
def start_call(self) -> PipelineUpdate:
|
| 233 |
+
"""Start a new call session."""
|
| 234 |
+
self._call_active = True
|
| 235 |
+
|
| 236 |
+
# Try to initialize real VADListener; fall back to mock on error
|
| 237 |
+
try:
|
| 238 |
+
self._vad = VADListener()
|
| 239 |
+
except (RuntimeError, ValueError) as e:
|
| 240 |
+
logger.warning(f"VADListener init failed at runtime ({e}) β using MockVADListener")
|
| 241 |
+
self._vad = MockVADListener()
|
| 242 |
+
|
| 243 |
+
self._utterances = []
|
| 244 |
+
self._current_intent = None
|
| 245 |
+
self._last_response = ""
|
| 246 |
+
self._booking_info = None
|
| 247 |
+
|
| 248 |
+
self._call_id = create_call("gradio_session")
|
| 249 |
+
logger.info(f"Call started β id={self._call_id}")
|
| 250 |
+
|
| 251 |
+
return self._build_update()
|
| 252 |
+
|
| 253 |
+
def end_call(self) -> PipelineUpdate:
|
| 254 |
+
"""End the current call."""
|
| 255 |
+
if not self._call_active:
|
| 256 |
+
return self._build_update()
|
| 257 |
+
|
| 258 |
+
self._call_active = False
|
| 259 |
+
if self._vad:
|
| 260 |
+
self._vad.reset()
|
| 261 |
+
|
| 262 |
+
logger.info(f"Call ended β id={self._call_id}")
|
| 263 |
+
return self._build_update()
|
| 264 |
+
|
| 265 |
+
def reset(self) -> PipelineUpdate:
|
| 266 |
+
"""Full session reset."""
|
| 267 |
+
self.end_call()
|
| 268 |
+
self._call_id = None
|
| 269 |
+
self._utterances = []
|
| 270 |
+
self._current_intent = None
|
| 271 |
+
self._last_response = ""
|
| 272 |
+
self._booking_info = None
|
| 273 |
+
return self._build_update()
|
| 274 |
+
|
| 275 |
+
# ββ Audio processing ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 276 |
+
|
| 277 |
+
def process_audio_chunk(
|
| 278 |
+
self, sample_rate: int, audio: np.ndarray
|
| 279 |
+
) -> PipelineUpdate:
|
| 280 |
+
"""
|
| 281 |
+
Process one audio chunk from Gradio microphone.
|
| 282 |
+
|
| 283 |
+
If a complete utterance is detected (speech β silence), runs the
|
| 284 |
+
full pipeline: transcribe β parse intent β evaluate β update DB.
|
| 285 |
+
"""
|
| 286 |
+
if not self._call_active or not self._vad or not self._call_id:
|
| 287 |
+
return self._build_update()
|
| 288 |
+
|
| 289 |
+
# ββ Step 1: VAD β detect utterance boundaries ββββββββββββββββββββββββββ
|
| 290 |
+
try:
|
| 291 |
+
utterances_detected = list(self._vad.process_chunk(sample_rate, audio))
|
| 292 |
+
except RuntimeError as e:
|
| 293 |
+
# VAD failed at runtime (e.g., torch/numpy issue) β fall back to mock
|
| 294 |
+
if not isinstance(self._vad, MockVADListener):
|
| 295 |
+
logger.warning(
|
| 296 |
+
f"VAD process_chunk failed ({e}) β switching to MockVADListener"
|
| 297 |
+
)
|
| 298 |
+
self._vad = MockVADListener()
|
| 299 |
+
try:
|
| 300 |
+
utterances_detected = list(self._vad.process_chunk(sample_rate, audio))
|
| 301 |
+
except Exception as e2:
|
| 302 |
+
logger.error(f"Mock VAD also failed: {e2}")
|
| 303 |
+
return self._build_update()
|
| 304 |
+
else:
|
| 305 |
+
logger.error(f"Mock VAD failed: {e}")
|
| 306 |
+
return self._build_update()
|
| 307 |
+
|
| 308 |
+
if not utterances_detected:
|
| 309 |
+
return self._build_update()
|
| 310 |
+
|
| 311 |
+
# ββ Steps 2β5: process each utterance ββββββββββββββββββββββββββββββββββ
|
| 312 |
+
for sample_rate_utt, audio_np in utterances_detected:
|
| 313 |
+
self._process_utterance(sample_rate_utt, audio_np)
|
| 314 |
+
|
| 315 |
+
return self._build_update()
|
| 316 |
+
|
| 317 |
+
# ββ Internal helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 318 |
+
|
| 319 |
+
def _process_utterance(self, sample_rate: int, audio: np.ndarray) -> None:
|
| 320 |
+
"""Process one complete utterance through the full pipeline."""
|
| 321 |
+
# Transcribe
|
| 322 |
+
try:
|
| 323 |
+
transcript_chunk = self._transcriber.transcribe(sample_rate, audio)
|
| 324 |
+
except (RuntimeError, ImportError) as e:
|
| 325 |
+
# Model failed to load at runtime β fall back to mock
|
| 326 |
+
if not isinstance(self._transcriber, MockTranscriber):
|
| 327 |
+
logger.warning(f"Transcriber failed ({e}) β switching to MockTranscriber")
|
| 328 |
+
self._transcriber = get_mock_transcriber()
|
| 329 |
+
transcript_chunk = self._transcriber.transcribe(sample_rate, audio)
|
| 330 |
+
else:
|
| 331 |
+
logger.error(f"MockTranscriber also failed: {e}")
|
| 332 |
+
return
|
| 333 |
+
|
| 334 |
+
if not transcript_chunk or not transcript_chunk.strip():
|
| 335 |
+
logger.debug("Transcription empty β skipping")
|
| 336 |
+
return
|
| 337 |
+
|
| 338 |
+
append_transcript(self._call_id, transcript_chunk)
|
| 339 |
+
self._utterances.append(transcript_chunk)
|
| 340 |
+
logger.info(f"Transcribed: '{transcript_chunk[:80]}β¦'")
|
| 341 |
+
|
| 342 |
+
# Parse intent
|
| 343 |
+
if self._parser:
|
| 344 |
+
try:
|
| 345 |
+
self._current_intent = self._parser.parse_accumulated(self._utterances)
|
| 346 |
+
update_call_intent(self._call_id, self._current_intent.model_dump())
|
| 347 |
+
except Exception as e:
|
| 348 |
+
logger.warning(f"Intent parsing failed: {e}")
|
| 349 |
+
return
|
| 350 |
+
else:
|
| 351 |
+
logger.debug("Parser not available β skipping intent parse")
|
| 352 |
+
return
|
| 353 |
+
|
| 354 |
+
# Evaluate
|
| 355 |
+
if not self._evaluator:
|
| 356 |
+
logger.debug("Evaluator not available β skipping evaluation")
|
| 357 |
+
return
|
| 358 |
+
|
| 359 |
+
slot_available = True
|
| 360 |
+
slot_conflicts = []
|
| 361 |
+
|
| 362 |
+
if (self._current_intent.preferred_date and
|
| 363 |
+
self._current_intent.preferred_time):
|
| 364 |
+
slot_available = is_slot_available(
|
| 365 |
+
self._current_intent.preferred_date,
|
| 366 |
+
self._current_intent.preferred_time,
|
| 367 |
+
self._current_intent.duration_minutes or 30,
|
| 368 |
+
)
|
| 369 |
+
if not slot_available:
|
| 370 |
+
slot_conflicts = get_booked_slots(self._current_intent.preferred_date)
|
| 371 |
+
|
| 372 |
+
try:
|
| 373 |
+
result = self._evaluator.evaluate(
|
| 374 |
+
self._current_intent,
|
| 375 |
+
self._utterances,
|
| 376 |
+
slot_available=slot_available,
|
| 377 |
+
slot_conflicts=slot_conflicts,
|
| 378 |
+
)
|
| 379 |
+
except Exception as e:
|
| 380 |
+
logger.warning(f"Evaluation failed: {e}")
|
| 381 |
+
result = None
|
| 382 |
+
|
| 383 |
+
if result:
|
| 384 |
+
update_call_decision(self._call_id, result.decision, result.reasoning)
|
| 385 |
+
self._last_response = result.spoken_response
|
| 386 |
+
|
| 387 |
+
# Handle booking confirmation
|
| 388 |
+
if result.decision == "schedule":
|
| 389 |
+
self._booking_info = {
|
| 390 |
+
"booking_id": self._call_id,
|
| 391 |
+
"caller": self._current_intent.caller_name or "Unknown",
|
| 392 |
+
"date": self._current_intent.preferred_date,
|
| 393 |
+
"time": self._current_intent.preferred_time,
|
| 394 |
+
"duration": self._current_intent.duration_minutes or 30,
|
| 395 |
+
"type": self._current_intent.meeting_type or "phone",
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
# Terminal decision β end call
|
| 399 |
+
if result.decision == "end_call":
|
| 400 |
+
self._call_active = False
|
| 401 |
+
|
| 402 |
+
def _build_update(self) -> PipelineUpdate:
|
| 403 |
+
"""Build UI update from current state."""
|
| 404 |
+
status = "π’ Active" if self._call_active else "β« Ready"
|
| 405 |
+
vad_speaking = self._vad.is_speaking if self._vad else False
|
| 406 |
+
|
| 407 |
+
# Format intent as markdown table
|
| 408 |
+
intent_md = self._format_intent_md()
|
| 409 |
+
|
| 410 |
+
# Call log
|
| 411 |
+
call_log = get_recent_calls(limit=10)
|
| 412 |
+
|
| 413 |
+
return PipelineUpdate(
|
| 414 |
+
status=status,
|
| 415 |
+
vad_speaking=vad_speaking,
|
| 416 |
+
transcript_lines=self._utterances,
|
| 417 |
+
intent_md=intent_md,
|
| 418 |
+
agent_response=self._last_response,
|
| 419 |
+
booking_confirmed=self._booking_info,
|
| 420 |
+
call_log=call_log,
|
| 421 |
+
)
|
| 422 |
+
|
| 423 |
+
def _format_intent_md(self) -> str:
|
| 424 |
+
"""Format current intent as markdown table."""
|
| 425 |
+
if not self._current_intent:
|
| 426 |
+
return "_No data yet β waiting for first utteranceβ¦_"
|
| 427 |
+
|
| 428 |
+
intent = self._current_intent
|
| 429 |
+
rows = [
|
| 430 |
+
f"| Field | Value |",
|
| 431 |
+
f"|-------|-------|",
|
| 432 |
+
f"| Intent | `{intent.intent}` |",
|
| 433 |
+
f"| Caller | {intent.caller_name or 'β'} |",
|
| 434 |
+
f"| Date | {intent.preferred_date or 'β'} |",
|
| 435 |
+
f"| Time | {intent.preferred_time or 'β'} |",
|
| 436 |
+
f"| Duration | {intent.duration_minutes or 'β'} min |",
|
| 437 |
+
f"| Type | {intent.meeting_type or 'β'} |",
|
| 438 |
+
f"| Confidence | {intent.confidence:.1%} |",
|
| 439 |
+
f"| Missing | {', '.join(intent.missing_fields) or 'β'} |",
|
| 440 |
+
]
|
| 441 |
+
return "\n".join(rows)
|
pipeline/transcriber.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
pipeline/transcriber.py
|
| 3 |
+
|
| 4 |
+
Cohere Transcribe wrapper for the telecalling agent.
|
| 5 |
+
|
| 6 |
+
Model
|
| 7 |
+
βββββ
|
| 8 |
+
CohereLabs/cohere-transcribe-03-2026
|
| 9 |
+
- 2B parameter Conformer encoder + lightweight Transformer decoder
|
| 10 |
+
- Audio-in, text-out ASR, Apache 2.0
|
| 11 |
+
- Gated: requires HF_TOKEN with accepted licence at
|
| 12 |
+
https://huggingface.co/CohereLabs/cohere-transcribe-03-2026
|
| 13 |
+
|
| 14 |
+
Memory budget on RTX 2050 (4 GB VRAM)
|
| 15 |
+
ββββββββββββββββββββββββββββββββββββββ
|
| 16 |
+
Full fp32 β ~8 GB β
|
| 17 |
+
float16 β ~4 GB β (tight, unstable with other allocations)
|
| 18 |
+
torch_dtype=torch.float16 + device_map keeps ~2.2 GB β β
|
| 19 |
+
|
| 20 |
+
Design decisions
|
| 21 |
+
ββββββββββββββββ
|
| 22 |
+
- Lazy-loaded: model is not pulled from HF until the first call arrives.
|
| 23 |
+
This keeps Gradio startup fast and avoids OOM if the user never speaks.
|
| 24 |
+
- GPU-first with automatic CPU fallback: if CUDA is unavailable or VRAM
|
| 25 |
+
is insufficient the model moves to CPU transparently.
|
| 26 |
+
- Uses model.transcribe(audio_arrays=...) to avoid disk I/O entirely.
|
| 27 |
+
- compile=False by default: torch.compile causes a 30-60 s warmup on
|
| 28 |
+
first call which would break the live-call UX. Set compile=True only
|
| 29 |
+
in batch / offline mode.
|
| 30 |
+
- Thread-safe: model loading is protected by a threading.Lock so
|
| 31 |
+
concurrent Gradio sessions don't race to download.
|
| 32 |
+
|
| 33 |
+
Usage
|
| 34 |
+
βββββ
|
| 35 |
+
transcriber = Transcriber() # cheap β model not loaded yet
|
| 36 |
+
text = transcriber.transcribe(sr, audio_np) # loads model on first call
|
| 37 |
+
transcriber.unload() # free VRAM between sessions
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
import logging
|
| 41 |
+
import threading
|
| 42 |
+
import time
|
| 43 |
+
from typing import Optional
|
| 44 |
+
|
| 45 |
+
import numpy as np
|
| 46 |
+
import torch
|
| 47 |
+
|
| 48 |
+
from config import (
|
| 49 |
+
TRANSCRIBE_MODEL_ID,
|
| 50 |
+
TRANSCRIBE_LANGUAGE,
|
| 51 |
+
TRANSCRIBE_DEVICE,
|
| 52 |
+
HF_TOKEN,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
logger = logging.getLogger(__name__)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class Transcriber:
|
| 59 |
+
"""
|
| 60 |
+
Lazy-loading wrapper around CohereLabs/cohere-transcribe-03-2026.
|
| 61 |
+
|
| 62 |
+
Thread-safe: a single instance can be shared across the whole app.
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
def __init__(self):
|
| 66 |
+
self._model = None
|
| 67 |
+
self._processor = None
|
| 68 |
+
self._device = None
|
| 69 |
+
self._lock = threading.Lock()
|
| 70 |
+
self._loaded = False
|
| 71 |
+
|
| 72 |
+
# ββ Public API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 73 |
+
|
| 74 |
+
def transcribe(self, sample_rate: int, audio: np.ndarray) -> str:
|
| 75 |
+
"""
|
| 76 |
+
Transcribe a single utterance.
|
| 77 |
+
|
| 78 |
+
Parameters
|
| 79 |
+
----------
|
| 80 |
+
sample_rate : int
|
| 81 |
+
Sample rate of `audio` (typically 16000 from VAD).
|
| 82 |
+
audio : np.ndarray
|
| 83 |
+
Mono float32 PCM in [-1.0, 1.0].
|
| 84 |
+
|
| 85 |
+
Returns
|
| 86 |
+
-------
|
| 87 |
+
str
|
| 88 |
+
Transcribed text, stripped of leading/trailing whitespace.
|
| 89 |
+
Returns "" on error so the pipeline can continue gracefully.
|
| 90 |
+
"""
|
| 91 |
+
if audio is None or len(audio) == 0:
|
| 92 |
+
return ""
|
| 93 |
+
|
| 94 |
+
self._ensure_loaded()
|
| 95 |
+
|
| 96 |
+
audio = self._validate_audio(audio)
|
| 97 |
+
if audio is None:
|
| 98 |
+
return ""
|
| 99 |
+
|
| 100 |
+
try:
|
| 101 |
+
t0 = time.perf_counter()
|
| 102 |
+
|
| 103 |
+
results = self._model.transcribe(
|
| 104 |
+
processor = self._processor,
|
| 105 |
+
audio_arrays = [audio], # list of np.ndarray
|
| 106 |
+
sample_rates = [sample_rate], # matching list of ints
|
| 107 |
+
language = TRANSCRIBE_LANGUAGE,
|
| 108 |
+
punctuation = True,
|
| 109 |
+
batch_size = 1, # one utterance at a time (live)
|
| 110 |
+
compile = False, # no warmup delay in live mode
|
| 111 |
+
pipeline_detokenization = False, # not needed for batch_size=1
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
elapsed = time.perf_counter() - t0
|
| 115 |
+
text = results[0].strip() if results else ""
|
| 116 |
+
|
| 117 |
+
duration = len(audio) / sample_rate
|
| 118 |
+
rtfx = duration / elapsed if elapsed > 0 else 0
|
| 119 |
+
logger.info(
|
| 120 |
+
f"Transcribed {duration:.2f}s audio in {elapsed:.2f}s "
|
| 121 |
+
f"(RTFx {rtfx:.1f}x): '{text[:80]}{'β¦' if len(text)>80 else ''}'"
|
| 122 |
+
)
|
| 123 |
+
return text
|
| 124 |
+
|
| 125 |
+
except Exception as exc:
|
| 126 |
+
logger.error(f"Transcription failed: {exc}", exc_info=True)
|
| 127 |
+
return ""
|
| 128 |
+
|
| 129 |
+
def transcribe_batch(
|
| 130 |
+
self, utterances: list[tuple[int, np.ndarray]]
|
| 131 |
+
) -> list[str]:
|
| 132 |
+
"""
|
| 133 |
+
Transcribe multiple utterances in one forward pass.
|
| 134 |
+
More efficient for post-call batch processing.
|
| 135 |
+
|
| 136 |
+
Parameters
|
| 137 |
+
----------
|
| 138 |
+
utterances : list of (sample_rate, audio_np) tuples
|
| 139 |
+
|
| 140 |
+
Returns
|
| 141 |
+
-------
|
| 142 |
+
list of str β one entry per input, "" on individual errors
|
| 143 |
+
"""
|
| 144 |
+
if not utterances:
|
| 145 |
+
return []
|
| 146 |
+
|
| 147 |
+
self._ensure_loaded()
|
| 148 |
+
|
| 149 |
+
sample_rates = [sr for sr, _ in utterances]
|
| 150 |
+
audio_arrays = [self._validate_audio(a) for _, a in utterances]
|
| 151 |
+
|
| 152 |
+
# Replace any None (invalid) arrays with silent arrays
|
| 153 |
+
audio_arrays = [
|
| 154 |
+
a if a is not None else np.zeros(sample_rates[i], dtype=np.float32)
|
| 155 |
+
for i, a in enumerate(audio_arrays)
|
| 156 |
+
]
|
| 157 |
+
|
| 158 |
+
try:
|
| 159 |
+
results = self._model.transcribe(
|
| 160 |
+
processor = self._processor,
|
| 161 |
+
audio_arrays = audio_arrays,
|
| 162 |
+
sample_rates = sample_rates,
|
| 163 |
+
language = TRANSCRIBE_LANGUAGE,
|
| 164 |
+
punctuation = True,
|
| 165 |
+
batch_size = len(utterances),
|
| 166 |
+
compile = False,
|
| 167 |
+
pipeline_detokenization = True, # helps with larger batches
|
| 168 |
+
)
|
| 169 |
+
return [r.strip() for r in results]
|
| 170 |
+
|
| 171 |
+
except Exception as exc:
|
| 172 |
+
logger.error(f"Batch transcription failed: {exc}", exc_info=True)
|
| 173 |
+
return [""] * len(utterances)
|
| 174 |
+
|
| 175 |
+
def unload(self):
|
| 176 |
+
"""
|
| 177 |
+
Release GPU memory. Call at end of a call session if memory is tight.
|
| 178 |
+
Model will be reloaded lazily on the next call.
|
| 179 |
+
"""
|
| 180 |
+
with self._lock:
|
| 181 |
+
if self._loaded:
|
| 182 |
+
del self._model
|
| 183 |
+
del self._processor
|
| 184 |
+
self._model = None
|
| 185 |
+
self._processor = None
|
| 186 |
+
self._loaded = False
|
| 187 |
+
if torch.cuda.is_available():
|
| 188 |
+
torch.cuda.empty_cache()
|
| 189 |
+
logger.info("Transcriber unloaded β VRAM freed.")
|
| 190 |
+
|
| 191 |
+
@property
|
| 192 |
+
def is_loaded(self) -> bool:
|
| 193 |
+
return self._loaded
|
| 194 |
+
|
| 195 |
+
@property
|
| 196 |
+
def device(self) -> Optional[str]:
|
| 197 |
+
return str(self._device) if self._device else None
|
| 198 |
+
|
| 199 |
+
# ββ Internal ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 200 |
+
|
| 201 |
+
def _ensure_loaded(self):
|
| 202 |
+
"""Load model + processor exactly once, thread-safely."""
|
| 203 |
+
if self._loaded:
|
| 204 |
+
return
|
| 205 |
+
|
| 206 |
+
with self._lock:
|
| 207 |
+
if self._loaded: # double-checked locking
|
| 208 |
+
return
|
| 209 |
+
self._load()
|
| 210 |
+
|
| 211 |
+
def _load(self):
|
| 212 |
+
"""
|
| 213 |
+
Pull model from HuggingFace and move to best available device.
|
| 214 |
+
|
| 215 |
+
Device selection for RTX 2050 (4 GB):
|
| 216 |
+
- float16 on CUDA: ~2.2 GB VRAM β preferred
|
| 217 |
+
- float32 on CPU: ~8.0 GB RAM β fallback
|
| 218 |
+
"""
|
| 219 |
+
from transformers import AutoProcessor, AutoModelForSpeechSeq2Seq
|
| 220 |
+
|
| 221 |
+
logger.info(f"Loading Cohere Transcribe ({TRANSCRIBE_MODEL_ID})β¦")
|
| 222 |
+
t0 = time.perf_counter()
|
| 223 |
+
|
| 224 |
+
token_kwargs = {"token": HF_TOKEN} if HF_TOKEN else {}
|
| 225 |
+
|
| 226 |
+
# Processor (tokenizer + feature extractor) β tiny, load first
|
| 227 |
+
self._processor = AutoProcessor.from_pretrained(
|
| 228 |
+
TRANSCRIBE_MODEL_ID,
|
| 229 |
+
trust_remote_code=True,
|
| 230 |
+
**token_kwargs,
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
# Determine device & dtype
|
| 234 |
+
if torch.cuda.is_available():
|
| 235 |
+
try:
|
| 236 |
+
self._device = torch.device(TRANSCRIBE_DEVICE)
|
| 237 |
+
dtype = torch.float16
|
| 238 |
+
logger.info(f"CUDA available β loading in float16 on {self._device}")
|
| 239 |
+
except Exception:
|
| 240 |
+
self._device = torch.device("cpu")
|
| 241 |
+
dtype = torch.float32
|
| 242 |
+
logger.warning("CUDA device init failed β falling back to CPU float32")
|
| 243 |
+
else:
|
| 244 |
+
self._device = torch.device("cpu")
|
| 245 |
+
dtype = torch.float32
|
| 246 |
+
logger.info("No CUDA β loading on CPU in float32 (will be slower)")
|
| 247 |
+
|
| 248 |
+
# Model weights
|
| 249 |
+
self._model = AutoModelForSpeechSeq2Seq.from_pretrained(
|
| 250 |
+
TRANSCRIBE_MODEL_ID,
|
| 251 |
+
trust_remote_code = True,
|
| 252 |
+
torch_dtype = dtype,
|
| 253 |
+
low_cpu_mem_usage = True, # stream weights instead of double-buffering
|
| 254 |
+
**token_kwargs,
|
| 255 |
+
).to(self._device)
|
| 256 |
+
|
| 257 |
+
self._model.eval()
|
| 258 |
+
|
| 259 |
+
elapsed = time.perf_counter() - t0
|
| 260 |
+
logger.info(
|
| 261 |
+
f"Cohere Transcribe ready on {self._device} "
|
| 262 |
+
f"(dtype={dtype}, loaded in {elapsed:.1f}s)"
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
if torch.cuda.is_available():
|
| 266 |
+
allocated = torch.cuda.memory_allocated(self._device) / 1024**3
|
| 267 |
+
reserved = torch.cuda.memory_reserved(self._device) / 1024**3
|
| 268 |
+
logger.info(
|
| 269 |
+
f"VRAM after load β allocated: {allocated:.2f} GB, "
|
| 270 |
+
f"reserved: {reserved:.2f} GB"
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
self._loaded = True
|
| 274 |
+
|
| 275 |
+
@staticmethod
|
| 276 |
+
def _validate_audio(audio: np.ndarray) -> Optional[np.ndarray]:
|
| 277 |
+
"""
|
| 278 |
+
Ensure audio is mono float32. Returns None if unusable.
|
| 279 |
+
"""
|
| 280 |
+
if audio is None or len(audio) == 0:
|
| 281 |
+
return None
|
| 282 |
+
|
| 283 |
+
audio = np.array(audio, dtype=np.float32)
|
| 284 |
+
|
| 285 |
+
# Stereo β mono
|
| 286 |
+
if audio.ndim == 2:
|
| 287 |
+
audio = audio.mean(axis=1)
|
| 288 |
+
elif audio.ndim != 1:
|
| 289 |
+
logger.warning(f"Unexpected audio shape {audio.shape} β skipping")
|
| 290 |
+
return None
|
| 291 |
+
|
| 292 |
+
# Normalise int16-range inputs
|
| 293 |
+
if audio.max() > 1.0 or audio.min() < -1.0:
|
| 294 |
+
audio = audio / 32768.0
|
| 295 |
+
|
| 296 |
+
audio = np.clip(audio, -1.0, 1.0)
|
| 297 |
+
return audio
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
# ββ Module-level singleton βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 301 |
+
# Shared across the whole app β model loaded once, reused every utterance.
|
| 302 |
+
_transcriber: Optional[Transcriber] = None
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def get_transcriber() -> Transcriber:
|
| 306 |
+
"""Return the module-level singleton, creating it if needed."""
|
| 307 |
+
global _transcriber
|
| 308 |
+
if _transcriber is None:
|
| 309 |
+
_transcriber = Transcriber()
|
| 310 |
+
return _transcriber
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
# ββ Offline smoke test (no HF token needed) βββββββββββββββββββββββββββββββββββ
|
| 314 |
+
|
| 315 |
+
def _smoke_test_offline():
|
| 316 |
+
"""
|
| 317 |
+
Validates the audio pre-processing path without loading the model.
|
| 318 |
+
Full model load requires a HF token + accepted Cohere licence.
|
| 319 |
+
"""
|
| 320 |
+
import math
|
| 321 |
+
logging.basicConfig(level=logging.INFO)
|
| 322 |
+
logger.info("Running offline smoke test (pre-processing only)β¦")
|
| 323 |
+
|
| 324 |
+
SR = 16000
|
| 325 |
+
|
| 326 |
+
# 1. Stereo int16 β mono float32
|
| 327 |
+
stereo_int16 = (np.random.randn(SR, 2) * 32767).astype(np.int16)
|
| 328 |
+
result = Transcriber._validate_audio(stereo_int16)
|
| 329 |
+
assert result is not None
|
| 330 |
+
assert result.ndim == 1, f"Expected mono, got shape {result.shape}"
|
| 331 |
+
assert result.dtype == np.float32
|
| 332 |
+
assert result.max() <= 1.0
|
| 333 |
+
assert result.min() >= -1.0
|
| 334 |
+
logger.info(" β Stereo int16 β mono float32 normalisation")
|
| 335 |
+
|
| 336 |
+
# 2. Already mono float32 β passthrough
|
| 337 |
+
mono_float = np.sin(2 * math.pi * 440 * np.linspace(0, 1, SR)).astype(np.float32)
|
| 338 |
+
result = Transcriber._validate_audio(mono_float)
|
| 339 |
+
assert result is not None and result.shape == (SR,)
|
| 340 |
+
logger.info(" β Mono float32 passthrough")
|
| 341 |
+
|
| 342 |
+
# 3. Empty input β None
|
| 343 |
+
result = Transcriber._validate_audio(np.array([]))
|
| 344 |
+
assert result is None
|
| 345 |
+
logger.info(" β Empty input β None")
|
| 346 |
+
|
| 347 |
+
# 4. Singleton pattern
|
| 348 |
+
t1 = get_transcriber()
|
| 349 |
+
t2 = get_transcriber()
|
| 350 |
+
assert t1 is t2
|
| 351 |
+
logger.info(" β Module singleton")
|
| 352 |
+
|
| 353 |
+
logger.info("\nOffline smoke test PASSED β")
|
| 354 |
+
logger.info(
|
| 355 |
+
"\nTo run the full model test (requires HF_TOKEN + accepted licence):\n"
|
| 356 |
+
" export HF_TOKEN=hf_...\n"
|
| 357 |
+
" PYTHONPATH=. python3 -c \"\n"
|
| 358 |
+
" from pipeline.transcriber import get_transcriber\n"
|
| 359 |
+
" import numpy as np\n"
|
| 360 |
+
" t = get_transcriber()\n"
|
| 361 |
+
" # 2s of 440 Hz tone β expect near-empty transcription\n"
|
| 362 |
+
" audio = np.sin(2*3.14*440*np.linspace(0,2,32000)).astype('float32')\n"
|
| 363 |
+
" print(repr(t.transcribe(16000, audio)))\n"
|
| 364 |
+
" \""
|
| 365 |
+
)
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
if __name__ == "__main__":
|
| 369 |
+
_smoke_test_offline()
|
pipeline/vad_listener.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
pipeline/vad_listener.py
|
| 3 |
+
|
| 4 |
+
Silero VAD-based speech listener.
|
| 5 |
+
|
| 6 |
+
Architecture
|
| 7 |
+
ββββββββββββ
|
| 8 |
+
Gradio streams raw PCM audio from the browser microphone in small numpy
|
| 9 |
+
chunks. This module wraps that stream in a stateful VADListener that:
|
| 10 |
+
|
| 11 |
+
1. Feeds each chunk to Silero VADIterator (runs on CPU via ONNX).
|
| 12 |
+
2. Accumulates PCM frames while speech is detected.
|
| 13 |
+
3. On silence-end event, flushes the complete utterance into a
|
| 14 |
+
thread-safe queue as a (sample_rate, np.ndarray) tuple.
|
| 15 |
+
4. The orchestrator reads from that queue and passes each utterance
|
| 16 |
+
to Cohere Transcribe.
|
| 17 |
+
|
| 18 |
+
Because Gradio's streaming callback fires on the main thread, all VAD
|
| 19 |
+
processing is synchronous and cheap (< 1 ms per 250 ms chunk on CPU).
|
| 20 |
+
No background threads are needed at this stage.
|
| 21 |
+
|
| 22 |
+
Usage
|
| 23 |
+
βββββ
|
| 24 |
+
listener = VADListener()
|
| 25 |
+
|
| 26 |
+
# Called by Gradio's streaming audio component on each chunk:
|
| 27 |
+
for utterance in listener.process_chunk(sample_rate, audio_np):
|
| 28 |
+
# utterance is (sample_rate, np.ndarray[float32]) β ready for ASR
|
| 29 |
+
transcript = transcriber.transcribe(*utterance)
|
| 30 |
+
|
| 31 |
+
# Reset between calls:
|
| 32 |
+
listener.reset()
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
import logging
|
| 36 |
+
import queue
|
| 37 |
+
from typing import Generator, Optional, Tuple
|
| 38 |
+
|
| 39 |
+
import numpy as np
|
| 40 |
+
import torch
|
| 41 |
+
|
| 42 |
+
from silero_vad import VADIterator, load_silero_vad
|
| 43 |
+
|
| 44 |
+
from config import (
|
| 45 |
+
VAD_SAMPLE_RATE,
|
| 46 |
+
VAD_CHUNK_SAMPLES,
|
| 47 |
+
VAD_SILENCE_THRESHOLD,
|
| 48 |
+
VAD_SILENCE_DURATION_S,
|
| 49 |
+
VAD_MIN_SPEECH_S,
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# Silero v6 requires exactly 512-sample windows at 16kHz (32 ms).
|
| 53 |
+
# We still accept larger Gradio chunks and stride through them internally.
|
| 54 |
+
VAD_WINDOW_SAMPLES = 512
|
| 55 |
+
|
| 56 |
+
logger = logging.getLogger(__name__)
|
| 57 |
+
|
| 58 |
+
# ββ Types βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 59 |
+
Utterance = Tuple[int, np.ndarray] # (sample_rate, float32 PCM)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class VADListener:
|
| 63 |
+
"""
|
| 64 |
+
Stateful VAD processor. One instance per Gradio user session.
|
| 65 |
+
|
| 66 |
+
The VADIterator state machine emits:
|
| 67 |
+
{'start': sample_idx} β speech onset detected
|
| 68 |
+
{'end': sample_idx} β silence detected after speech (utterance done)
|
| 69 |
+
None β mid-speech or mid-silence, keep accumulating
|
| 70 |
+
|
| 71 |
+
We buffer raw PCM frames during the speech window and yield a complete
|
| 72 |
+
utterance when 'end' fires.
|
| 73 |
+
"""
|
| 74 |
+
|
| 75 |
+
def __init__(self):
|
| 76 |
+
logger.info("Loading Silero VAD model (ONNX, CPU)β¦")
|
| 77 |
+
self._model = load_silero_vad(onnx=True) # ONNX = no PyTorch GPU mem
|
| 78 |
+
|
| 79 |
+
# min_silence_duration_ms drives the 'end' event timing.
|
| 80 |
+
# We use VAD_SILENCE_DURATION_S from config (default 0.8 s = 800 ms).
|
| 81 |
+
self._vad = VADIterator(
|
| 82 |
+
model=self._model,
|
| 83 |
+
threshold=VAD_SILENCE_THRESHOLD,
|
| 84 |
+
sampling_rate=VAD_SAMPLE_RATE,
|
| 85 |
+
min_silence_duration_ms=int(VAD_SILENCE_DURATION_S * 1000),
|
| 86 |
+
speech_pad_ms=60, # 60 ms padding on each side β natural feel
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
self._speech_buffer: list[np.ndarray] = [] # accumulates speech frames
|
| 90 |
+
self._speaking: bool = False
|
| 91 |
+
|
| 92 |
+
# Internal queue β orchestrator can also pull from here if preferred
|
| 93 |
+
self._utterance_queue: queue.SimpleQueue = queue.SimpleQueue()
|
| 94 |
+
|
| 95 |
+
logger.info("Silero VAD ready.")
|
| 96 |
+
|
| 97 |
+
# ββ Public interface ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 98 |
+
|
| 99 |
+
def process_chunk(
|
| 100 |
+
self, sample_rate: int, audio: np.ndarray
|
| 101 |
+
) -> Generator[Utterance, None, None]:
|
| 102 |
+
"""
|
| 103 |
+
Accept one audio chunk from Gradio and yield zero or one utterance.
|
| 104 |
+
|
| 105 |
+
Parameters
|
| 106 |
+
----------
|
| 107 |
+
sample_rate : int
|
| 108 |
+
Sample rate reported by Gradio (may differ from VAD_SAMPLE_RATE).
|
| 109 |
+
audio : np.ndarray
|
| 110 |
+
Raw PCM, any shape. Will be normalised to mono float32 16 kHz.
|
| 111 |
+
|
| 112 |
+
Yields
|
| 113 |
+
------
|
| 114 |
+
(VAD_SAMPLE_RATE, np.ndarray[float32])
|
| 115 |
+
Complete utterance, ready to be passed to Cohere Transcribe.
|
| 116 |
+
"""
|
| 117 |
+
if audio is None or len(audio) == 0:
|
| 118 |
+
return
|
| 119 |
+
|
| 120 |
+
# ββ 1. Normalise to mono float32 ββββββββββββββββββββββββββββββββββββββ
|
| 121 |
+
audio = self._to_mono_float32(audio)
|
| 122 |
+
|
| 123 |
+
# ββ 2. Resample if Gradio gives a different rate ββββββββββββββββββββββ
|
| 124 |
+
if sample_rate != VAD_SAMPLE_RATE:
|
| 125 |
+
audio = self._resample(audio, sample_rate, VAD_SAMPLE_RATE)
|
| 126 |
+
|
| 127 |
+
# ββ 3. Pad to chunk boundary (Silero needs fixed-size windows) ββββββββ
|
| 128 |
+
audio = self._pad_to_chunk(audio)
|
| 129 |
+
|
| 130 |
+
# ββ 4. Walk through 512-sample windows (Silero v6 requirement) βββββββββ
|
| 131 |
+
for i in range(0, len(audio), VAD_WINDOW_SAMPLES):
|
| 132 |
+
window = audio[i : i + VAD_WINDOW_SAMPLES]
|
| 133 |
+
if len(window) < VAD_WINDOW_SAMPLES:
|
| 134 |
+
break # incomplete trailing window β skip
|
| 135 |
+
|
| 136 |
+
tensor = torch.from_numpy(window)
|
| 137 |
+
event = self._vad(tensor)
|
| 138 |
+
|
| 139 |
+
if event is None:
|
| 140 |
+
# Mid-speech: keep buffering. Mid-silence: do nothing.
|
| 141 |
+
if self._speaking:
|
| 142 |
+
self._speech_buffer.append(window)
|
| 143 |
+
|
| 144 |
+
elif "start" in event:
|
| 145 |
+
# Speech onset β start accumulating
|
| 146 |
+
if not self._speaking:
|
| 147 |
+
logger.debug(f"Speech START at sample {event['start']}")
|
| 148 |
+
self._speaking = True
|
| 149 |
+
self._speech_buffer = [window] # include onset window
|
| 150 |
+
|
| 151 |
+
elif "end" in event:
|
| 152 |
+
# Speech ended β flush if long enough
|
| 153 |
+
if self._speaking:
|
| 154 |
+
self._speaking = False
|
| 155 |
+
self._speech_buffer.append(window) # include trailing window
|
| 156 |
+
utterance = self._flush()
|
| 157 |
+
if utterance is not None:
|
| 158 |
+
logger.debug(
|
| 159 |
+
f"Utterance flushed: {len(utterance[1])/VAD_SAMPLE_RATE:.2f}s"
|
| 160 |
+
)
|
| 161 |
+
yield utterance
|
| 162 |
+
|
| 163 |
+
def reset(self):
|
| 164 |
+
"""
|
| 165 |
+
Call between calls / sessions to wipe VAD state and audio buffer.
|
| 166 |
+
"""
|
| 167 |
+
self._vad.reset_states()
|
| 168 |
+
self._speech_buffer = []
|
| 169 |
+
self._speaking = False
|
| 170 |
+
# drain the queue
|
| 171 |
+
while not self._utterance_queue.empty():
|
| 172 |
+
try:
|
| 173 |
+
self._utterance_queue.get_nowait()
|
| 174 |
+
except queue.Empty:
|
| 175 |
+
break
|
| 176 |
+
logger.debug("VADListener reset.")
|
| 177 |
+
|
| 178 |
+
@property
|
| 179 |
+
def is_speaking(self) -> bool:
|
| 180 |
+
return self._speaking
|
| 181 |
+
|
| 182 |
+
# ββ Internal helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 183 |
+
|
| 184 |
+
def _flush(self) -> Optional[Utterance]:
|
| 185 |
+
"""
|
| 186 |
+
Concatenate buffer frames into a single utterance array.
|
| 187 |
+
Drops utterances shorter than VAD_MIN_SPEECH_S (noise / clicks).
|
| 188 |
+
"""
|
| 189 |
+
if not self._speech_buffer:
|
| 190 |
+
return None
|
| 191 |
+
|
| 192 |
+
utterance = np.concatenate(self._speech_buffer, axis=0)
|
| 193 |
+
duration = len(utterance) / VAD_SAMPLE_RATE
|
| 194 |
+
|
| 195 |
+
from config import VAD_MIN_SPEECH_S
|
| 196 |
+
if duration < VAD_MIN_SPEECH_S:
|
| 197 |
+
logger.debug(f"Utterance too short ({duration:.2f}s) β discarded.")
|
| 198 |
+
self._speech_buffer = []
|
| 199 |
+
return None
|
| 200 |
+
|
| 201 |
+
self._speech_buffer = []
|
| 202 |
+
return (VAD_SAMPLE_RATE, utterance)
|
| 203 |
+
|
| 204 |
+
@staticmethod
|
| 205 |
+
def _to_mono_float32(audio: np.ndarray) -> np.ndarray:
|
| 206 |
+
"""
|
| 207 |
+
Convert any numpy audio array to mono float32 in [-1.0, 1.0].
|
| 208 |
+
Gradio typically gives int16 or float32; handles both.
|
| 209 |
+
"""
|
| 210 |
+
audio = np.array(audio, dtype=np.float32)
|
| 211 |
+
|
| 212 |
+
# Stereo β mono
|
| 213 |
+
if audio.ndim == 2:
|
| 214 |
+
audio = audio.mean(axis=1)
|
| 215 |
+
|
| 216 |
+
# int16 range β float32 [-1, 1]
|
| 217 |
+
if audio.max() > 1.0 or audio.min() < -1.0:
|
| 218 |
+
audio = audio / 32768.0
|
| 219 |
+
|
| 220 |
+
# Clip to valid range
|
| 221 |
+
audio = np.clip(audio, -1.0, 1.0)
|
| 222 |
+
return audio
|
| 223 |
+
|
| 224 |
+
@staticmethod
|
| 225 |
+
def _resample(audio: np.ndarray, orig_sr: int, target_sr: int) -> np.ndarray:
|
| 226 |
+
"""
|
| 227 |
+
Simple linear interpolation resample.
|
| 228 |
+
Avoids importing torchaudio here (saves CUDA context startup cost).
|
| 229 |
+
Good enough for pre-processing before VAD.
|
| 230 |
+
"""
|
| 231 |
+
if orig_sr == target_sr:
|
| 232 |
+
return audio
|
| 233 |
+
ratio = target_sr / orig_sr
|
| 234 |
+
new_length = int(len(audio) * ratio)
|
| 235 |
+
return np.interp(
|
| 236 |
+
np.linspace(0, len(audio) - 1, new_length),
|
| 237 |
+
np.arange(len(audio)),
|
| 238 |
+
audio,
|
| 239 |
+
).astype(np.float32)
|
| 240 |
+
|
| 241 |
+
@staticmethod
|
| 242 |
+
def _pad_to_chunk(audio: np.ndarray) -> np.ndarray:
|
| 243 |
+
"""
|
| 244 |
+
Zero-pad audio so its length is a multiple of VAD_CHUNK_SAMPLES.
|
| 245 |
+
Silero requires fixed-size windows.
|
| 246 |
+
"""
|
| 247 |
+
remainder = len(audio) % VAD_WINDOW_SAMPLES
|
| 248 |
+
if remainder:
|
| 249 |
+
padding = VAD_WINDOW_SAMPLES - remainder
|
| 250 |
+
audio = np.concatenate([audio, np.zeros(padding, dtype=np.float32)])
|
| 251 |
+
return audio
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
# ββ Standalone smoke test βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 255 |
+
|
| 256 |
+
def _smoke_test():
|
| 257 |
+
"""
|
| 258 |
+
Simulates a real mic stream:
|
| 259 |
+
- 0.5 s silence
|
| 260 |
+
- 1.5 s synthetic speech-like tone (500 Hz sine)
|
| 261 |
+
- 0.9 s silence β should trigger 'end' and yield one utterance
|
| 262 |
+
- 0.5 s silence
|
| 263 |
+
"""
|
| 264 |
+
import math
|
| 265 |
+
logging.basicConfig(level=logging.DEBUG)
|
| 266 |
+
|
| 267 |
+
SR = VAD_SAMPLE_RATE
|
| 268 |
+
FREQ = 500 # Hz β sine at 500 Hz is reliably detected as speech
|
| 269 |
+
|
| 270 |
+
def sine_chunk(duration_s: float, frequency: float = 0) -> np.ndarray:
|
| 271 |
+
n = int(SR * duration_s)
|
| 272 |
+
if frequency == 0:
|
| 273 |
+
return np.zeros(n, dtype=np.float32)
|
| 274 |
+
t = np.linspace(0, duration_s, n, endpoint=False)
|
| 275 |
+
return (0.6 * np.sin(2 * math.pi * frequency * t)).astype(np.float32)
|
| 276 |
+
|
| 277 |
+
timeline = [
|
| 278 |
+
sine_chunk(0.5), # silence
|
| 279 |
+
sine_chunk(1.5, FREQ), # speech
|
| 280 |
+
sine_chunk(0.9), # silence β should flush
|
| 281 |
+
sine_chunk(0.5), # trailing silence
|
| 282 |
+
]
|
| 283 |
+
|
| 284 |
+
listener = VADListener()
|
| 285 |
+
utterances_received = 0
|
| 286 |
+
|
| 287 |
+
for chunk in timeline:
|
| 288 |
+
# Feed in 250 ms sub-chunks as Gradio would
|
| 289 |
+
for start in range(0, len(chunk), VAD_CHUNK_SAMPLES):
|
| 290 |
+
sub = chunk[start : start + VAD_CHUNK_SAMPLES]
|
| 291 |
+
for utt in listener.process_chunk(SR, sub):
|
| 292 |
+
utterances_received += 1
|
| 293 |
+
duration = len(utt[1]) / utt[0]
|
| 294 |
+
print(f" β Utterance received: {duration:.2f}s at {utt[0]} Hz")
|
| 295 |
+
|
| 296 |
+
print(f"\nSmoke test complete β {utterances_received} utterance(s) flushed.")
|
| 297 |
+
assert utterances_received >= 1, "Expected at least one utterance!"
|
| 298 |
+
print("PASSED β")
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
if __name__ == "__main__":
|
| 302 |
+
_smoke_test()
|
requirements.txt
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ββ Core ML / inference βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
torch==2.3.1
|
| 3 |
+
torchaudio==2.3.1
|
| 4 |
+
transformers>=4.56,<5.3,!=5.0.*,!=5.1.* # Cohere Transcribe requirement (exact)
|
| 5 |
+
accelerate>=0.30.0
|
| 6 |
+
bitsandbytes>=0.43.0 # 4-bit quant for MiniCPM on CPU
|
| 7 |
+
sentencepiece
|
| 8 |
+
protobuf
|
| 9 |
+
|
| 10 |
+
# ββ Cohere Transcribe ASR βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 11 |
+
soundfile
|
| 12 |
+
librosa
|
| 13 |
+
huggingface_hub
|
| 14 |
+
|
| 15 |
+
# ββ Qwen2.5 intent parser (llama-cpp-python with CUDA offload) ββββββββββββββββ
|
| 16 |
+
# Install with CUDA support β do NOT use the plain pip version.
|
| 17 |
+
# Run this separately after pip install -r requirements.txt:
|
| 18 |
+
# CMAKE_ARGS="-DGGML_CUDA=on" pip install llama-cpp-python --no-cache-dir
|
| 19 |
+
# Windows (MSVC):
|
| 20 |
+
# set CMAKE_ARGS=-DGGML_CUDA=on && pip install llama-cpp-python --no-cache-dir
|
| 21 |
+
llama-cpp-python>=0.2.85 # plain CPU fallback β override with CUDA build above
|
| 22 |
+
|
| 23 |
+
# ββ VAD βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 24 |
+
silero-vad # pulls torch; also needs torchaudio for audio I/O
|
| 25 |
+
onnxruntime>=1.16.0 # ONNX runtime for Silero VAD
|
| 26 |
+
pyaudio # microphone access (Linux: apt install python3-pyaudio)
|
| 27 |
+
numpy>=1.24.0
|
| 28 |
+
|
| 29 |
+
# ββ Gradio UI βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 30 |
+
gradio>=4.40.0
|
| 31 |
+
|
| 32 |
+
# ββ Utilities βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
+
python-dotenv # optional: load HF_TOKEN from .env file
|
| 34 |
+
pydantic>=2.0 # JSON schema validation for Qwen output
|