| """Analysis service — manages the full analysis pipeline.""" |
|
|
| from __future__ import annotations |
|
|
| import csv |
| import json |
| import os |
| import re |
| import shutil |
| import threading |
| import time |
| import uuid |
| from datetime import datetime, timezone |
| from pathlib import Path |
| from typing import Any |
|
|
| from src.aspect_sentiment.diarization import DiarizationResult, diarize_text |
| from src.aspect_sentiment.conversation_reconstruction import reconstruct_conversation |
| from src.aspect_sentiment.follow_up_alerts import ( |
| detect_follow_up_alerts, |
| save_follow_up_alerts, |
| ) |
| from src.aspect_sentiment.sentiment_timeline import compute_sentiment_timeline |
| from src.aspect_sentiment.analytics_engine import compute_conversation_analytics |
| from src.aspect_sentiment.llama_extraction import ( |
| derive_features, |
| detect_conversation_stages, |
| get_sentiment, |
| process_text, |
| rule_based_features, |
| summarize_conversation, |
| ) |
| from src.aspect_sentiment.privacy import ( |
| COMPANY_RX, |
| CUSTOMER_PHONE_RX, |
| EMAIL_RX, |
| PHONE_RX, |
| PrivacyResult, |
| extract_and_redact_pii, |
| ) |
| from src.aspect_sentiment.schemas import PipelineStage |
| from src.nexus_ai.core.paths import AUDIO_UPLOADS_DIR, ensure_runtime_dirs |
| from src.nexus_ai.repositories.sqlite import ConversationRepository |
| from src.ml.predictor import ( |
| CONVERSION_MODEL_PATH, |
| MODEL_FEATURES_PATH, |
| get_model_features, |
| load_model_metrics, |
| predict_with_trained_model, |
| ) |
|
|
|
|
| REPO_ROOT = Path(__file__).resolve().parents[2] |
| TRANSCRIPT_CSV_PATHS = [ |
| REPO_ROOT / "data" / "raw" / "transcripts.csv", |
| ] |
| TRANSCRIPT_CSV_FIELDS = [ |
| "file_name", "text", "language", "duration_s", "timestamp", |
| "products", "brands", "budget", "features", "intent", |
| "decision_stage", "use_case", "objections", "sentiment", |
| "confidence_score", "hesitation_score", "delay_flag", |
| "conversion_label", "conversion_probability", "conversion_prediction", |
| "model_accuracy", "model_precision", "model_recall", "model_f1", |
| "xgboost_base_probability", "intent_score", "behavioral_score", |
| "emotion_score", "engagement_score", "extraction_provider", |
| "pii_redaction_count", "raw_features_json", |
| ] |
| TRANSCRIPT_CSV_LOCK = threading.Lock() |
| CONVERSATION_REPOSITORY = ConversationRepository() |
|
|
| MONEY_CONTEXT_RX = re.compile( |
| r"\b(?P<context>budget|salary|earning|income|pay|price|cost)\b" |
| r"[^.?!]{0,80}?" |
| r"(?P<currency>rs\.?|inr|₹)?\s*" |
| r"(?<![A-Za-z])(?P<amount>[0-9][0-9,]*(?:\.\d+)?)" |
| r"\s*(?P<suffix>k|lakh|lakhs)?" |
| r"(?![\d,])" |
| r"(?!\s*(?:gb|tb|ram|ssd|inch)\b)", |
| re.IGNORECASE, |
| ) |
| BUDGET_AMOUNT_RX = re.compile( |
| r"\b(?:my\s+budget(?:\s+is)?|budget\s+(?:is|of)|" |
| r"(?:hoping|want|need|trying)\s+to\s+(?:stay|keep\s+it)\s+(?:at|around|under|within)|" |
| r"(?:stay|keep\s+it)\s+(?:at|around|under|within))" |
| r"[^.?!]{0,30}?" |
| r"(?P<currency>rs\.?|inr|₹)?\s*" |
| r"(?<![A-Za-z])(?P<amount>[0-9][0-9,]*(?:\.\d+)?)" |
| r"\s*(?P<suffix>k|lakh|lakhs)?" |
| r"(?![\d,])" |
| r"(?!\s*(?:gb|tb|ram|ssd|inch)\b)", |
| re.IGNORECASE, |
| ) |
| SELF_INTRO_RX = re.compile( |
| r"\b(?i:my name is|i am|i'm|this is)\s+(?P<name>[A-Z][A-Za-z]*(?:\s+[A-Z][A-Za-z]*){0,2})(?=\b|[.,!?])" |
| ) |
| GREETING_RX = re.compile( |
| r"\b(?:hi|hello|good morning|good afternoon)\s+(?P<name>[A-Z][A-Za-z]+)\b", |
| re.IGNORECASE, |
| ) |
| OCCUPATION_RX = re.compile( |
| r"\b(?:i am|i'm|working as|work as)\s+(?:an?\s+)?" |
| r"(?P<job>teacher|student|engineer|doctor|developer|manager|salesperson|consultant|designer)\b", |
| re.IGNORECASE, |
| ) |
| PRODUCT_NAME_TERMS = { |
| "iphone", "samsung", "galaxy", "ultra", "pro", "max", "plus", |
| "s25", "s24", "laptop", "phone", "mobile", "tv", "ac", |
| "refrigerator", "washing", "machine", |
| } |
| NON_PERSON_NAME_TERMS = { |
| "interested", "looking", "earning", "teacher", "student", |
| "calling", "sure", "here", "product", "service", |
| "proposal", "quotation", "pricing", |
| } |
|
|
|
|
| class AnalysisService: |
| """Orchestrates the full analysis pipeline: diarization, PII, extraction, |
| sentiment, scoring, follow-up alerts, and persistence.""" |
|
|
| |
| |
| |
|
|
| @staticmethod |
| def bundled_ffmpeg_path() -> str | None: |
| for ffmpeg_dir in REPO_ROOT.glob("ffmpeg-*"): |
| candidate = ffmpeg_dir / "bin" / "ffmpeg.exe" |
| if candidate.exists(): |
| return str(candidate) |
| return shutil.which("ffmpeg") |
|
|
| @staticmethod |
| def get_transcriber() -> Any: |
| _transcriber: Any | None = None |
|
|
| def _inner() -> Any: |
| nonlocal _transcriber |
| if _transcriber is None: |
| if os.getenv("USE_GROQ_WHISPER", "true").lower() == "true": |
| from src.aspect_sentiment.groq_audio import GroqCloudTranscriber |
| _transcriber = GroqCloudTranscriber() |
| else: |
| from src.aspect_sentiment.audio import WhisperTranscriber |
| _transcriber = WhisperTranscriber() |
| return _transcriber |
|
|
| return _inner |
|
|
| @staticmethod |
| def completed_stage(id: str, title: str, detail: str) -> PipelineStage: |
| return PipelineStage(id=id, title=title, status="completed", detail=detail) |
|
|
| |
| |
| |
|
|
| @staticmethod |
| def fallback_extraction(text: str) -> dict[str, Any]: |
| features = rule_based_features(text) |
| sentiment = get_sentiment(text) |
| derived = derive_features(text, features) |
| return { |
| "raw_features": features, |
| "sentiment_score": sentiment, |
| **derived, |
| "extraction_provider": "local-fallback", |
| } |
|
|
| @staticmethod |
| def pii_payload( |
| privacy: PrivacyResult, |
| extra_entities: list[dict[str, Any]] | None = None, |
| ) -> dict[str, Any]: |
| grouped: dict[str, list[str]] = {} |
| unique_entities: list[dict[str, Any]] = [] |
| seen: set[tuple[str, str]] = set() |
|
|
| for entity in privacy.entities: |
| key = (entity.type, str(entity.value).lower().strip()) |
| if key not in seen: |
| seen.add(key) |
| unique_entities.append({ |
| "type": entity.type, |
| "value": entity.value, |
| "source": entity.source, |
| "start": entity.start, |
| "end": entity.end, |
| }) |
| grouped.setdefault(entity.type, []).append(entity.value) |
|
|
| for entity in extra_entities or []: |
| key = (entity["type"], str(entity["value"]).lower().strip()) |
| if key not in seen: |
| seen.add(key) |
| unique_entities.append({ |
| "type": entity["type"], |
| "value": entity["value"], |
| "source": entity.get("source", "local"), |
| "start": entity.get("start"), |
| "end": entity.get("end"), |
| }) |
| grouped.setdefault(entity["type"], []).append(entity["value"]) |
|
|
| |
| phone_values = [] |
| for alias in ("customer_number", "phone", "mobile", "contact_phone", "contact_number", "phone_number", "mobile_number"): |
| for v in grouped.get(alias, []): |
| if v and v not in phone_values: |
| phone_values.append(v) |
| if phone_values: |
| for alias in ("customer_number", "phone", "mobile", "contact_phone", "contact_number", "phone_number", "mobile_number"): |
| grouped[alias] = list(dict.fromkeys(grouped.get(alias, []) + phone_values)) |
|
|
| return { |
| "entities": unique_entities, |
| "grouped": grouped, |
| "redactionCount": privacy.redaction_count, |
| "provider": privacy.provider, |
| } |
|
|
| @staticmethod |
| def transcript_payload(diarization: DiarizationResult) -> list[dict[str, Any]]: |
| return [ |
| { |
| "speaker": turn.speaker, |
| "rawSpeaker": turn.raw_speaker, |
| "text": turn.text, |
| "start": turn.start, |
| "end": turn.end, |
| "confidence": turn.confidence, |
| "overlap": turn.overlap, |
| "warnings": turn.warnings, |
| } |
| for turn in diarization.turns |
| ] |
|
|
| @staticmethod |
| def summarize_customer_behavior( |
| customer_text: str, |
| extraction: dict[str, Any], |
| conversation_summary: dict[str, Any] | None = None, |
| ) -> dict[str, Any]: |
| raw_features = extraction.get("raw_features", []) |
| text_lower = customer_text.lower() |
| |
| |
| intent_signals = sum(1 for f in raw_features if f.get("label") == "INTENT") |
| buying_intent_terms = ["buy", "purchase", "want", "need", "looking for", "interested in"] |
| intent_signals += sum(1 for term in buying_intent_terms if term in text_lower) |
| |
| |
| hesitation_score = extraction.get("hesitation_score", 0) |
| if hesitation_score == 0: |
| hesitation_terms = ["maybe", "thinking", "not sure", "later", "wait", "consider", "think about it"] |
| hesitation_score = sum(1 for term in hesitation_terms if term in text_lower) |
| |
| |
| urgency_signals = sum(1 for f in raw_features if f.get("label") in ["URGENCY", "URGENCY_LEVEL"]) |
| urgency_terms = ["today", "tomorrow", "this week", "immediately", "urgent", "asap", "now"] |
| urgency_signals += sum(1 for term in urgency_terms if term in text_lower) |
|
|
| return { |
| "focus": "customer-only", |
| "intentSignals": intent_signals, |
| "hesitationScore": hesitation_score, |
| "urgencySignals": urgency_signals, |
| "objectionSignals": len( |
| [f for f in raw_features if f.get("label") == "OBJECTION"] |
| ), |
| "wordCount": len(customer_text.split()), |
| "privacySafe": True, |
| } |
|
|
| @staticmethod |
| def privacy_safe_csv_text(result: dict[str, Any], fallback_text: str) -> str: |
| turns = result.get("diarizedTranscript") |
| if isinstance(turns, list) and turns: |
| safe_turns: list[str] = [] |
| for turn in turns: |
| if not isinstance(turn, dict): |
| continue |
| speaker = str(turn.get("speaker") or "Speaker") |
| turn_text = str(turn.get("text") or "").strip() |
| if not turn_text: |
| continue |
| if speaker == "Customer": |
| turn_text = extract_and_redact_pii(turn_text).cleaned_text |
| safe_turns.append(f"{speaker}: {turn_text}") |
| if safe_turns: |
| return " ".join(safe_turns) |
| return str(result.get("customerBehavioralTranscript") or result.get("transcript") or fallback_text) |
|
|
| async def run_pipeline( |
| self, |
| text: str, |
| source_name: str, |
| source_type: str, |
| started: float, |
| diarization: DiarizationResult | None = None, |
| transcription_confidence: float | None = None, |
| whisper_model: str | None = None, |
| language: str | None = None, |
| ) -> dict[str, Any]: |
| """Execute the full analysis pipeline and return the response payload.""" |
| t_start_internal = time.perf_counter() |
| |
| t0 = time.perf_counter() |
| diarized = diarization or diarize_text(text) |
| reconstruction = reconstruct_conversation(diarized) |
| t_diarization = (time.perf_counter() - t0) * 1000 |
| |
| reconstructed_diarization = DiarizationResult( |
| turns=reconstruction.turns, |
| speaker_map=diarized.speaker_map, |
| provider=f"{diarized.provider}+reconstructed", |
| speaker_confidence=diarized.speaker_confidence, |
| warnings=list(dict.fromkeys([*diarized.warnings, *reconstruction.metadata.warnings])), |
| ) |
| customer_text = reconstructed_diarization.customer_text or text |
| agent_text = reconstructed_diarization.agent_text |
| privacy = extract_and_redact_pii(customer_text) |
| local_entities = self.local_structured_entities(text, diarized) |
| llama_text = privacy.cleaned_text |
|
|
| t0 = time.perf_counter() |
| extraction = await process_text(llama_text) |
| if extraction is None: |
| extraction = self.fallback_extraction(llama_text) |
| extraction["privacy_redaction_count"] = privacy.redaction_count |
| extraction["analysis_scope"] = "customer_only" |
| conversation_summary = await summarize_conversation( |
| transcript=" ".join(text.split()), |
| customer_text=llama_text, |
| agent_text=agent_text, |
| ) |
| t_llama = (time.perf_counter() - t0) * 1000 |
|
|
| t0 = time.perf_counter() |
| prediction = predict_with_trained_model(extraction, llama_text, agent_text) |
| t_xgb = (time.perf_counter() - t0) * 1000 |
| |
| raw_features = extraction.get("raw_features", []) |
| sentiment_score = float(extraction.get("sentiment_score", 0)) |
|
|
| audio_quality = None |
| if transcription_confidence is not None: |
| if transcription_confidence >= 0.82: |
| quality_label = "Good" |
| elif transcription_confidence >= 0.65: |
| quality_label = "Fair" |
| else: |
| quality_label = "Poor" |
| audio_quality = { |
| "label": quality_label, |
| "confidence": transcription_confidence, |
| "language": language, |
| "whisperModel": whisper_model, |
| } |
|
|
| def get_sentiment_label(score: float, txt: str) -> str: |
| txt_lower = txt.lower() |
| if any(w in txt_lower for w in ["confusing", "too many options", "don't know"]): |
| return "Confused" |
| if any(w in txt_lower for w in ["love", "amazing", "exactly what i need", "perfect"]): |
| return "Emotionally Engaged" |
| if any(w in txt_lower for w in ["maybe", "not sure", "thinking", "think about it"]): |
| return "Hesitant" |
| if score > 0.6: |
| return "Very Positive" |
| if score > 0.2: |
| return "Positive" |
| if score > 0.05: |
| return "Mildly Positive" |
| if score < -0.2: |
| return "Negative" |
| return "Neutral" |
|
|
| dominant = get_sentiment_label(sentiment_score, llama_text) |
| privacy_info = self.pii_payload(privacy, local_entities) |
| detected_follow_ups = await detect_follow_up_alerts( |
| customer_text=llama_text, |
| diarization=diarized, |
| privacy_payload=privacy_info, |
| ) |
| follow_up_alerts = save_follow_up_alerts( |
| detected_follow_ups, |
| source_name=source_name, |
| source_type=source_type, |
| ) |
|
|
| products = [ |
| { |
| "name": feature.get("name", ""), |
| "entityType": feature.get("label", "FEATURE"), |
| "sentiment": dominant, |
| "score": sentiment_score, |
| "confidence": extraction.get("confidence_score", 0.5), |
| "mentions": 1, |
| "context": feature.get("label", "Feature"), |
| } |
| for feature in raw_features |
| ] |
|
|
| partial_result = { |
| "diarizationMetrics": diarized.metrics, |
| "diarizedTranscript": self.transcript_payload(diarized), |
| "reconstructedTranscript": self.transcript_payload(reconstructed_diarization), |
| "conversationStages": detect_conversation_stages(reconstructed_diarization.turns), |
| "sentimentTimeline": compute_sentiment_timeline(reconstructed_diarization.turns), |
| "summary": { |
| "positive": 100 if dominant == "positive" else 0, |
| "negative": 100 if dominant == "negative" else 0, |
| "neutral": 100 if dominant == "neutral" else 0, |
| "dominant": dominant, |
| "averageScore": sentiment_score, |
| "totalProducts": len(raw_features), |
| }, |
| "conversionScore": { |
| "probability": prediction["probability"], |
| "label": prediction["label"], |
| "confidence": round(abs(prediction["probability"] - 0.5) * 2, 2), |
| "features": extraction, |
| "model": CONVERSION_MODEL_PATH.name, |
| "explainability": prediction.get("explainability"), |
| "decisionTrace": prediction.get("decisionTrace"), |
| }, |
| "pipelineFeatures": extraction, |
| "conversationSummary": conversation_summary, |
| "rawFeatures": raw_features, |
| "metadata": { |
| "speakerConfidence": diarized.speaker_confidence, |
| } |
| } |
|
|
| pipeline_latencies = { |
| "vad_diarization_ms": round(max(1.0, t_diarization * 0.5), 2), |
| "embeddings_ms": round(max(1.0, t_diarization * 0.3), 2), |
| "classifier_ms": round(max(1.0, t_diarization * 0.2), 2), |
| "llama_extraction_ms": round(max(1.0, t_llama), 2), |
| "xgboost_prediction_ms": round(max(1.0, t_xgb), 2) |
| } |
|
|
| analytics = compute_conversation_analytics(partial_result, pipeline_latencies) |
|
|
| return { |
| "transcript": text, |
| "diarizationMetrics": diarized.metrics, |
| "diarizedTranscript": partial_result["diarizedTranscript"], |
| "reconstructedTranscript": partial_result["reconstructedTranscript"], |
| "conversationStages": partial_result["conversationStages"], |
| "sentimentTimeline": partial_result["sentimentTimeline"], |
| "analytics": analytics, |
| "calibratedConfidence": analytics["calibratedConfidence"], |
| "customerTranscript": customer_text, |
| "customerBehavioralTranscript": llama_text, |
| "agentTranscript": agent_text, |
| "privacy": privacy_info, |
| "followUpAlerts": follow_up_alerts, |
| "customerBehaviorSummary": self.summarize_customer_behavior(llama_text, extraction, conversation_summary), |
| "conversationSummary": conversation_summary, |
| "normalizedText": " ".join(text.split()), |
| "rawFeatures": raw_features, |
| "pipelineFeatures": extraction, |
| "products": products, |
| "summary": partial_result["summary"], |
| "conversionScore": partial_result["conversionScore"], |
| "audioQuality": audio_quality, |
| "prediction": prediction, |
| "metadata": { |
| "sourceType": source_type, |
| "sourceName": source_name, |
| "processingMs": int((time.perf_counter() - started) * 1000), |
| "extractionProvider": extraction.get("extraction_provider", "llama"), |
| "modelFeatures": len(get_model_features()), |
| "diarizationProvider": diarized.provider, |
| "speakerConfidence": diarized.speaker_confidence, |
| "diarizationWarnings": diarized.warnings, |
| "reconstruction": { |
| "confidence": reconstruction.metadata.confidence, |
| "warnings": reconstruction.metadata.warnings, |
| "fallbackUsed": reconstruction.metadata.fallback_used, |
| "modelUsed": reconstruction.metadata.model_used, |
| "processingMs": reconstruction.metadata.processing_time_ms, |
| "mergedFragments": reconstruction.metadata.merged_fragments, |
| "overlapTurns": reconstruction.metadata.overlap_turns, |
| }, |
| "analysisScope": "customer_only_privacy_safe", |
| "piiRedactionCount": privacy.redaction_count, |
| "transcriptionConfidence": transcription_confidence, |
| "whisperModel": whisper_model, |
| "language": language, |
| }, |
| "pipeline": [ |
| self.completed_stage("diarization", "Speaker diarization", |
| f"Transcript separated with {diarized.provider}").model_dump(), |
| self.completed_stage("reconstruction", "Conversation reconstruction", |
| f"{reconstruction.metadata.merged_fragments} fragment(s) merged").model_dump(), |
| self.completed_stage("privacy", "Local PII extraction", |
| f"{privacy.redaction_count} sensitive item(s) redacted before LLaMA").model_dump(), |
| self.completed_stage("llama", "Customer-only LLaMA extraction", |
| "Structured sales features extracted from cleaned customer speech").model_dump(), |
| self.completed_stage("follow-up-alerts", "Follow-up alert detection", |
| f"{len(follow_up_alerts)} alert(s) saved").model_dump(), |
| self.completed_stage("model", "Customer-weighted conversion model", |
| "Hybrid conversion model executed").model_dump(), |
| ], |
| } |
|
|
| |
| |
| |
|
|
| @staticmethod |
| def _valid_money_amount(amount: str, suffix: str | None, currency: str | None) -> bool: |
| try: |
| value = float(AnalysisService.normalize_money(amount, suffix)) |
| except ValueError: |
| return False |
| return value >= 100 or bool(suffix) or bool(currency) |
|
|
| @staticmethod |
| def normalize_money(amount: str, suffix: str | None = None) -> str: |
| value = float(amount.replace(",", "")) |
| suffix = (suffix or "").lower() |
| if suffix == "k": |
| value *= 1000 |
| elif suffix in {"lakh", "lakhs"}: |
| value *= 100000 |
| return str(int(value)) if value.is_integer() else str(value) |
|
|
| @staticmethod |
| def _valid_person_name(value: str) -> bool: |
| clean = value.strip().strip(".,!?") |
| if not clean or any(ch.isdigit() for ch in clean): |
| return False |
| parts = [part.lower() for part in clean.split()] |
| if any(part in NON_PERSON_NAME_TERMS or part in PRODUCT_NAME_TERMS for part in parts): |
| return False |
| return all(part[:1].isalpha() for part in parts) |
|
|
| @staticmethod |
| def _append_entity( |
| entities: list[dict[str, Any]], |
| entity_type: str, |
| value: str, |
| source: str, |
| ) -> None: |
| clean = value.strip().strip(".,!?") |
| if not clean: |
| return |
| if entity_type in {"customer_name", "agent_name"} and not AnalysisService._valid_person_name(clean): |
| return |
| opposite_type = "agent_name" if entity_type == "customer_name" else "customer_name" |
| if entity_type in {"customer_name", "agent_name"}: |
| if any( |
| item["type"] == opposite_type and item["value"].lower() == clean.lower() |
| for item in entities |
| ): |
| return |
| key = (entity_type, clean.lower()) |
| if key not in {(item["type"], item["value"].lower()) for item in entities}: |
| entities.append({"type": entity_type, "value": clean, "source": source, "start": None, "end": None}) |
|
|
| @staticmethod |
| def local_structured_entities(text: str, diarization: DiarizationResult) -> list[dict[str, Any]]: |
| entities: list[dict[str, Any]] = [] |
|
|
| for match in BUDGET_AMOUNT_RX.finditer(text): |
| if AnalysisService._valid_money_amount( |
| match.group("amount"), match.group("suffix"), match.group("currency") |
| ): |
| AnalysisService._append_entity( |
| entities, |
| "budget", |
| AnalysisService.normalize_money(match.group("amount"), match.group("suffix")), |
| "local-regex", |
| ) |
|
|
| for match in MONEY_CONTEXT_RX.finditer(text): |
| context = match.group("context").lower() |
| if not AnalysisService._valid_money_amount( |
| match.group("amount"), match.group("suffix"), match.group("currency") |
| ): |
| continue |
| amount = AnalysisService.normalize_money(match.group("amount"), match.group("suffix")) |
| entity_type = ( |
| "budget" |
| if context == "budget" |
| else "product_price" if context in {"price", "cost"} else "income" |
| ) |
| AnalysisService._append_entity(entities, entity_type, amount, "local-regex") |
|
|
| for sentence in re.split(r"(?<=[.!?])\s+", text): |
| lower = sentence.lower() |
| if any(term in lower for term in ["earning", "income", "salary", "per month"]): |
| for match in re.finditer( |
| r"(?:rs\.?|inr|₹)?\s*([0-9][0-9,]*(?:\.\d+)?)\s*(k|lakh|lakhs)?", |
| sentence, |
| re.IGNORECASE, |
| ): |
| AnalysisService._append_entity( |
| entities, |
| "income", |
| AnalysisService.normalize_money(match.group(1), match.group(2)), |
| "local-regex", |
| ) |
|
|
| for turn in diarization.turns: |
| if turn.speaker == "Customer": |
| for match in SELF_INTRO_RX.finditer(turn.text): |
| candidate = match.group("name") |
| if candidate.lower() not in { |
| "looking", "earning", "teacher", "interested", "calling", "sure", "here" |
| }: |
| AnalysisService._append_entity(entities, "customer_name", candidate, "speaker-regex") |
| for match in GREETING_RX.finditer(turn.text): |
| candidate = match.group("name") |
| if candidate.lower() not in {"sir", "madam", "maam", "ma'am", "there"}: |
| AnalysisService._append_entity(entities, "agent_name", candidate, "speaker-regex") |
| for match in OCCUPATION_RX.finditer(turn.text): |
| AnalysisService._append_entity(entities, "job_title", match.group("job"), "speaker-regex") |
| elif turn.speaker == "Agent": |
| for match in SELF_INTRO_RX.finditer(turn.text): |
| candidate = match.group("name") |
| if candidate.lower() not in {"calling", "sure", "here", "just"}: |
| AnalysisService._append_entity(entities, "agent_name", candidate, "speaker-regex") |
| for match in GREETING_RX.finditer(turn.text): |
| candidate = match.group("name") |
| if candidate.lower() not in {"sir", "madam", "maam", "ma'am", "there"}: |
| AnalysisService._append_entity(entities, "customer_name", candidate, "speaker-regex") |
|
|
| |
| for match in EMAIL_RX.finditer(text): |
| AnalysisService._append_entity(entities, "email", match.group(0).strip(), "local-regex") |
|
|
| for match in CUSTOMER_PHONE_RX.finditer(text): |
| val = match.group(1).strip() if match.lastindex else match.group(0).strip() |
| AnalysisService._append_entity(entities, "customer_number", val, "local-regex") |
|
|
| for match in PHONE_RX.finditer(text): |
| val = match.group(1).strip() if match.lastindex else match.group(0).strip() |
| AnalysisService._append_entity(entities, "phone", val, "local-regex") |
|
|
| for match in COMPANY_RX.finditer(text): |
| val = match.group(1).strip() if match.lastindex else match.group(0).strip() |
| AnalysisService._append_entity(entities, "company_name", val, "local-regex") |
|
|
| return entities |
|
|
| |
| |
| |
|
|
| @staticmethod |
| def _csv_join(values: list[str]) -> str: |
| unique = [] |
| for value in values: |
| clean = str(value or "").strip() |
| if clean and clean.lower() not in {item.lower() for item in unique}: |
| unique.append(clean) |
| return ", ".join(unique) |
|
|
| @staticmethod |
| def csv_feature_columns(result: dict[str, Any]) -> dict[str, Any]: |
| raw_features = result.get("rawFeatures") |
| if not isinstance(raw_features, list): |
| raw_features = [] |
|
|
| by_label: dict[str, list[str]] = {} |
| for feature in raw_features: |
| if not isinstance(feature, dict): |
| continue |
| label = str(feature.get("label") or "FEATURE").upper() |
| value = str(feature.get("name") or feature.get("value") or "").strip() |
| if value: |
| by_label.setdefault(label, []).append(value) |
|
|
| pipeline_features = result.get("pipelineFeatures") if isinstance(result.get("pipelineFeatures"), dict) else {} |
| summary = result.get("summary") if isinstance(result.get("summary"), dict) else {} |
| conversion = result.get("conversionScore") if isinstance(result.get("conversionScore"), dict) else {} |
| prediction = result.get("prediction") if isinstance(result.get("prediction"), dict) else {} |
| debug_metrics = prediction.get("debug_metrics") if isinstance(prediction.get("debug_metrics"), dict) else {} |
| metadata = result.get("metadata") if isinstance(result.get("metadata"), dict) else {} |
| model_metrics = load_model_metrics() |
|
|
| return { |
| "products": AnalysisService._csv_join(by_label.get("PRODUCT", [])), |
| "brands": AnalysisService._csv_join(by_label.get("BRAND", [])), |
| "budget": AnalysisService._csv_join(by_label.get("BUDGET", [])), |
| "features": AnalysisService._csv_join(by_label.get("FEATURE", [])), |
| "intent": AnalysisService._csv_join(by_label.get("INTENT", [])), |
| "decision_stage": AnalysisService._csv_join(by_label.get("DECISION_STAGE", [])), |
| "use_case": AnalysisService._csv_join(by_label.get("USE_CASE", [])), |
| "objections": AnalysisService._csv_join(by_label.get("OBJECTION", []) + by_label.get("OBJECTION_TYPE", [])), |
| "sentiment": summary.get("dominant", ""), |
| "confidence_score": pipeline_features.get("confidence_score", ""), |
| "hesitation_score": pipeline_features.get("hesitation_score", ""), |
| "delay_flag": pipeline_features.get("delay_flag", ""), |
| "conversion_label": conversion.get("label", ""), |
| "conversion_probability": conversion.get("probability", ""), |
| "conversion_prediction": prediction.get("prediction", ""), |
| "model_accuracy": model_metrics.get("accuracy", ""), |
| "model_precision": model_metrics.get("precision", ""), |
| "model_recall": model_metrics.get("recall", ""), |
| "model_f1": model_metrics.get("f1", ""), |
| "xgboost_base_probability": debug_metrics.get("xgboost_base", ""), |
| "intent_score": debug_metrics.get("intent_score", ""), |
| "behavioral_score": debug_metrics.get("behavioral_score_scaled", ""), |
| "emotion_score": debug_metrics.get("emotion_score", ""), |
| "engagement_score": debug_metrics.get("engagement_score", ""), |
| "extraction_provider": metadata.get( |
| "extractionProvider", pipeline_features.get("extraction_provider", "") |
| ), |
| "pii_redaction_count": metadata.get( |
| "piiRedactionCount", pipeline_features.get("privacy_redaction_count", "") |
| ), |
| "raw_features_json": json.dumps(raw_features, ensure_ascii=True), |
| } |
|
|
| @staticmethod |
| def ensure_transcript_csv_schema(csv_path: Path) -> None: |
| if not csv_path.exists() or csv_path.stat().st_size == 0: |
| return |
| with csv_path.open(newline="", encoding="utf-8-sig") as handle: |
| reader = csv.DictReader(handle) |
| if reader.fieldnames == TRANSCRIPT_CSV_FIELDS: |
| return |
| rows = list(reader) |
| upgraded_rows = [ |
| {field: row.get(field, "") for field in TRANSCRIPT_CSV_FIELDS} for row in rows |
| ] |
| with csv_path.open("w", newline="", encoding="utf-8") as handle: |
| writer = csv.DictWriter(handle, fieldnames=TRANSCRIPT_CSV_FIELDS) |
| writer.writeheader() |
| writer.writerows(upgraded_rows) |
|
|
| def append_transcript_csv( |
| self, |
| *, |
| source_name: str, |
| text: str, |
| result: dict[str, Any] | None = None, |
| language: str | None = None, |
| duration_s: float | None = None, |
| ) -> None: |
| cleaned_text = " ".join(text.split()) |
| if not cleaned_text: |
| return |
|
|
| row = { |
| "file_name": source_name, |
| "text": cleaned_text, |
| "language": language or "", |
| "duration_s": duration_s if duration_s is not None else "", |
| "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), |
| **self.csv_feature_columns(result or {}), |
| } |
|
|
| with TRANSCRIPT_CSV_LOCK: |
| for csv_path in TRANSCRIPT_CSV_PATHS: |
| csv_path.parent.mkdir(parents=True, exist_ok=True) |
| self.ensure_transcript_csv_schema(csv_path) |
| needs_header = not csv_path.exists() or csv_path.stat().st_size == 0 |
| with csv_path.open("a", newline="", encoding="utf-8") as handle: |
| writer = csv.DictWriter(handle, fieldnames=TRANSCRIPT_CSV_FIELDS) |
| if needs_header: |
| writer.writeheader() |
| writer.writerow(row) |
|
|
| def append_transcript_sqlite( |
| self, |
| *, |
| source_name: str, |
| source_type: str, |
| text: str, |
| result: dict[str, Any] | None = None, |
| language: str | None = None, |
| duration_s: float | None = None, |
| ) -> None: |
| cleaned_text = " ".join(text.split()) |
| if not cleaned_text: |
| return |
|
|
| created_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") |
| CONVERSATION_REPOSITORY.create( |
| conversation_id=str(uuid.uuid4()), |
| source_name=source_name, |
| source_type=source_type, |
| transcript=cleaned_text, |
| language=language, |
| duration_s=duration_s, |
| metadata={ |
| "summary": (result or {}).get("summary", {}), |
| "conversionScore": (result or {}).get("conversionScore", {}), |
| "pipelineFeatures": (result or {}).get("pipelineFeatures", {}), |
| }, |
| created_at=created_at, |
| ) |
|
|
| @staticmethod |
| def explain_prediction(row: Any) -> list[str]: |
| def value(column: str, default: int | float = 0) -> int | float: |
| if column not in row.columns: |
| return default |
| return row[column].values[0] |
|
|
| reasons: list[str] = [] |
| if value("confidence_score") > 0.6: |
| reasons.append("Customer shows buying intent") |
| if value("hesitation_score") >= 2: |
| reasons.append("Customer is hesitant") |
| if value("delay_flag") == 1: |
| reasons.append("Customer postponed decision") |
| if value("sentiment_score") > 0.3: |
| reasons.append("Positive sentiment") |
| if not reasons: |
| reasons.append("Limited buying signals detected") |
| return reasons |
|
|
|
|
| |
| analysis_service = AnalysisService() |
|
|