mirror of
https://github.com/hoshikawa2/compass_backoffice.git
synced 2026-07-09 13:54:20 +00:00
462 lines
20 KiB
Python
462 lines
20 KiB
Python
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
|