prianshujha commited on
Commit
9a02b57
Β·
0 Parent(s):

added pipeline file

Browse files
__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