| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import os |
| import time |
| import logging |
| import asyncio |
| import re |
| import threading |
| import httpx |
| from typing import List, Optional, Dict, Any |
|
|
| from client import groq, fireworks |
| from utils import detect_language_from_content, SUPPORTED_LANGUAGES |
| from language_config import normalize_language |
| from prompt_builder import build_system_prompt |
| from summary import generate_summary |
| from greeting_handler import ( |
| classify_opening_message, |
| get_short_reply, |
| should_short_circuit, |
| ) |
| from utils import ( |
| estimate_payload_tokens, |
| strip_formatting, |
| ) |
|
|
| logger = logging.getLogger("voice_pipeline") |
|
|
| TOKEN_THRESHOLD = 4000 |
| DURATION_THRESHOLD_MINUTES = 30 |
| MODEL_FAST = "llama-3.1-8b-instant" |
| MODEL_HEAVY = "llama-3.3-70b-versatile" |
| FALLBACK_MODEL_FIREWORKS = "accounts/fireworks/models/llama-v3p1-8b-instruct" |
|
|
| TTS_SPACE_URL = os.getenv("TTS_SPACE_URL", "https://voice-tts-tts.hf.space") |
| TTS_SYNTHESISE_TIMEOUT = 15.0 |
|
|
| |
| SENTENCE_END_RE = re.compile( |
| r'[^.!?\u06d4\u061f\u0964\n]+[.!?\u06d4\u061f\u0964]+(?:\s|$)' |
| r'|[^.!?\u06d4\u061f\u0964\n]*\n', |
| re.UNICODE, |
| ) |
|
|
| |
| |
| |
| |
| CLAUSE_END_RE = re.compile( |
| r'[^.!?,\u060c\u06d4\u061f\u0964\n]+[.!?,\u060c\u06d4\u061f\u0964]+(?:\s|$)' |
| r'|[^.!?,\u060c\u06d4\u061f\u0964\n]*\n', |
| re.UNICODE, |
| ) |
| MIN_FIRST_CLAUSE_CHARS = 15 |
|
|
| |
| |
| |
| |
| FOREIGN_SCRIPT_RE = re.compile( |
| r'[\u4E00-\u9FFF\u3400-\u4DBF' |
| r'\u3040-\u309F\u30A0-\u30FF' |
| r'\uAC00-\uD7A3' |
| r'\u0900-\u097F' |
| r'\u0400-\u04FF' |
| r'\u0E00-\u0E7F' |
| r']+' |
| ) |
|
|
|
|
| def strip_foreign_scripts(text: str) -> str: |
| """ |
| Issue 5: hard safety-net filter. Removes characters from scripts that |
| have no place in Urdu-only TTS output (Chinese, Japanese, Korean, |
| Hindi/Devanagari, Cyrillic, Thai) regardless of what the LLM produced. |
| Latin letters and digits are NOT touched — legitimate in Urdu call |
| speech (times, prices, acronyms) and handled by the TTS transliterator. |
| Logs a warning whenever it actually strips something, for visibility. |
| """ |
| if not text: |
| return text |
| cleaned = FOREIGN_SCRIPT_RE.sub('', text) |
| cleaned = re.sub(r'\s{2,}', ' ', cleaned).strip() |
| if cleaned != text.strip(): |
| logger.warning( |
| f"[Issue 5] Stripped foreign-script characters from LLM output: " |
| f"original={text[:80]!r} cleaned={cleaned[:80]!r}" |
| ) |
| return cleaned |
|
|
|
|
| def _extract_sentences(buffer: str) -> tuple[list[str], str]: |
| sentences: list[str] = [] |
| last_end = 0 |
| for m in SENTENCE_END_RE.finditer(buffer): |
| sentence = buffer[m.start():m.end()].strip() |
| if sentence: |
| sentences.append(sentence) |
| last_end = m.end() |
| return sentences, buffer[last_end:] |
|
|
|
|
| def _extract_first_clause(buffer: str) -> tuple[Optional[str], str]: |
| """ |
| Issue 4: for the FIRST chunk of a turn only. Finds the earliest |
| point where accumulated text reaches a clause boundary (comma or |
| sentence end) AND is at least MIN_FIRST_CLAUSE_CHARS long. Returns |
| (clause_text, remainder) or (None, buffer) if no qualifying |
| boundary has been reached yet. |
| """ |
| matches = list(CLAUSE_END_RE.finditer(buffer)) |
| if not matches: |
| return None, buffer |
|
|
| for m in matches: |
| candidate = buffer[0:m.end()].strip() |
| if len(candidate) >= MIN_FIRST_CLAUSE_CHARS: |
| return candidate, buffer[m.end():] |
|
|
| return None, buffer |
|
|
|
|
| async def fire_tts(sentence: str, language: str, gender: str) -> tuple[Optional[bytes], str]: |
| """Public alias — used by voice_orchestrator for bridge phrase synthesis.""" |
| return await _fire_tts(sentence, language, gender) |
|
|
|
|
| async def _fire_tts(sentence: str, language: str, gender: str) -> tuple[Optional[bytes], str]: |
| """ |
| v2 (Urdu-only mode): tts_lang is always the session's language — |
| no per-sentence content-based re-detection. |
| |
| v4 (Issue 5): sentence is passed through strip_foreign_scripts() |
| before being sent to TTS — a hard safety net regardless of what |
| upstream prompt/model behaviour produced. |
| """ |
| sentence = sentence.strip() |
| if not sentence: |
| return None, normalize_language(language) |
|
|
| |
| sentence = strip_foreign_scripts(sentence) |
| if not sentence: |
| |
| |
| return None, normalize_language(language) |
|
|
| tts_lang = normalize_language(language) |
|
|
| |
| |
|
|
| try: |
| async with httpx.AsyncClient(timeout=TTS_SYNTHESISE_TIMEOUT) as client: |
| resp = await client.post( |
| f"{TTS_SPACE_URL}/synthesise", |
| json={"text": sentence, "language": tts_lang, "gender": gender}, |
| ) |
| if resp.status_code == 200: |
| logger.info(f"TTS ✓ lang={tts_lang} chars={len(sentence)}") |
| return resp.content, tts_lang |
| logger.warning(f"TTS Space returned HTTP {resp.status_code}: {resp.text[:120]}") |
| return None, tts_lang |
| except httpx.TimeoutException: |
| logger.error(f"TTS request timed out ({TTS_SYNTHESISE_TIMEOUT}s): {sentence[:60]!r}") |
| return None, tts_lang |
| except Exception as exc: |
| logger.error(f"TTS request error: {exc}") |
| return None, tts_lang |
|
|
|
|
| def _start_groq_stream_thread( |
| params: dict, |
| q: asyncio.Queue, |
| loop: asyncio.AbstractEventLoop, |
| cancel_event: threading.Event, |
| ) -> None: |
| def _run() -> None: |
| try: |
| stream = groq.chat.completions.create(**{**params, "stream": True}) |
| for chunk in stream: |
| if cancel_event.is_set(): |
| return |
| delta = chunk.choices[0].delta |
| if delta and delta.content: |
| loop.call_soon_threadsafe(q.put_nowait, ("token", delta.content)) |
| loop.call_soon_threadsafe(q.put_nowait, ("done", None)) |
| except Exception as exc: |
| if not cancel_event.is_set(): |
| loop.call_soon_threadsafe(q.put_nowait, ("error", str(exc))) |
|
|
| threading.Thread(target=_run, daemon=True, name="groq-stream-worker").start() |
|
|
|
|
| def _select_primary_model(session, detected_language: str) -> str: |
| ld = session.language_detector |
| if detected_language == "ur" and (ld.is_locked() or ld.confidence >= 0.70): |
| return MODEL_HEAVY |
| return MODEL_FAST |
|
|
|
|
| def _fields_spec_dicts(session) -> List[Dict[str, Any]]: |
| return list(session.caller_collection_fields or []) |
|
|
|
|
| async def _roll_summary(session, current_summary: str) -> str: |
| if len(session.messages) <= 6: |
| return current_summary or "" |
| messages_to_summarize = session.messages[:-6] |
| session.messages = session.messages[-6:] |
| return await generate_summary( |
| current_summary or "", |
| messages_to_summarize, |
| caller_info=session.caller_info.to_dict(), |
| caller_fields_spec=_fields_spec_dicts(session), |
| ) |
|
|
|
|
| async def _query_rag(session, message: str) -> str: |
| if not getattr(session, "rag_enabled", False): |
| return "" |
| company_id = getattr(session, "company_id", None) |
| if not company_id: |
| return "" |
|
|
| agent_config_id = getattr(session, "agent_config_id", None) |
| rag_tenant_id = f"{company_id}_a{agent_config_id}" if agent_config_id else company_id |
|
|
| RAG_SPACE_URL = os.getenv("RAG_SPACE_URL", "") |
| RAG_API_KEY = os.getenv("RAG_API_KEY", "") |
| if not RAG_SPACE_URL: |
| logger.warning("[RAG] RAG_SPACE_URL not set — skipping live RAG query.") |
| return "" |
|
|
| try: |
| async with httpx.AsyncClient(timeout=3.0) as client: |
| tenant_ids = [rag_tenant_id] |
| if agent_config_id and rag_tenant_id != company_id: |
| tenant_ids.append(company_id) |
|
|
| for tenant in tenant_ids: |
| resp = await client.post( |
| f"{RAG_SPACE_URL}/api/v1/query", |
| json={"company_id": tenant, "query": message, "top_k": 5}, |
| headers={"X-RAG-API-Key": RAG_API_KEY}, |
| ) |
|
|
| if resp.status_code != 200: |
| logger.warning(f"[RAG] Query returned HTTP {resp.status_code} for tenant={tenant}") |
| continue |
|
|
| rag_data = resp.json() |
| if rag_data.get("needs_rag") and rag_data.get("context"): |
| logger.info( |
| f"[RAG] ✅ Context retrieved for tenant={tenant} " |
| f"mode={rag_data.get('search_mode')} " |
| f"chunks={len(rag_data.get('chunks', []))}" |
| ) |
| return rag_data["context"] |
|
|
| logger.info(f"[RAG] Skipped for tenant={tenant}: needs_rag={rag_data.get('needs_rag')}") |
|
|
| return "" |
|
|
| except httpx.TimeoutException: |
| logger.warning(f"[RAG] Query timed out (3s) for tenant={rag_tenant_id} — continuing without context") |
| return "" |
| except Exception as rag_err: |
| logger.warning(f"[RAG] Query failed (non-fatal) for tenant={rag_tenant_id}: {rag_err}") |
| return "" |
|
|
|
|
| def _build_prompt(session, detected_language: str, input_text: str) -> str: |
| greeting_phase = ( |
| session.turn_count <= 3 |
| and not session.language_detector.is_locked() |
| and session.greeting_streak > 0 |
| ) |
| return build_system_prompt( |
| company_name=session.company_name, |
| agent_name=session.agent_name, |
| agent_gender=session.agent_gender, |
| language=detected_language, |
| document_context=session.document_context, |
| custom_rules=session.custom_rules, |
| collected_caller_info=session.caller_info.to_dict(), |
| input_text=input_text, |
| |
| caller_collection_fields=_fields_spec_dicts(session), |
| turn_count=session.turn_count, |
| greeting_phase=greeting_phase, |
| company_name_ur=getattr(session, "company_name_ur", None), |
| agent_name_ur=getattr(session, "agent_name_ur", None), |
| agent_role=getattr(session, "agent_role", None), |
| agent_role_ur=getattr(session, "agent_role_ur", None), |
| overview_ur=getattr(session, "overview_ur", None), |
| ) |
|
|
|
|
| async def run_turn( |
| session, |
| message: str, |
| *, |
| cancel_event: threading.Event, |
| on_token, |
| on_audio, |
| on_done, |
| agent_gender: str = "male", |
| current_summary: str = "", |
| session_start: Optional[int] = None |
| ) -> None: |
| start_timestamp = time.time() |
|
|
| detected_language = normalize_language(session.process_user_message(message)) |
| session.add_message("user", message) |
|
|
| opening_tier = classify_opening_message( |
| message, |
| session.turn_count, |
| session.language_detector.is_locked(), |
| ) |
| short_circuit = should_short_circuit(opening_tier) |
| if short_circuit: |
| session.greeting_streak += 1 |
| else: |
| session.greeting_streak = 0 |
|
|
| new_summary, rag_context = await asyncio.gather( |
| _roll_summary(session, current_summary or ""), |
| _query_rag(session, message), |
| ) |
|
|
| if rag_context: |
| session.document_context = rag_context |
|
|
| system_prompt = _build_prompt(session, detected_language, message) |
|
|
| optimized_payload = [{"role": "system", "content": system_prompt}] |
| if new_summary: |
| optimized_payload.append({ |
| "role": "system", |
| "content": f"Summary of earlier conversation context for your memory bank: {new_summary}", |
| }) |
| optimized_payload.extend(session.get_history_for_api()) |
|
|
| raw_session_start = session_start or 0 |
| if 0 < raw_session_start < 1e10: |
| raw_session_start *= 1000 |
| duration_minutes = max(0.0, (time.time() * 1000 - raw_session_start) / 60000) if raw_session_start else 0 |
| estimated_tokens = estimate_payload_tokens(optimized_payload) |
| max_response_tokens: Optional[int] = None |
|
|
| if duration_minutes >= DURATION_THRESHOLD_MINUTES or estimated_tokens > TOKEN_THRESHOLD: |
| logger.warning( |
| f"[pipeline] Token Governor active. " |
| f"Tokens={estimated_tokens}, Duration={duration_minutes:.1f}m" |
| ) |
| cleaned_history = [ |
| {"role": m["role"], "content": strip_formatting(m["content"])} |
| for m in session.get_history_for_api() |
| ] |
| concise_control = { |
| "role": "system", |
| "content": "Token Governor Active: Reply ultra-concisely. No markdown, no filler. 1-2 short sentences max.", |
| } |
| short_sys = [ |
| m for m in optimized_payload |
| if m.get("role") == "system" and len(m.get("content", "")) < 400 |
| ] |
| optimized_payload = [concise_control] + short_sys |
| if new_summary: |
| optimized_payload.append({ |
| "role": "system", |
| "content": f"Summary of earlier conversation context for your memory bank: {new_summary}", |
| }) |
| optimized_payload.extend(cleaned_history) |
| max_response_tokens = 120 |
|
|
| primary_model = _select_primary_model(session, detected_language) |
| inference_params: Dict[str, Any] = { |
| "model": primary_model, |
| "messages": optimized_payload, |
| "temperature": 0.4, |
| "top_p": 0.95, |
| } |
| if max_response_tokens: |
| inference_params["max_tokens"] = max_response_tokens |
|
|
| tts_gender = agent_gender if agent_gender in ("female", "male") else "female" |
|
|
| loop = asyncio.get_running_loop() |
| q: asyncio.Queue = asyncio.Queue() |
| full_text_parts: list[str] = [] |
| buffer = "" |
| tts_tasks: List[asyncio.Task] = [] |
| |
| |
| first_chunk_dispatched = False |
|
|
| async def _synthesise_and_emit(sentence: str): |
| if cancel_event.is_set(): |
| return |
| audio_bytes, tts_lang = await _fire_tts(sentence, detected_language, tts_gender) |
| if audio_bytes and not cancel_event.is_set(): |
| if asyncio.iscoroutinefunction(on_audio): |
| await on_audio(sentence, audio_bytes, tts_lang) |
| else: |
| on_audio(sentence, audio_bytes, tts_lang) |
|
|
| try: |
| if short_circuit: |
| short_reply = get_short_reply(opening_tier, message, session.greeting_streak) |
| full_text_parts.append(short_reply) |
|
|
| if cancel_event.is_set(): |
| return |
|
|
| if asyncio.iscoroutinefunction(on_token): |
| await on_token(short_reply) |
| else: |
| on_token(short_reply) |
|
|
| task = asyncio.create_task(_synthesise_and_emit(short_reply)) |
| tts_tasks.append(task) |
| await task |
| else: |
| _start_groq_stream_thread(inference_params, q, loop, cancel_event) |
|
|
| while not cancel_event.is_set(): |
| event_type, data = await q.get() |
|
|
| if cancel_event.is_set(): |
| break |
|
|
| if event_type == "error": |
| if fireworks is not None: |
| logger.warning("[pipeline] Groq error -> Fireworks fallback") |
| try: |
| fb_params = {**inference_params, "model": FALLBACK_MODEL_FIREWORKS} |
| fb_resp = await loop.run_in_executor( |
| None, |
| lambda: fireworks.chat.completions.create(**fb_params), |
| ) |
| if cancel_event.is_set(): |
| return |
| fallback_text = fb_resp.choices[0].message.content.strip() |
| full_text_parts.append(fallback_text) |
|
|
| if asyncio.iscoroutinefunction(on_token): |
| await on_token(fallback_text) |
| else: |
| on_token(fallback_text) |
|
|
| fb_sentences, _ = _extract_sentences(fallback_text + " ") |
| for sent in fb_sentences: |
| if cancel_event.is_set(): |
| break |
| task = asyncio.create_task(_synthesise_and_emit(sent)) |
| tts_tasks.append(task) |
|
|
| if tts_tasks: |
| await asyncio.gather(*tts_tasks, return_exceptions=True) |
| except Exception as fb_exc: |
| logger.error(f"[pipeline] Fallback failed: {fb_exc}") |
| raise fb_exc |
| else: |
| raise Exception(data) |
| break |
|
|
| elif event_type == "done": |
| remainder = buffer.strip() |
| if remainder and not cancel_event.is_set(): |
| full_text_parts.append(remainder) |
| if asyncio.iscoroutinefunction(on_token): |
| await on_token(remainder) |
| else: |
| on_token(remainder) |
| task = asyncio.create_task(_synthesise_and_emit(remainder)) |
| tts_tasks.append(task) |
| first_chunk_dispatched = True |
|
|
| if tts_tasks: |
| await asyncio.gather(*tts_tasks, return_exceptions=True) |
| break |
|
|
| else: |
| token: str = data |
| full_text_parts.append(token) |
| if asyncio.iscoroutinefunction(on_token): |
| await on_token(token) |
| else: |
| on_token(token) |
| buffer += token |
|
|
| |
| |
| |
| |
| if not first_chunk_dispatched: |
| clause, buffer = _extract_first_clause(buffer) |
| if clause: |
| task = asyncio.create_task(_synthesise_and_emit(clause)) |
| tts_tasks.append(task) |
| first_chunk_dispatched = True |
| else: |
| completed_sentences, buffer = _extract_sentences(buffer) |
| for sentence in completed_sentences: |
| if cancel_event.is_set(): |
| break |
| task = asyncio.create_task(_synthesise_and_emit(sentence)) |
| tts_tasks.append(task) |
|
|
| except asyncio.CancelledError: |
| logger.warning(f"[pipeline] asyncio.CancelledError caught. Setting cancel flag.") |
| cancel_event.set() |
| finally: |
| for task in tts_tasks: |
| if not task.done(): |
| task.cancel() |
|
|
| if not cancel_event.is_set() or len(full_text_parts) > 0: |
| full_text = "".join(full_text_parts).strip() |
| session.add_message("assistant", full_text) |
|
|
| elapsed_ms = int((time.time() - start_timestamp) * 1000) |
| if not cancel_event.is_set(): |
| if asyncio.iscoroutinefunction(on_done): |
| await on_done(full_text, detected_language, new_summary, elapsed_ms) |
| else: |
| on_done(full_text, detected_language, new_summary, elapsed_ms) |