""" audit_engine.py — Qualora RAG-Augmented LLM Scoring Engine =========================================================== Orchestrates the LLM inference pipeline, enforcing strict JSON schemas and executing the Multi-Tier Fallback Cascade for high availability. Architecture: 1. retrieve_policy_context() → ChromaDB / MongoDB Vector Search → policy chunks 2. _fetch_agent_history() → MongoDB db.audits → last 5 scores for context 3. _build_prompt() → structured prompt with all context layers 4. _run_llm_cascade() → Gemini → OpenRouter → Groq → HF Inference 5. _parse_llm_response() → validate & coerce via centralized JSON repair Output schema (stable contract for frontend & MongoDB): { summary: str, agent_f1_score: float [0.0–1.0], satisfaction_prediction: str ["High"|"Medium"|"Low"], compliance_risk: str ["Green"|"Amber"|"Red"], quality_matrix: { language_proficiency: int [0–10], cognitive_empathy: int [0–10], efficiency: int [0–10], bias_reduction: int [0–10], active_listening: int [0–10], }, compliance_flags: list[str], behavioral_nudges: list[str], emotions: { agent: str, customer: str, }, _audit_metadata: { rag_provider: str, llm_provider: str, llm_model: str, policy_chunks: int, history_audits: int, tier: str, latency_ms: int, } } """ import os import json import logging import time import hashlib from datetime import datetime, timezone import copy import re as _re from bson import ObjectId from bson.errors import InvalidId import httpx # Optional provider SDKs: import lazily to avoid import-time crashes when # environment lacks heavy vendor libraries. Use factory functions below # to initialize clients only when needed. from core import ( GROQ_API_KEY, OPENROUTER_API_KEY, HF_SPACE_TOKEN, HF_SPACE_URL, ELEVENLABS_API_KEY, DEEPGRAM_API_KEY, get_db, repair_json, RAG_NULL_SENTINEL ) from services.rag import retrieve_policy_context log = logging.getLogger(__name__) class LLMRateLimitError(RuntimeError): """Raised when a provider signals it is rate-limited so the cascade should skip this provider and move to the next tier immediately.""" # Optional Semantic Emotion Inference (sentence-transformers) _ST_AVAILABLE = False _st_model = None try: from sentence_transformers import SentenceTransformer, util as st_util _ST_AVAILABLE = True except Exception: SentenceTransformer = None st_util = None _ST_AVAILABLE = False log.warning("sentence_transformers not installed — falling back to keyword heuristics for emotion inference.") # Caching for prototype embeddings (lazy init) _st_proto_emb = None _st_proto_map = None _st_proto_texts = None _st_prototypes_initialized = False # ───────────────────────────────────────────────────────────────────────────── # Transcription Cascade # ───────────────────────────────────────────────────────────────────────────── try: from gradio_client import Client as GradioClient, handle_file as gradio_handle_file GRADIO_CLIENT_AVAILABLE = True except ImportError: GRADIO_CLIENT_AVAILABLE = False log.warning("gradio_client not installed.") try: from deepgram import DeepgramClient DEEPGRAM_AVAILABLE = True except ImportError: DeepgramClient = None DEEPGRAM_AVAILABLE = False log.warning("deepgram-sdk not installed.") # Groq / ElevenLabs lazy initialization try: from groq import Groq GROQ_AVAILABLE = True except Exception: Groq = None GROQ_AVAILABLE = False log.warning("groq SDK not installed or unavailable.") try: from elevenlabs.client import ElevenLabs ELEVENLABS_AVAILABLE = True except Exception: ElevenLabs = None ELEVENLABS_AVAILABLE = False log.warning("elevenlabs SDK not installed or unavailable.") def get_elevenlabs_client(): if not ELEVENLABS_AVAILABLE or not ELEVENLABS_API_KEY: return None try: return ElevenLabs(api_key=ELEVENLABS_API_KEY) except Exception as e: log.warning("Failed to initialize ElevenLabs client: %s", e) return None def get_groq_client(): if not GROQ_AVAILABLE or not GROQ_API_KEY: return None try: return Groq(api_key=GROQ_API_KEY) except Exception as e: log.warning("Failed to initialize Groq client: %s", e) return None def get_deepgram_client(): if not DEEPGRAM_AVAILABLE or not DEEPGRAM_API_KEY: return None try: return DeepgramClient(api_key=DEEPGRAM_API_KEY) except Exception as e: log.warning("Failed to initialize Deepgram client: %s", e) return None # Deterministic Persistence Cache for identical inputs _AUDIT_CACHE = {} _MAX_AUDIT_CACHE = int(os.environ.get("MAX_AUDIT_CACHE", "1024")) _PERSIST_AUDIT_CACHE = os.environ.get("PERSIST_AUDIT_CACHE", "1") != "0" _AUDIT_CACHE_COLLECTION = os.environ.get("AUDIT_CACHE_COLLECTION", "audit_cache") _AUDIT_CACHE_TTL = int(os.environ.get("AUDIT_CACHE_TTL_SECONDS", "0")) _CACHE_INDEXED = False _LLM_TEMPERATURE = float(os.environ.get("AUDIT_LLM_TEMPERATURE", "0.0")) def _get_cache_collection(): """Return the MongoDB collection used for persisting audit cache or None. Ensures indexes are created once per process. """ global _CACHE_INDEXED if not _PERSIST_AUDIT_CACHE: return None try: db = get_db() if db is None: return None coll = db[_AUDIT_CACHE_COLLECTION] if not _CACHE_INDEXED: try: coll.create_index([("cache_key", 1)], unique=True) if _AUDIT_CACHE_TTL > 0: coll.create_index([("created_at", 1)], expireAfterSeconds=_AUDIT_CACHE_TTL) except Exception as e: log.warning("Failed to ensure audit cache indexes: %s", e) _CACHE_INDEXED = True return coll except Exception as e: log.warning("Failed to get DB collection for audit cache: %s", e) return None def _canonicalize_transcript_for_cache(transcript: str) -> str: """Normalize transcript text so trivial formatting differences do not cause cache misses (e.g., CRLF vs LF, extra spaces, trailing whitespace). """ if transcript is None: return "" txt = str(transcript).replace("\r\n", "\n").replace("\r", "\n") lines = [] for ln in txt.split("\n"): # Collapse internal runs of spaces/tabs while preserving line breaks. lines.append(" ".join(ln.split())) # Remove duplicate blank lines and trim edges. out = [] prev_blank = False for ln in lines: is_blank = (ln == "") if is_blank and prev_blank: continue out.append(ln) prev_blank = is_blank return "\n".join(out).strip() def _policy_cache_fingerprint(org_id: str) -> str: """Return a lightweight fingerprint of KB state for cache invalidation. This prevents stale audits (computed before KB updates) from being reused after documents/chunks change for the same org. """ try: db = get_db() if db is None: return "kb:none" safe_org = None try: safe_org = ObjectId(str(org_id)) except (InvalidId, TypeError, ValueError): safe_org = None org_filter = {"org_id": safe_org} if safe_org is not None else {"org_id": org_id} docs_coll = getattr(db, "kb_docs", None) chunks_coll = getattr(db, "kb_chunks", None) doc_count = docs_coll.count_documents(org_filter) if docs_coll is not None else 0 chunk_count = chunks_coll.count_documents(org_filter) if chunks_coll is not None else 0 newest_doc = None if docs_coll is not None: newest_doc = docs_coll.find_one(org_filter, {"updated_at": 1, "created_at": 1}, sort=[("updated_at", -1), ("created_at", -1)]) newest_chunk = None if chunks_coll is not None: newest_chunk = chunks_coll.find_one(org_filter, {"created_at": 1, "doc_id": 1}, sort=[("created_at", -1)]) doc_ts = "" if newest_doc: ts = newest_doc.get("updated_at") or newest_doc.get("created_at") if ts is not None: doc_ts = ts.isoformat() if hasattr(ts, "isoformat") else str(ts) chunk_marker = "" if newest_chunk: did = newest_chunk.get("doc_id") cts = newest_chunk.get("created_at") chunk_marker = f"{did}:{cts.isoformat() if hasattr(cts, 'isoformat') else cts}" return f"kb:d{doc_count}:c{chunk_count}:dt{doc_ts}:cm{chunk_marker}" except Exception as e: log.warning("Failed to build policy fingerprint for cache key: %s", e) return "kb:error" def _build_acoustic_context(speaker_profiles: dict) -> str: """ Converts acoustic profiles (pitch, intensity, emotion) into a plain-English preamble. Grounds LLM analysis in real physical signal evidence (pyannote/SpeechBrain). """ # Accept either a plain speaker_profiles mapping OR the acoustic_profile # wrapper used by the caller (e.g. { 'speaker_profiles': {...}, 'turns': [...] }). if not speaker_profiles: return "" # Unwrap wrapper if present sp = speaker_profiles.get('speaker_profiles') if isinstance(speaker_profiles, dict) and 'speaker_profiles' in speaker_profiles else speaker_profiles # Normalize list-form profiles into a dict {Speaker N: profile} if isinstance(sp, list): norm = {} for i, item in enumerate(sp): if isinstance(item, dict): key = item.get('speaker') or item.get('speaker_id') or item.get('spk') or f"Speaker {i+1}" norm[str(key)] = item else: norm[f"Speaker {i+1}"] = {} sp = norm if not isinstance(sp, dict): return "" lines = ["\n\n[ACOUSTIC SIGNAL ANALYSIS — from pyannote 3.1 + parselmouth + SpeechBrain wav2vec2]"] for spk, profile in sp.items(): # Defensive: ensure profile is a dict-like object if not isinstance(profile, dict): try: profile = dict(profile) except Exception: profile = {} pitch = profile.get("avg_pitch_hz") intens = profile.get("avg_intensity_db") emo = profile.get("dominant_emotion", "unknown") turns = profile.get("turn_count", 0) # Male baseline ~120 Hz, Female baseline ~210 Hz; elevated pitch → stress/anxiety pitch_note = "" if pitch: if pitch > 260: pitch_note = " (elevated — stress signal)" elif pitch < 100: pitch_note = " (depressed — fatigue signal)" line = f" {spk}: avg_pitch={pitch:.0f}Hz{pitch_note}" if pitch else f" {spk}:" if intens: line += f", avg_intensity={intens:.1f}dB" line += f", dominant_acoustic_emotion={emo}, turn_count={turns}" lines.append(line) lines.append("Use these acoustic signals to enrich scoring — especially cognitive_empathy " "and compliance_risk. Acoustic evidence is ground-truth, not NLP inferred.") return "\n".join(lines) def transcribe_via_hf_space(audio_path: str) -> dict: if not GRADIO_CLIENT_AVAILABLE: raise RuntimeError("gradio_client package not installed.") if not HF_SPACE_URL: raise RuntimeError("HF_SPACE_URL env var not set.") if not os.path.exists(audio_path): raise FileNotFoundError(f"Audio file not found: {audio_path}") log.info("--- [HF Space T1] Connecting to Qualora ASR node ---") token = HF_SPACE_TOKEN if HF_SPACE_TOKEN else None # gradio-client API changed from `token=` to `hf_token=` in newer releases. # Keep both paths for backward/forward compatibility. try: if token: client = GradioClient(HF_SPACE_URL, hf_token=token) else: client = GradioClient(HF_SPACE_URL) except TypeError: client = GradioClient(HF_SPACE_URL, token=token) if token else GradioClient(HF_SPACE_URL) raw = client.predict(gradio_handle_file(audio_path), api_name="/predict") if isinstance(raw, dict): data = raw elif isinstance(raw, str): try: data = json.loads(raw) except json.JSONDecodeError: return {"transcript": raw.strip(), "speaker_profiles": {}, "turns": []} else: data = {"transcript": str(raw), "speaker_profiles": {}, "turns": []} if "error" in data: raise RuntimeError(f"HF Space returned error: {data['error']}") transcript = str(data.get("transcript", "")).strip() if not transcript: raise RuntimeError("HF Space returned empty transcript.") log.info("✅ [HF Space T1] Done.") return { "transcript": transcript, "speaker_profiles": data.get("speaker_profiles", {}), "turns": data.get("turns", []), } def _elevenlabs_transcribe(audio_path: str) -> str: client = get_elevenlabs_client() if not client: raise RuntimeError("ElevenLabs client not configured or SDK missing.") with open(audio_path, "rb") as f: response = client.speech_to_text.convert( file=f, model_id="scribe_v1", diarize=True, language_code="en", timestamps_granularity="word" ) lines = [] if hasattr(response, 'words') and response.words: current_speaker = None current_text = [] for w in response.words: spk = getattr(w, 'speaker_id', None) txt = getattr(w, 'text', '') or getattr(w, 'punctuated_word', '') if not txt.strip(): continue if spk != current_speaker: if current_speaker is not None and current_text: label = str(current_speaker).replace('speaker_', 'Speaker ').strip() lines.append(f"{label}: {' '.join(current_text).strip()}") current_speaker = spk current_text = [txt.strip()] else: current_text.append(txt.strip()) if current_speaker is not None and current_text: label = str(current_speaker).replace('speaker_', 'Speaker ').strip() lines.append(f"{label}: {' '.join(current_text).strip()}") if lines: return "\n\n".join(lines) if hasattr(response, 'text') and response.text: return response.text return str(response) def _deepgram_transcribe(audio_path: str) -> str: client = get_deepgram_client() if not client: raise RuntimeError("Deepgram client not configured or SDK missing.") with open(audio_path, "rb") as f: buf = f.read() payload = {"buffer": buf} options = { "model": "nova-2", "smart_format": True, "diarize": True, "punctuate": True, "utterances": True, "language": "en" } try: response = client.listen.rest.v("1").transcribe_file(payload, options) resp_dict = response if isinstance(response, dict) else (response.to_dict() if hasattr(response, 'to_dict') else {}) if "results" in resp_dict and "utterances" in resp_dict["results"]: lines = [f"Speaker {u['speaker']}: {u['transcript']}" for u in resp_dict["results"]["utterances"]] if lines: return "\n\n".join(lines) if "results" in resp_dict and "channels" in resp_dict["results"]: alt = resp_dict["results"]["channels"][0]["alternatives"][0] if "transcript" in alt: return alt["transcript"] except Exception as e: raise RuntimeError(f"Deepgram transcription failed: {str(e)}") raise RuntimeError("Deepgram response contained no parseable transcript.") def _groq_transcribe(audio_path: str) -> str: client = get_groq_client() if not client: raise RuntimeError("Groq client not configured or SDK missing.") models = [("whisper-large-v3", "T1"), ("whisper-large-v3-turbo", "T2")] last_err = None for model, tier in models: try: with open(audio_path, "rb") as f: transcription = client.audio.transcriptions.create( file=(os.path.basename(audio_path), f.read()), model=model, response_format="verbose_json", language="en", temperature=0.0 ) if hasattr(transcription, 'text') and transcription.text and transcription.text.strip(): log.info(f"✅ [Groq Whisper] [{tier}] {model} transcribed successfully.") return transcription.text else: log.warning(f"⚠️ [{tier}] {model} returned empty transcript.") last_err = RuntimeError(f"{model} returned empty transcript") except Exception as e: log.warning(f"⚠️ [{tier}] {model} failed: {e}") last_err = e raise RuntimeError(f"All Groq Whisper models failed. Last error: {last_err}") def perform_voice_capture_apis(audio_path: str) -> tuple: # Try ElevenLabs -> Deepgram -> Groq in order, using lazy factories try: log.info("--- [API Chain T2a] ElevenLabs Scribe (if available) ---") return _elevenlabs_transcribe(audio_path), "ElevenLabs Scribe" except Exception as e: log.warning(f"⚠️ [ElevenLabs] {e}") try: log.info("--- [API Chain T2b] Deepgram Nova-2 (if available) ---") return _deepgram_transcribe(audio_path), "Deepgram Nova-2" except Exception as e: log.warning(f"⚠️ [Deepgram] {e}") try: log.info("--- [API Chain T2c] Groq Whisper-large-v3 (if available) ---") return _groq_transcribe(audio_path), "Groq Whisper-large-v3" except Exception as e: log.warning(f"⚠️ [Groq] {e}") raise RuntimeError("All configured API-chain transcription providers failed.") def _get_fallbacks_available() -> list: out = [] if ELEVENLABS_API_KEY: out.append("elevenlabs") if DEEPGRAM_API_KEY: out.append("deepgram") if GROQ_API_KEY: out.append("groq") return out # ───────────────────────────────────────────────────────────────────────────── # Structured Prompt Template # ───────────────────────────────────────────────────────────────────────────── _SYSTEM_PROMPT = """You are Qualora, a Strict Quality Auditor AI acting as a Legal Judge. Your absolute and only source of truth is the provided [POLICY CONTEXT] (Input A). SPEAKER ROLE INFERENCE: Analyze the [INTERACTION TRANSCRIPT] to resolve generic labels (e.g., SPEAKER_00) to Agent or Customer. - AGENT side: initiates greetings, offers solutions, references policies, stays professional. - CUSTOMER side: describes a problem, asks for help, expressing a need for service. Once identified, apply consistently for all turns. SECURITY MANDATE: 1. Meticulously scrub the transcript for violations matching constraints of Input A. 2. YOU MUST strictly adhere to Input A (the provided policies). Do not invent or hallucinate corporate rules outside of what is provided in Input A. 3. For every compliance flag, cite the specific clause from Input A evaluating it. Treat Input B strictly as Evidence. 4. Return ONLY valid JSON matching the exact requested schema. No markdown formatting. """ _USER_PROMPT_TEMPLATE = """## AUDIT TASK Evaluate the following customer support interaction transcript and return a quality audit in JSON. --- ### POLICY CONTEXT (from KB) {policy_context} --- ### AGENT HISTORICAL PERFORMANCE (last {history_count} audits) {agent_history} --- {acoustic_context} --- ### INTERACTION TRANSCRIPT {transcript} --- ### INSTRUCTIONS Score the agent objectively. Use the policy context to flag compliance violations. Use agent history for calibration only — do not let it inflate scores. Return ONLY this JSON schema (no other text): {{ "summary": "<2-3 sentence interaction summary>", "agent_f1_score": , "satisfaction_prediction": "", "compliance_risk": "", "quality_matrix": {{ "language_proficiency": , "cognitive_empathy": , "efficiency": , "bias_reduction": , "active_listening": }}, "compliance_flags": ["", ""], "behavioral_nudges": ["", "", ""], "emotions": {{ "agent": "", "customer": "", "timeline": [{{ "turn": , "speaker": "", "emotion": "