"""
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": "