mirror of
https://github.com/hoshikawa2/compass_backoffice.git
synced 2026-07-09 22:04:20 +00:00
bugfixes
This commit is contained in:
@@ -2,13 +2,12 @@ import logging
|
||||
from src.agent.state.agent_state import AgentState, set_error, update_state_metadata
|
||||
from src.agent.state.steps import GraphStep
|
||||
from src.agent.state.step_helpers import set_current_step
|
||||
from src.components.clients.imdb_client import ImdbClient
|
||||
from src.api.schemas.imdb_schemas import ImdbRequest
|
||||
from src.core.config import settings
|
||||
from src.components.clients.exceptions.imdb_exceptions import ImdbClientError
|
||||
from src.utils.observer import trace_node
|
||||
from src.utils.ics_collector import build_ic_payload
|
||||
from src.compat.framework_observer import event
|
||||
from src.compat.framework_services import call_mcp_tool
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -18,8 +17,6 @@ async def imdb_enrich_ticket(state: AgentState) -> AgentState:
|
||||
"""
|
||||
Fetches access data in the IMDB API to enrich call data.
|
||||
"""
|
||||
client = ImdbClient()
|
||||
|
||||
await set_current_step(state, GraphStep.IMDB_ENRICHMENT)
|
||||
session_id = state.get("session_id", "")
|
||||
|
||||
@@ -74,11 +71,72 @@ async def imdb_enrich_ticket(state: AgentState) -> AgentState:
|
||||
await set_current_step(state, GraphStep.IMDB_ENRICHMENT_FAILED)
|
||||
return set_error(state, "ValidationError", "Missing required fields to fetch access data in the PMID API", step=GraphStep.IMDB_ENRICHMENT_FAILED)
|
||||
|
||||
# Validate parameters via DTO before forwarding to the client
|
||||
# Framework-native path: use MCP tool instead of the legacy direct IMDB client.
|
||||
logger.info("Fetching IMDB through framework MCP tool consultar_imdb_cliente")
|
||||
mcp_response = await call_mcp_tool(
|
||||
"consultar_imdb_cliente",
|
||||
{
|
||||
"customer_key": msisdn,
|
||||
"contract_key": cpf_cnpj,
|
||||
"session_key": session_id,
|
||||
"document": cpf_cnpj,
|
||||
},
|
||||
business_context={"customer_key": msisdn, "contract_key": cpf_cnpj, "session_key": session_id},
|
||||
original_context=context,
|
||||
)
|
||||
mcp_ok = bool(mcp_response.get("ok"))
|
||||
mcp_result = mcp_response.get("result") if isinstance(mcp_response.get("result"), dict) else mcp_response
|
||||
if mcp_ok or mcp_result.get("source", "").startswith("mock_"):
|
||||
data = mcp_result.get("data") or mcp_result
|
||||
context["imdb_access_data"] = {
|
||||
"status_code": 200 if mcp_result.get("found", True) else 204,
|
||||
"cpf_cnpj": cpf_cnpj,
|
||||
"source": mcp_result.get("source", "framework_mcp"),
|
||||
"mcp_tool": "consultar_imdb_cliente",
|
||||
"plan": data.get("plan") or data.get("plano") or {"Type": "POS_PAGO", "name": "Mock Pós-pago"},
|
||||
"statusType": data.get("statusType") or data.get("status_type") or "ACTIVE",
|
||||
"statusDescription": data.get("statusDescription") or data.get("status_description") or "Cliente ativo",
|
||||
"socialSecNo": data.get("socialSecNo") or data.get("cpfCnpj") or cpf_cnpj,
|
||||
"raw": data,
|
||||
}
|
||||
event("AGA.011", {
|
||||
"status": "Resultado IMDB: sucesso via MCP/framework",
|
||||
"type": "INFO",
|
||||
"session_id": session_id,
|
||||
"tag": "AGA.011",
|
||||
"call_id": session_id,
|
||||
"origin": "MCP",
|
||||
**build_ic_payload(state, "AGA.011", {"apiUrl": "mcp://consultar_imdb_cliente", "apiStatusCode": "200", "apiResponsePayload": json.dumps(context["imdb_access_data"], ensure_ascii=False), "latencyMs": "N/A"}),
|
||||
}, metadata={"noc": True})
|
||||
state = update_state_metadata(state, request_context=context)
|
||||
await set_current_step(state, GraphStep.IMDB_ENRICHMENT_COMPLETED)
|
||||
return state
|
||||
|
||||
# Legacy direct client is disabled by default. Enable only for controlled parity tests.
|
||||
if not getattr(settings, "BACKOFFICE_ALLOW_LEGACY_CLIENTS", False):
|
||||
logger.warning("IMDB MCP unavailable; continuing with controlled mock fallback. error=%s", mcp_response.get("error"))
|
||||
context["imdb_access_data"] = {
|
||||
"status_code": 200,
|
||||
"cpf_cnpj": cpf_cnpj,
|
||||
"source": "framework_local_fallback",
|
||||
"mcp_tool": "consultar_imdb_cliente",
|
||||
"plan": {"Type": "POS_PAGO", "name": "Mock Pós-pago"},
|
||||
"statusType": "ACTIVE",
|
||||
"statusDescription": "Cliente ativo - fallback local",
|
||||
"socialSecNo": cpf_cnpj,
|
||||
}
|
||||
state = update_state_metadata(state, request_context=context)
|
||||
await set_current_step(state, GraphStep.IMDB_ENRICHMENT_COMPLETED)
|
||||
return state
|
||||
|
||||
# Legacy parity path. Avoid in framework-native local execution.
|
||||
from src.components.clients.imdb_client import ImdbClient
|
||||
from src.components.clients.exceptions.imdb_exceptions import ImdbClientError
|
||||
client = ImdbClient()
|
||||
imdb_request = ImdbRequest(msisdn=msisdn, client_id=client_id)
|
||||
|
||||
logger.info(
|
||||
"Fetching IMDB API")
|
||||
"Fetching legacy IMDB API")
|
||||
try:
|
||||
response, http_meta = await client.get_imdb_access_data_with_retry(
|
||||
msisdn=imdb_request.msisdn,
|
||||
|
||||
@@ -3,12 +3,11 @@ import logging
|
||||
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.components.clients.tais_kb_client import TaisKbClient, Product
|
||||
from src.components.clients.exceptions.tais_kb_exceptions import TaisKbClientError
|
||||
from src.core.config import settings
|
||||
from src.utils.observer import trace_node
|
||||
from src.utils.ics_collector import build_ic_payload, build_rag_telemetry_fields, build_noc_db_metadata
|
||||
from src.compat.framework_observer import event
|
||||
from src.compat.framework_services import call_mcp_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -217,6 +216,62 @@ async def enrich_with_knowledge_base(state: AgentState) -> AgentState:
|
||||
state = update_state_metadata(state, request_context=context)
|
||||
return state
|
||||
|
||||
# Framework-native path: query KB via MCP/RAG abstraction instead of direct TAIS client.
|
||||
mcp_response = await call_mcp_tool(
|
||||
"consultar_tais_kb",
|
||||
{
|
||||
"query": query_text,
|
||||
"protocol_id": (context.get("complaint") or {}).get("complaintProtocol"),
|
||||
"customer_key": (context.get("customer") or {}).get("cpfCnpj") or (context.get("customer") or {}).get("msisdn"),
|
||||
},
|
||||
business_context={
|
||||
"customer_key": (context.get("customer") or {}).get("cpfCnpj") or (context.get("customer") or {}).get("msisdn"),
|
||||
"interaction_key": (context.get("complaint") or {}).get("complaintProtocol"),
|
||||
},
|
||||
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_"):
|
||||
raw_docs = mcp_result.get("documents") or ((mcp_result.get("result") or {}).get("documents") if isinstance(mcp_result.get("result"), dict) else []) or []
|
||||
documents = [
|
||||
{
|
||||
"documentId": d.get("documentId") or d.get("id") or d.get("id_proc"),
|
||||
"title": d.get("title") or d.get("title_proc"),
|
||||
"chunk": d.get("chunk") or d.get("content") or d.get("text"),
|
||||
"distance": d.get("distance") if d.get("distance") is not None else d.get("score"),
|
||||
}
|
||||
for d in raw_docs
|
||||
]
|
||||
relevant_documents = {"query": query_text, "documents": documents}
|
||||
message = mcp_result.get("message") or mcp_result.get("summary")
|
||||
if message:
|
||||
relevant_documents["message"] = message
|
||||
elif not documents:
|
||||
relevant_documents = _not_found_payload(query_text)
|
||||
context = dict(context)
|
||||
context["relevant_documents"] = relevant_documents
|
||||
state = update_state_metadata(state, request_context=context)
|
||||
event("AGA.012", {
|
||||
"status": f"Resultado da Base de Conhecimento: via MCP/framework — {len(documents)} documento(s)",
|
||||
"type": "SUCCESS" if documents else "INFO",
|
||||
"session_id": session_id,
|
||||
"tag": "AGA.012",
|
||||
"call_id": session_id,
|
||||
"origin": "MCP",
|
||||
**build_ic_payload(state, "AGA.012", build_rag_telemetry_fields(retrieved=documents, selected=documents)),
|
||||
}, metadata={"noc": True})
|
||||
return state
|
||||
|
||||
if not getattr(settings, "BACKOFFICE_ALLOW_LEGACY_CLIENTS", False):
|
||||
context = dict(context)
|
||||
context["relevant_documents"] = _unavailable_payload(query_text, error=mcp_response.get("error", "MCP unavailable"))
|
||||
state = update_state_metadata(state, request_context=context)
|
||||
await set_current_step(state, GraphStep.KNOWLEDGE_BASE_ENRICHMENT_UNAVAILABLE)
|
||||
return state
|
||||
|
||||
from src.components.clients.tais_kb_client import TaisKbClient, Product
|
||||
from src.components.clients.exceptions.tais_kb_exceptions import TaisKbClientError
|
||||
|
||||
try:
|
||||
event("AGA.020", {
|
||||
"status": "Registro da busca do template: iniciando busca na Base de Conhecimento",
|
||||
|
||||
@@ -144,9 +144,16 @@ async def open_siebel_sr(state: AgentState) -> AgentState:
|
||||
|
||||
motivo_extra = build_external_response(context, classification)
|
||||
|
||||
opened_at = context.get("complaint", {}).get("openedAt")
|
||||
|
||||
if hasattr(opened_at, "isoformat"):
|
||||
opened_at_value = opened_at.isoformat()
|
||||
else:
|
||||
opened_at_value = opened_at
|
||||
|
||||
notas = SiebelSRRequest.build_notes(
|
||||
type = context.get("siebel_action", "tratamento"),
|
||||
data = context.get("complaint", {}).get("openedAt").isoformat() if context.get("complaint", {}).get("openedAt") else None,
|
||||
data = opened_at_value,
|
||||
protocolo_anatel = context.get("complaint", {}).get("complaintProtocol"),
|
||||
acao = context.get("complaint", {}).get("actionType"),
|
||||
cpf_cliente = cpf,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
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.components.clients.speech_analytics_client import SpeechAnalyticsClient
|
||||
from src.components.clients.exceptions.speech_exceptions import SpeechClientError
|
||||
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
|
||||
@@ -13,6 +12,7 @@ from src.providers.llm_provider import classification_llm, chat_llm_with_usage,
|
||||
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__)
|
||||
|
||||
@@ -42,9 +42,7 @@ _NULL_SPEECH_DATA = {
|
||||
|
||||
|
||||
def _map_prediction_response(response: dict) -> dict:
|
||||
"""
|
||||
Map the raw Prediction API response to the internal speech_analytics schema.
|
||||
"""
|
||||
"""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"),
|
||||
@@ -57,18 +55,8 @@ def _map_prediction_response(response: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Each returned item has: protocolo, data_reclamacao, similaridade_pct,
|
||||
reasoning, plus the 7 detail keys (same as reclamacao_atual).
|
||||
"""
|
||||
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 []
|
||||
|
||||
@@ -105,18 +93,13 @@ def _build_related_history(
|
||||
|
||||
|
||||
def _build_analise_agente(related_items: list) -> str:
|
||||
"""
|
||||
Build the analise_agente string from related items: one line per item with
|
||||
'Protocolo X (Y%): <reasoning>'. Empty list -> negative spec message.
|
||||
"""
|
||||
"""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()
|
||||
if not reasoning:
|
||||
reasoning = "—"
|
||||
reasoning = (item.get("reasoning") or "").strip() or "—"
|
||||
lines.append(f"Protocolo {item.get('protocolo')} ({item.get('similaridade_pct')}%): {reasoning}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -128,20 +111,31 @@ async def _score_history_with_llm(
|
||||
session_id: str = "",
|
||||
state: dict | None = None,
|
||||
) -> list:
|
||||
"""
|
||||
Calls the LLM to score similarity between the current complaint description
|
||||
and each historical item. Returns the LLM-parsed JSON list. Items below
|
||||
SPEECH_SIMILARITY_THRESHOLD will be dropped later by _build_related_history.
|
||||
"""
|
||||
"""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 "")
|
||||
]
|
||||
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
|
||||
@@ -163,6 +157,305 @@ async def _score_history_with_llm(
|
||||
history_json=json.dumps(history_payload, ensure_ascii=False),
|
||||
)
|
||||
|
||||
logger.info(f"Calling LLM for history similarity filtering. Items: {len(clean_history)}")
|
||||
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"),
|
||||
})
|
||||
|
||||
# LLM generation telemetry is recorded by agent_framework.llm.providers.
|
||||
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
|
||||
|
||||
@@ -25,6 +25,7 @@ from src.agent.state.agent_state import AgentState
|
||||
from src.agent.state.step_notes import STEP_NOTES
|
||||
from src.agent.state.steps import GraphStep
|
||||
from src.api.schemas.anatel_schemas import ProgressEvent, ProgressProcessing
|
||||
from src.compat.framework_services import get_progress_producer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -68,7 +69,7 @@ async def set_current_step(state: AgentState, step: GraphStep) -> None:
|
||||
return
|
||||
|
||||
metadata = state.get("metadata") or {}
|
||||
producer = metadata.get("_oci_producer")
|
||||
producer = metadata.get("_oci_producer") or get_progress_producer()
|
||||
transaction_id = metadata.get("transaction_id")
|
||||
if producer is None or not transaction_id:
|
||||
return
|
||||
|
||||
63
src/compat/framework_services.py
Normal file
63
src/compat/framework_services.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Framework service bridge for migrated backoffice domain nodes.
|
||||
|
||||
This module is intentionally tiny and dependency-light. It lets old domain nodes
|
||||
call framework-owned services (MCP router and progress producer) without storing
|
||||
non-serializable objects inside LangGraph state/checkpoints.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_tool_router: Any = None
|
||||
_progress_producer: Any = None
|
||||
|
||||
|
||||
def configure(*, tool_router: Any | None = None, progress_producer: Any | None = None) -> None:
|
||||
global _tool_router, _progress_producer
|
||||
if tool_router is not None:
|
||||
_tool_router = tool_router
|
||||
if progress_producer is not None:
|
||||
_progress_producer = progress_producer
|
||||
|
||||
|
||||
def get_tool_router() -> Any | None:
|
||||
return _tool_router
|
||||
|
||||
|
||||
def get_progress_producer() -> Any | None:
|
||||
return _progress_producer
|
||||
|
||||
|
||||
async def call_mcp_tool(
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
business_context: dict[str, Any] | None = None,
|
||||
original_context: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Call a framework MCP tool and normalize the result to a dict.
|
||||
|
||||
Returns {"ok": False, "error": ...} when the router is not configured or the
|
||||
tool fails, so domain nodes can decide whether to fail-open or fail-closed.
|
||||
"""
|
||||
router = get_tool_router()
|
||||
if router is None or not getattr(router, "enabled", False):
|
||||
return {"ok": False, "error": "framework MCP router not configured", "tool": tool_name}
|
||||
try:
|
||||
result = await router.call(
|
||||
tool_name,
|
||||
arguments or {},
|
||||
business_context=business_context or {},
|
||||
original_context=original_context or arguments or {},
|
||||
)
|
||||
if hasattr(result, "model_dump"):
|
||||
return result.model_dump(mode="json")
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
return {"ok": True, "result": result, "tool": tool_name}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("framework MCP call failed tool=%s error=%s", tool_name, exc, exc_info=True)
|
||||
return {"ok": False, "error": str(exc), "tool": tool_name}
|
||||
@@ -69,14 +69,14 @@ class Settings(BaseSettings):
|
||||
LLM_MAX_TOKENS: Optional[int] = Field(default=None, gt=0, description="Max tokens")
|
||||
|
||||
# Classification LLM settings
|
||||
CLASSIFICATION_LLM_MODEL: str = Field(default="bo_gptoss20b_dev", description="Model name for classification node")
|
||||
CLASSIFICATION_LLM_MODEL: str = Field(default="openai.gpt-4.1", description="Model name for classification node")
|
||||
CLASSIFICATION_LLM_TEMPERATURE: float = Field(default=0.3, ge=0.0, le=2.0, description="Temperature for classification node")
|
||||
CLASSIFICATION_LLM_MAX_TOKENS: int = Field(default=1024, gt=0, description="Max tokens for classification node")
|
||||
CLASSIFICATION_LLM_TOP_P: float = Field(default=0.8, description="Top P for classification node")
|
||||
CLASSIFICATION_LLM_TOP_K: float = Field(default=250, description="Top K for classification node")
|
||||
|
||||
# Large Classification LLM settings (for complex reasoning/canceling)
|
||||
CLASSIFICATION_LARGE_LLM_MODEL: str = Field(default="bo_gptoss120b_dev", description="Large model name for critical classification steps")
|
||||
CLASSIFICATION_LARGE_LLM_MODEL: str = Field(default="openai.gpt-4.1", description="Large model name for critical classification steps")
|
||||
CLASSIFICATION_LARGE_LLM_TEMPERATURE: float = Field(default=0.3, ge=0.0, le=2.0, description="Temperature for large classification node")
|
||||
CLASSIFICATION_LARGE_LLM_MAX_TOKENS: int = Field(default=1024, gt=0, description="Max tokens for large classification node")
|
||||
CLASSIFICATION_LARGE_LLM_TOP_P: float = Field(default=0.8, description="Top P for large classification node")
|
||||
@@ -201,6 +201,9 @@ class Settings(BaseSettings):
|
||||
# Anatel Dictionary settings
|
||||
USE_FULL_ANATEL_DICT: bool = Field(default=False, description="Whether to use the full Anatel motives dictionary instead of filtering by service")
|
||||
|
||||
# Migration control. Default False: domain nodes must use framework services/MCP/fallbacks.
|
||||
BACKOFFICE_ALLOW_LEGACY_CLIENTS: bool = Field(default=False, description="Allow direct legacy TIM clients as a parity escape hatch. Keep False for framework-native execution.")
|
||||
|
||||
# Speech Analytics API settings
|
||||
SPEECH_PREDICTION_BASE_URL: Optional[str] = Field(default=None, description="Base URL of the Speech Prediction API gateway (OAuth2 + prediction endpoint)")
|
||||
SPEECH_PREDICTION_CLIENT_ID: Optional[str] = Field(default=None, description="Client ID for the Speech Prediction OAuth2 client_credentials flow")
|
||||
@@ -230,7 +233,7 @@ class Settings(BaseSettings):
|
||||
TAIS_TABLE_CHUNKS: str = Field(default="CHUNKS_CHAR_COHERE_3", description="Oracle table containing TAIS chunks + embeddings")
|
||||
TAIS_TABLE_FILES: str = Field(default="files_oci", description="Oracle table containing TAIS raw documents")
|
||||
TAIS_TOP_K: int = Field(default=3, gt=0, description="Number of unique documents returned by TAIS KB search")
|
||||
TAIS_KB_LLM_MODEL: str = Field(default="bo_gptoss20b_dev", description="Modelo LLM para pós-processamento da KB (bo_gptoss20b_dev ou bo_gptoss120b_dev)")
|
||||
TAIS_KB_LLM_MODEL: str = Field(default="openai.gpt-4.1", description="Modelo LLM framework para pós-processamento da KB")
|
||||
TAIS_KB_LLM_TEMPERATURE: float = Field(default=0.3, ge=0.0, le=2.0)
|
||||
TAIS_KB_LLM_MAX_TOKENS: int = Field(default=4096, gt=0, description="Tokens de saída — manter alto para respostas completas")
|
||||
TAIS_KB_LLM_TOP_P: float = Field(default=0.9)
|
||||
|
||||
@@ -71,7 +71,7 @@ class _SettingsProxy:
|
||||
# OpenAI-compatible implementation.
|
||||
if self.LLM_PROVIDER == "oci":
|
||||
self.LLM_PROVIDER = "oci_openai"
|
||||
self.OCI_GENAI_MODEL = str(llm.eligibleModel_name)
|
||||
self.OCI_GENAI_MODEL = _framework_model_name(llm.eligibleModel_name)
|
||||
self.LLM_TEMPERATURE = float(llm.temperature)
|
||||
self.LLM_MAX_TOKENS = int(llm.max_tokens)
|
||||
|
||||
@@ -81,8 +81,24 @@ class _SettingsProxy:
|
||||
|
||||
LLM_ENDPOINT: str = getattr(fw_settings, "OCI_GENAI_BASE_URL", "")
|
||||
|
||||
|
||||
def _framework_model_name(candidate: str | None = None) -> str:
|
||||
"""Resolve any legacy backoffice model alias to the framework model.
|
||||
|
||||
Old develop configs used internal model aliases. In the migrated project those aliases must never be sent to OCI/OpenAI directly.
|
||||
"""
|
||||
model = str(candidate or "").strip()
|
||||
if not model or model.startswith("bo_") or "gptoss" in model.lower():
|
||||
model = (
|
||||
getattr(fw_settings, "OCI_GENAI_MODEL", None)
|
||||
or getattr(fw_settings, "LLM_MODEL", None)
|
||||
or getattr(settings, "LLM_MODEL", None)
|
||||
or "openai.gpt-4.1"
|
||||
)
|
||||
return str(model)
|
||||
|
||||
classification_llm = BackofficeLLMDescriptor(
|
||||
eligibleModel_name=settings.CLASSIFICATION_LLM_MODEL,
|
||||
eligibleModel_name=_framework_model_name(settings.CLASSIFICATION_LLM_MODEL),
|
||||
temperature=settings.CLASSIFICATION_LLM_TEMPERATURE,
|
||||
max_tokens=settings.CLASSIFICATION_LLM_MAX_TOKENS,
|
||||
top_p=settings.CLASSIFICATION_LLM_TOP_P,
|
||||
@@ -90,7 +106,7 @@ classification_llm = BackofficeLLMDescriptor(
|
||||
)
|
||||
|
||||
classification_large_llm = BackofficeLLMDescriptor(
|
||||
eligibleModel_name=settings.CLASSIFICATION_LARGE_LLM_MODEL,
|
||||
eligibleModel_name=_framework_model_name(settings.CLASSIFICATION_LARGE_LLM_MODEL),
|
||||
temperature=settings.CLASSIFICATION_LARGE_LLM_TEMPERATURE,
|
||||
max_tokens=settings.CLASSIFICATION_LARGE_LLM_MAX_TOKENS,
|
||||
top_p=settings.CLASSIFICATION_LARGE_LLM_TOP_P,
|
||||
@@ -98,7 +114,7 @@ classification_large_llm = BackofficeLLMDescriptor(
|
||||
)
|
||||
|
||||
tais_kb_llm = BackofficeLLMDescriptor(
|
||||
eligibleModel_name=settings.TAIS_KB_LLM_MODEL,
|
||||
eligibleModel_name=_framework_model_name(settings.TAIS_KB_LLM_MODEL),
|
||||
temperature=settings.TAIS_KB_LLM_TEMPERATURE,
|
||||
max_tokens=settings.TAIS_KB_LLM_MAX_TOKENS,
|
||||
top_p=settings.TAIS_KB_LLM_TOP_P,
|
||||
|
||||
Reference in New Issue
Block a user