import time import logging import json import re from src.agent.state.agent_state import AgentState, update_state_metadata from src.agent.state.steps import GraphStep from src.agent.state.step_helpers import set_current_step from src.utils.observer import trace_node, score_current_trace from src.utils.ics_collector import build_ic_payload, build_noc_api_metadata, build_noc_llm_metadata from src.compat.framework_observer import event from src.providers.llm_provider import classification_llm, chat_llm_with_usage, LLM_ENDPOINT from src.core.config import settings from src.core.prompt_manager import get_prompt from src.agent.local_prompts.speech_history_analysis import speech_history_similarity_pt from src.compat.framework_services import call_mcp_tool logger = logging.getLogger(__name__) _NO_SIMILAR_MESSAGE = "Não foram encontradas reclamações anteriores similares à reclamação atual" _HISTORY_UNAVAILABLE_MESSAGE = "Indisponível SPEECH para Histórico de Reclamação - realizar consulta manualmente" _HISTORY_DETAIL_KEYS = ( "reclamacao_resumo", "causa_raiz", "descortesia_cliente", "motivo_reclamacao", "submotivo_reclamacao", "sentimento_cliente", "solucao_proposta_cliente", ) _NULL_SPEECH_DATA = { "reclamacao_resumo": None, "causa_raiz": None, "descortesia_cliente": None, "motivo_reclamacao": None, "submotivo_reclamacao": None, "sentimento_cliente": None, "solucao_proposta_cliente": None, "analise_agente": None, } def _map_prediction_response(response: dict) -> dict: """Map the raw Prediction API response to the internal speech_analytics schema.""" variables = response.get("variables", {}) return { "reclamacao_resumo": response.get("resume") or variables.get("resume"), "causa_raiz": variables.get("causa_raiz"), "descortesia_cliente": variables.get("descortesia_cliente"), "motivo_reclamacao": variables.get("motivo"), "submotivo_reclamacao": variables.get("submotivo"), "sentimento_cliente": variables.get("sentimento_cliente"), "solucao_proposta_cliente": variables.get("solucao_proposta_cliente"), } def _build_related_history(raw_history: list, current_complaint_id: str | None, llm_scores: list, threshold: int) -> list: """Merge LLM similarity scores with raw history items and filter by threshold.""" if not raw_history or not llm_scores: return [] history_by_protocol = {str(item.get("protocolo")): item for item in raw_history} related: list[dict] = [] for score in llm_scores: protocolo = str(score.get("protocolo")) if score.get("protocolo") is not None else None if not protocolo or protocolo == str(current_complaint_id or ""): continue try: similaridade_pct = int(score.get("similaridade_pct", 0)) except (TypeError, ValueError): continue if similaridade_pct < threshold: continue raw_item = history_by_protocol.get(protocolo) if not raw_item: continue merged = { "protocolo": raw_item.get("protocolo"), "data_reclamacao": raw_item.get("data_reclamacao"), "similaridade_pct": similaridade_pct, "reasoning": (score.get("reasoning") or "").strip(), } for key in _HISTORY_DETAIL_KEYS: merged[key] = raw_item.get(key) related.append(merged) related.sort(key=lambda x: x["similaridade_pct"], reverse=True) return related def _build_analise_agente(related_items: list) -> str: """Build the analise_agente text from related items.""" if not related_items: return _NO_SIMILAR_MESSAGE lines = [] for item in related_items: reasoning = (item.get("reasoning") or "").strip() or "—" lines.append(f"Protocolo {item.get('protocolo')} ({item.get('similaridade_pct')}%): {reasoning}") return "\n".join(lines) async def _score_history_with_llm( current_id: str | None, current_complaint_description: str, history_list: list, session_id: str = "", state: dict | None = None, ) -> list: """Score similarity between current complaint and historical complaints using the LLM.""" if not current_complaint_description or not history_list: event("AGA.015", { "status": "Houve acionamento do Agente Especializado (LLM): não — sem resumo atual ou histórico vazio", "type": "INFO", "session_id": session_id, "tag": "AGA.015", "call_id": session_id, "origin": "LLM", **build_ic_payload({"session_id": session_id}, "AGA.015"), }) return [] clean_history = [item for item in history_list if str(item.get("protocolo")) != str(current_id or "")] if not clean_history: logger.info("No historical items left after deduplication.") event("AGA.015", { "status": "Houve acionamento do Agente Especializado (LLM): não — histórico vazio após deduplicação", "type": "INFO", "session_id": session_id, "tag": "AGA.015", "call_id": session_id, "origin": "LLM", **build_ic_payload({"session_id": session_id}, "AGA.015"), }) return [] llm = classification_llm prompt_template = get_prompt("speech_history_similarity_pt", speech_history_similarity_pt) history_payload = [ { "protocolo": item.get("protocolo"), "motivo_reclamacao": item.get("motivo_reclamacao"), "submotivo_reclamacao": item.get("submotivo_reclamacao"), "causa_raiz": item.get("causa_raiz"), "reclamacao_resumo": item.get("reclamacao_resumo"), } for item in clean_history ] message = prompt_template.format( current_complaint_description=current_complaint_description, history_json=json.dumps(history_payload, ensure_ascii=False), ) logger.info("Calling LLM for history similarity filtering. Items: %s", len(clean_history)) event("AGA.014", { "status": "Houve acionamento do Agente Especializado (LLM): sim — filtragem de histórico Speech", "type": "INFO", "session_id": session_id, "tag": "AGA.014", "call_id": session_id, "origin": "LLM", **build_ic_payload({"session_id": session_id}, "AGA.014"), }) try: _t0 = time.perf_counter() llm_resp = chat_llm_with_usage(llm, message) content = llm_resp.content json_match = re.search(r"```(?:json)?\s*(\[.*?\])\s*```", content, re.DOTALL) if json_match: content = json_match.group(1) else: json_match = re.search(r"(\[.*?\])", content, re.DOTALL) if json_match: content = json_match.group(1) scores = json.loads(content) if not isinstance(scores, list): raise ValueError(f"LLM returned non-list: {type(scores).__name__}") score_current_trace(name="speech_history_similarity_valid", value=1.0) return scores except Exception as e: logger.error("Error during LLM history scoring: %s", e, exc_info=True) score_current_trace(name="speech_history_similarity_valid", value=0.0, comment=str(e)) if state is not None: event("NOC.004", { "status": f"LLM error during history similarity analysis: {e}", "type": "FAILURE", **build_noc_llm_metadata( state, "NOC.004", latency_ms=int((time.perf_counter() - _t0) * 1000) if "_t0" in locals() else 0, llm_endpoint=LLM_ENDPOINT, model_name=str(getattr(llm, "eligibleModel_name", "unknown")), ), }, metadata={"noc": True}) return [] @trace_node async def enrich_with_speech(state: AgentState) -> AgentState: """Enrich the ticket with Speech Analytics NLP insights and historical similarity.""" logger.info("Running speech enrichment node.") await set_current_step(state, GraphStep.SPEECH_ENRICHMENT) session_id = state.get("session_id", "") context = state.get("metadata", {}).get("request_context", {}) if "speech_analytics" in context: logger.info("Speech analytics data already exists in context (cached). Skipping API call.") return state complaint = context.get("complaint", {}) reclamacao_id: str | None = complaint.get("complaintProtocol") raw_text: str | None = complaint.get("description") customer = context.get("customer", {}) cpf_cnpj: str | None = customer.get("cpfCnpj") msisdn: str | None = customer.get("msisdn") # Framework-native path: call Speech through MCP/fallback instead of direct legacy clients. mcp_response = await call_mcp_tool( "consultar_speech_analytics", { "protocol_id": reclamacao_id, "customer_key": cpf_cnpj or msisdn, "interaction_key": reclamacao_id, "document": cpf_cnpj, }, business_context={"customer_key": cpf_cnpj or msisdn, "interaction_key": reclamacao_id}, original_context=context, ) mcp_result = mcp_response.get("result") if isinstance(mcp_response.get("result"), dict) else mcp_response if mcp_response.get("ok") or str(mcp_result.get("source", "")).startswith("mock_"): summary = mcp_result.get("summary") or (raw_text or "Reclamação recebida para análise.") speech_data = { "reclamacao_resumo": summary, "causa_raiz": mcp_result.get("root_cause") or "Cobrança/contato indevido", "descortesia_cliente": mcp_result.get("rude_customer") or "False", "motivo_reclamacao": mcp_result.get("reason") or complaint.get("motive"), "submotivo_reclamacao": mcp_result.get("subreason") or complaint.get("modality"), "sentimento_cliente": mcp_result.get("sentiment") or "Negativo", "solucao_proposta_cliente": mcp_result.get("proposed_solution") or "Analisar cobrança e cessar contatos indevidos", "historico_bruto": mcp_result.get("events") or [], "historico_relacionado": mcp_result.get("related_history") or [], "analise_agente": mcp_result.get("agent_analysis") or (mcp_result.get("message") or _NO_SIMILAR_MESSAGE), "source": mcp_result.get("source", "framework_mcp"), "mcp_tool": "consultar_speech_analytics", } context = dict(context) context["speech_analytics"] = speech_data state = update_state_metadata(state, request_context=context) event("AGA.034", { "status": "Resultado Speech: sucesso via MCP/framework", "type": "SUCCESS", "session_id": session_id, "tag": "AGA.034", "call_id": session_id, "origin": "MCP", **build_ic_payload(state, "AGA.034"), }, metadata={"noc": True}) return state if not getattr(settings, "BACKOFFICE_ALLOW_LEGACY_CLIENTS", False): speech_data = dict(_NULL_SPEECH_DATA) speech_data.update({ "reclamacao_resumo": raw_text or "Indisponível SPEECH para Reclamação Atual - realizar consulta manualmente", "historico_bruto": [], "historico_relacionado": [], "analise_agente": _HISTORY_UNAVAILABLE_MESSAGE, "source": "framework_local_fallback", }) context = dict(context) context["speech_analytics"] = speech_data state = update_state_metadata(state, request_context=context) await set_current_step(state, GraphStep.SPEECH_ENRICHMENT_UNAVAILABLE) return state from src.components.clients.speech_analytics_client import SpeechAnalyticsClient from src.components.clients.exceptions.speech_exceptions import SpeechClientError speech_data: dict = dict(_NULL_SPEECH_DATA) history_unavailable = False if not reclamacao_id or not raw_text: logger.warning( "Speech enrichment skipped prediction: missing complaintProtocol or description. " "reclamacao_id=%s, raw_text_present=%s", bool(reclamacao_id), bool(raw_text) ) event("AGA.010", { "status": "Resultado consulta Speech: ignorada — complaintProtocol ou description ausente", "type": "INFO", "session_id": session_id, "tag": "AGA.010", "call_id": session_id, "origin": "AGENT", **build_ic_payload(state, "AGA.010"), }, metadata={"noc": True}) else: try: client = SpeechAnalyticsClient() response, http_meta = await client.get_prediction( reclamacao_id=reclamacao_id, raw_text=raw_text, customer_segment="", ) speech_data = _map_prediction_response(response) if not speech_data.get("reclamacao_resumo"): speech_data["reclamacao_resumo"] = "Reclamação Atual não foi encontrada no SPEECH" event("NOC.002", { "status": "Invalid API response", "type": "WARNING", **build_noc_api_metadata( state, "NOC.002", retry_count=http_meta.get("retry_count", 0), latency_ms=http_meta["latency_ms"], api_url=http_meta["url"], status_code=http_meta["status_code"], ), }, metadata={"noc": True}) logger.info("Speech Analytics prediction retrieved successfully. reclamacao_id=%s", reclamacao_id) event("AGA.010", { "status": "Resultado consulta Speech: sucesso", "type": "SUCCESS", "session_id": session_id, "tag": "AGA.010", "call_id": session_id, "origin": "AGENT", **build_ic_payload(state, "AGA.010", { "apiUrl": http_meta["url"], "apiStatusCode": http_meta["status_code"], "apiResponsePayload": http_meta["response_text"], "latencyMs": http_meta["latency_ms"], }), }, metadata={"noc": True}) except SpeechClientError as client_exc: logger.warning("Speech Analytics unavailable — continuing without prediction. Error: %s", client_exc) speech_data["reclamacao_resumo"] = "Indisponível SPEECH para Reclamação Atual - realizar consulta manualmente" await set_current_step(state, GraphStep.SPEECH_ENRICHMENT_UNAVAILABLE) api_fields = { "apiUrl": getattr(client_exc, "url", None) or "N/A", "apiStatusCode": getattr(client_exc, "status_code", None) if getattr(client_exc, "status_code", None) is not None else "N/A", "apiResponsePayload": getattr(client_exc, "response_text", None) if getattr(client_exc, "response_text", None) is not None else "N/A", "latencyMs": getattr(client_exc, "latency_ms", None) if getattr(client_exc, "latency_ms", None) is not None else "N/A", } event("AGA.035", { "status": f"Erro na consulta Speech (Reclamação Atual): SpeechClientError: {str(client_exc)}", "type": "FAILURE", "session_id": session_id, "tag": "AGA.035", "call_id": session_id, "origin": "AGENT", **build_ic_payload(state, "AGA.035", api_fields), }, metadata={"noc": True}) historico: list = [] if not cpf_cnpj: logger.warning("Speech enrichment skipped history fetch: cpfCnpj absent.", extra={"session_id": session_id}) event("AGA.034", { "status": "Resultado consulta histórico Speech: ignorada — cpfCnpj ausente", "type": "INFO", "session_id": session_id, "tag": "AGA.034", "call_id": session_id, "origin": "AGENT", **build_ic_payload(state, "AGA.034"), }, metadata={"noc": True}) else: try: client = SpeechAnalyticsClient() historico, http_meta = await client.get_history(cpf_cnpj, msisdn) logger.info("Speech Analytics history retrieved successfully. %s items.", len(historico), extra={"session_id": session_id}) event("AGA.034", { "status": f"Resultado consulta histórico Speech: sucesso — {len(historico)} item(ns)", "type": "SUCCESS", "session_id": session_id, "tag": "AGA.034", "call_id": session_id, "origin": "AGENT", **build_ic_payload(state, "AGA.034", { "apiUrl": http_meta["url"], "apiStatusCode": http_meta["status_code"], "apiResponsePayload": http_meta["response_text"], "latencyMs": http_meta["latency_ms"], }), }, metadata={"noc": True}) if not historico: event("NOC.002", { "status": "Invalid API response", "type": "WARNING", **build_noc_api_metadata( state, "NOC.002", retry_count=http_meta.get("retry_count", 0), latency_ms=http_meta["latency_ms"], api_url=http_meta["url"], status_code=http_meta["status_code"], ), }, metadata={"noc": True}) except Exception as exc: logger.warning("Failed to fetch speech history: %s", exc) await set_current_step(state, GraphStep.SPEECH_ENRICHMENT_UNAVAILABLE) api_fields = {} if isinstance(exc, SpeechClientError): api_fields = { "apiUrl": getattr(exc, "url", None) or "N/A", "apiStatusCode": getattr(exc, "status_code", None) if getattr(exc, "status_code", None) is not None else "N/A", "apiResponsePayload": getattr(exc, "response_text", None) if getattr(exc, "response_text", None) is not None else "N/A", "latencyMs": getattr(exc, "latency_ms", None) if getattr(exc, "latency_ms", None) is not None else "N/A", } event("AGA.035", { "status": f"Falha ao buscar histórico Speech: {str(exc)}", "type": "FAILURE", "session_id": session_id, "tag": "AGA.035", "call_id": session_id, "origin": "AGENT", **build_ic_payload(state, "AGA.035", api_fields), }, metadata={"noc": True}) history_unavailable = True historico_relacionado: list = [] if historico and raw_text: scores = await _score_history_with_llm( current_id=reclamacao_id, current_complaint_description=raw_text, history_list=historico, session_id=session_id, state=state, ) historico_relacionado = _build_related_history( raw_history=historico, current_complaint_id=reclamacao_id, llm_scores=scores, threshold=settings.SPEECH_SIMILARITY_THRESHOLD, ) def _strip_to_detail_keys(h_list: list) -> list: return [{k: item.get(k) for k in _HISTORY_DETAIL_KEYS} for item in h_list] speech_data["historico_bruto"] = _strip_to_detail_keys(historico) speech_data["historico_relacionado"] = historico_relacionado speech_data["analise_agente"] = _HISTORY_UNAVAILABLE_MESSAGE if history_unavailable else _build_analise_agente(historico_relacionado) context = dict(context) context["speech_analytics"] = speech_data state = update_state_metadata(state, request_context=context) logger.info("Speech enrichment node completed successfully. State metadata and current step updated: %s", state.get("current_step")) return state