Projeto do Agent Contas ORACLE

This commit is contained in:
2026-08-19 09:35:50 -03:00
commit 950a2bcd33
1366 changed files with 177217 additions and 0 deletions

0
app/__init__.py Normal file
View File

10
app/agents/README.md Normal file
View File

@@ -0,0 +1,10 @@
# Agentes do domínio Contas
Estes agentes são especializações finas do `AgentRuntimeMixin` do `agent_framework_oci`.
- `FaturasAgent`: faturas e explicação de cobrança.
- `VasAgent`: serviços VAS, histórico e serviços estratégicos.
- `ContestacaoAgent`: ações transacionais de cancelamento e contestação.
- `SuporteContasAgent`: protocolos, acompanhamento e encerramento.
Confirmação, clarificação, memória, RAG, guardrails, judges, MCP routing e telemetria não são reimplementados aqui; são capacidades do framework.

View File

@@ -0,0 +1,10 @@
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
import yaml
@lru_cache(maxsize=16)
def load_domain_prompt(name: str) -> str:
path = Path(__file__).resolve().parents[2] / "config" / "prompts" / f"{name}.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
return str(data.get("system") or "").strip()

View File

@@ -0,0 +1,132 @@
from app.agents.prompting import apply_agent_profile_prompt
from app.agents.contas_prompting import load_domain_prompt
from app.agents.runtime import AgentRuntimeMixin
class ContestacaoAgent(AgentRuntimeMixin):
name = "contestacao_agent"
def __init__(
self,
llm,
telemetry=None,
tool_router=None,
rag_service=None,
cache=None,
settings=None,
observer=None,
memory=None,
summary_memory=None,
guardrail_pipeline=None,
):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
self.memory = memory
self.summary_memory = summary_memory
self.guardrail_pipeline = guardrail_pipeline
async def run(self, state):
await self._emit_ic(
"IC.CONTESTACAO_AGENT_STARTED",
state,
{"business_component": "contestacao"},
component="agent.contestacao.start",
)
tool_context = await self._collect_tool_context(state)
if tool_context:
await self._emit_ic(
"IC.CONTESTACAO_MCP_CONTEXT_COLLECTED",
state,
{"tool_result_count": len(tool_context)},
component="agent.contestacao.mcp",
)
state["mcp_results"] = tool_context
clarification_message = self.transaction_clarification_message(state)
if clarification_message:
return {
"answer": f"[{self.__class__.__name__}] {clarification_message}",
"next_state": state.get("next_state") or "COLLECTING_PARAMETERS",
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
confirmation_message = self.transaction_confirmation_message(state)
if confirmation_message:
result = {
"answer": f"[{self.__class__.__name__}] {confirmation_message}",
"next_state": state.get("next_state"),
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
return result
direct_answer = self.build_direct_mcp_answer(state, tool_context, agent_label="ContestacaoAgent")
if direct_answer:
return {
"answer": direct_answer,
"next_state": state.get("next_state") or "ACTIVE",
"mcp_results": tool_context,
"rag": {"enabled": False, "skipped": True, "reason": "direct_mcp_answer"},
**self.transaction_state_patch(state),
}
rag_context, rag_metadata = await self._retrieve_rag_context(state)
if rag_metadata.get("enabled"):
await self._emit_ic(
"IC.CONTESTACAO_RAG_CONTEXT_RETRIEVED",
state,
{
"document_count": rag_metadata.get("document_count"),
"graph_neighbors": rag_metadata.get("graph_neighbors"),
"latency_ms": rag_metadata.get("latency_ms"),
},
component="agent.contestacao.rag",
)
# Prepara ConversationSummaryMemory antes de montar o prompt.
# O build_messages() do framework injeta resumo + últimas mensagens quando habilitado.
await self.prepare_memory_context(state)
messages = self.build_messages(
state,
system_prompt=apply_agent_profile_prompt(
state,
load_domain_prompt("contestation"),
),
mcp_results=tool_context,
rag_context=rag_context,
rag_metadata=rag_metadata,
)
answer = await self._invoke_llm_cached(state, "ContestacaoAgent", messages)
result = {
"answer": f"[ContestacaoAgent] {answer}",
"next_state": "CONTESTACAO_ACTIVE",
"mcp_results": tool_context,
"rag": rag_metadata,
"memory_context_metadata": state.get("memory_context_metadata"),
**self.transaction_state_patch(state),
}
await self._emit_ic(
"IC.CONTESTACAO_AGENT_COMPLETED",
state,
{
"answer_chars": len(result.get("answer") or ""),
"has_mcp_results": bool(tool_context),
"rag_enabled": bool(rag_metadata.get("enabled")),
"memory_context": state.get("memory_context_metadata"),
},
component="agent.contestacao.completed",
)
return result
async def _collect_tool_context(self, state):
return await self._collect_mcp_context(state)

132
app/agents/faturas_agent.py Normal file
View File

@@ -0,0 +1,132 @@
from app.agents.prompting import apply_agent_profile_prompt
from app.agents.contas_prompting import load_domain_prompt
from app.agents.runtime import AgentRuntimeMixin
class FaturasAgent(AgentRuntimeMixin):
name = "faturas_agent"
def __init__(
self,
llm,
telemetry=None,
tool_router=None,
rag_service=None,
cache=None,
settings=None,
observer=None,
memory=None,
summary_memory=None,
guardrail_pipeline=None,
):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
self.memory = memory
self.summary_memory = summary_memory
self.guardrail_pipeline = guardrail_pipeline
async def run(self, state):
await self._emit_ic(
"IC.FATURAS_AGENT_STARTED",
state,
{"business_component": "faturas"},
component="agent.faturas.start",
)
tool_context = await self._collect_tool_context(state)
if tool_context:
await self._emit_ic(
"IC.FATURAS_MCP_CONTEXT_COLLECTED",
state,
{"tool_result_count": len(tool_context)},
component="agent.faturas.mcp",
)
state["mcp_results"] = tool_context
clarification_message = self.transaction_clarification_message(state)
if clarification_message:
return {
"answer": f"[{self.__class__.__name__}] {clarification_message}",
"next_state": state.get("next_state") or "COLLECTING_PARAMETERS",
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
confirmation_message = self.transaction_confirmation_message(state)
if confirmation_message:
result = {
"answer": f"[{self.__class__.__name__}] {confirmation_message}",
"next_state": state.get("next_state"),
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
return result
direct_answer = self.build_direct_mcp_answer(state, tool_context, agent_label="FaturasAgent")
if direct_answer:
return {
"answer": direct_answer,
"next_state": state.get("next_state") or "ACTIVE",
"mcp_results": tool_context,
"rag": {"enabled": False, "skipped": True, "reason": "direct_mcp_answer"},
**self.transaction_state_patch(state),
}
rag_context, rag_metadata = await self._retrieve_rag_context(state)
if rag_metadata.get("enabled"):
await self._emit_ic(
"IC.FATURAS_RAG_CONTEXT_RETRIEVED",
state,
{
"document_count": rag_metadata.get("document_count"),
"graph_neighbors": rag_metadata.get("graph_neighbors"),
"latency_ms": rag_metadata.get("latency_ms"),
},
component="agent.faturas.rag",
)
# Prepara ConversationSummaryMemory antes de montar o prompt.
# O build_messages() do framework injeta resumo + últimas mensagens quando habilitado.
await self.prepare_memory_context(state)
messages = self.build_messages(
state,
system_prompt=apply_agent_profile_prompt(
state,
load_domain_prompt("billing"),
),
mcp_results=tool_context,
rag_context=rag_context,
rag_metadata=rag_metadata,
)
answer = await self._invoke_llm_cached(state, "FaturasAgent", messages)
result = {
"answer": f"[FaturasAgent] {answer}",
"next_state": "FATURAS_ACTIVE",
"mcp_results": tool_context,
"rag": rag_metadata,
"memory_context_metadata": state.get("memory_context_metadata"),
**self.transaction_state_patch(state),
}
await self._emit_ic(
"IC.FATURAS_AGENT_COMPLETED",
state,
{
"answer_chars": len(result.get("answer") or ""),
"has_mcp_results": bool(tool_context),
"rag_enabled": bool(rag_metadata.get("enabled")),
"memory_context": state.get("memory_context_metadata"),
},
component="agent.faturas.completed",
)
return result
async def _collect_tool_context(self, state):
return await self._collect_mcp_context(state)

15
app/agents/prompting.py Normal file
View File

@@ -0,0 +1,15 @@
from __future__ import annotations
def apply_agent_profile_prompt(state: dict, default_prompt: str) -> str:
"""Adiciona o prefixo de prompt configurado para o agent_template selecionado.
Cada agent_id pode definir metadata.system_prefix em config/agents.yaml. Isso
mantém prompts isolados sem duplicar o código dos agentes especializados.
"""
profile = state.get("agent_profile") or (state.get("context") or {}).get("agent_profile") or {}
metadata = profile.get("metadata") or {}
prefix = (metadata.get("system_prefix") or "").strip()
if not prefix:
return default_prompt
return f"{prefix}\n\n{default_prompt}"

7
app/agents/runtime.py Normal file
View File

@@ -0,0 +1,7 @@
from __future__ import annotations
# Compatibilidade local do template/backend.
# A implementação oficial agora fica no framework para evitar duplicação entre agentes.
from agent_framework.runtime import AgentRuntimeMixin, MessageBuilder, RuntimeContext
__all__ = ["AgentRuntimeMixin", "MessageBuilder", "RuntimeContext"]

View File

@@ -0,0 +1,138 @@
from app.agents.prompting import apply_agent_profile_prompt
from app.agents.contas_prompting import load_domain_prompt
from app.agents.runtime import AgentRuntimeMixin
from app.domain.contas.informational_context import infer_informational_context
class SuporteContasAgent(AgentRuntimeMixin):
name = "suporte_contas_agent"
def __init__(
self,
llm,
telemetry=None,
tool_router=None,
rag_service=None,
cache=None,
settings=None,
observer=None,
memory=None,
summary_memory=None,
guardrail_pipeline=None,
):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
self.memory = memory
self.summary_memory = summary_memory
self.guardrail_pipeline = guardrail_pipeline
async def run(self, state):
await self._emit_ic(
"IC.SUPORTE_CONTAS_AGENT_STARTED",
state,
{"business_component": "suporte"},
component="agent.suporte_contas.start",
)
tool_context = await self._collect_tool_context(state)
if tool_context:
await self._emit_ic(
"IC.SUPORTE_CONTAS_MCP_CONTEXT_COLLECTED",
state,
{"tool_result_count": len(tool_context)},
component="agent.suporte_contas.mcp",
)
state["mcp_results"] = tool_context
clarification_message = self.transaction_clarification_message(state)
if clarification_message:
return {
"answer": f"[{self.__class__.__name__}] {clarification_message}",
"next_state": state.get("next_state") or "COLLECTING_PARAMETERS",
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
confirmation_message = self.transaction_confirmation_message(state)
if confirmation_message:
result = {
"answer": f"[{self.__class__.__name__}] {confirmation_message}",
"next_state": state.get("next_state"),
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
return result
direct_answer = self.build_direct_mcp_answer(state, tool_context, agent_label="SuporteContasAgent")
if direct_answer:
return {
"answer": direct_answer,
"next_state": state.get("next_state") or "ACTIVE",
"mcp_results": tool_context,
"rag": {"enabled": False, "skipped": True, "reason": "direct_mcp_answer"},
**self.transaction_state_patch(state),
}
rag_context, rag_metadata = await self._retrieve_rag_context(state)
informational_patch = infer_informational_context(
str(rag_metadata.get("query") or state.get("sanitized_input") or state.get("user_text") or ""),
state.get("invoice_detail"),
) if rag_metadata.get("enabled") else {}
if rag_metadata.get("enabled"):
await self._emit_ic(
"IC.SUPORTE_CONTAS_RAG_CONTEXT_RETRIEVED",
state,
{
"document_count": rag_metadata.get("document_count"),
"graph_neighbors": rag_metadata.get("graph_neighbors"),
"latency_ms": rag_metadata.get("latency_ms"),
},
component="agent.suporte_contas.rag",
)
# Prepara ConversationSummaryMemory antes de montar o prompt.
# O build_messages() do framework injeta resumo + últimas mensagens quando habilitado.
await self.prepare_memory_context(state)
messages = self.build_messages(
state,
system_prompt=apply_agent_profile_prompt(
state,
load_domain_prompt("support"),
),
mcp_results=tool_context,
rag_context=rag_context,
rag_metadata=rag_metadata,
)
answer = await self._invoke_llm_cached(state, "SuporteContasAgent", messages)
result = {
"answer": f"[SuporteContasAgent] {answer}",
"next_state": "SUPORTE_CONTAS_ACTIVE",
"mcp_results": tool_context,
"rag": rag_metadata,
"memory_context_metadata": state.get("memory_context_metadata"),
**informational_patch,
**self.transaction_state_patch(state),
}
await self._emit_ic(
"IC.SUPORTE_CONTAS_AGENT_COMPLETED",
state,
{
"answer_chars": len(result.get("answer") or ""),
"has_mcp_results": bool(tool_context),
"rag_enabled": bool(rag_metadata.get("enabled")),
"memory_context": state.get("memory_context_metadata"),
},
component="agent.suporte_contas.completed",
)
return result
async def _collect_tool_context(self, state):
return await self._collect_mcp_context(state)

138
app/agents/vas_agent.py Normal file
View File

@@ -0,0 +1,138 @@
from app.agents.prompting import apply_agent_profile_prompt
from app.agents.contas_prompting import load_domain_prompt
from app.agents.runtime import AgentRuntimeMixin
from app.domain.contas.informational_context import infer_informational_context
class VasAgent(AgentRuntimeMixin):
name = "vas_agent"
def __init__(
self,
llm,
telemetry=None,
tool_router=None,
rag_service=None,
cache=None,
settings=None,
observer=None,
memory=None,
summary_memory=None,
guardrail_pipeline=None,
):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
self.memory = memory
self.summary_memory = summary_memory
self.guardrail_pipeline = guardrail_pipeline
async def run(self, state):
await self._emit_ic(
"IC.VAS_AGENT_STARTED",
state,
{"business_component": "vas"},
component="agent.vas.start",
)
tool_context = await self._collect_tool_context(state)
if tool_context:
await self._emit_ic(
"IC.VAS_MCP_CONTEXT_COLLECTED",
state,
{"tool_result_count": len(tool_context)},
component="agent.vas.mcp",
)
state["mcp_results"] = tool_context
clarification_message = self.transaction_clarification_message(state)
if clarification_message:
return {
"answer": f"[{self.__class__.__name__}] {clarification_message}",
"next_state": state.get("next_state") or "COLLECTING_PARAMETERS",
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
confirmation_message = self.transaction_confirmation_message(state)
if confirmation_message:
result = {
"answer": f"[{self.__class__.__name__}] {confirmation_message}",
"next_state": state.get("next_state"),
"mcp_results": tool_context,
**self.transaction_state_patch(state),
}
return result
direct_answer = self.build_direct_mcp_answer(state, tool_context, agent_label="VasAgent")
if direct_answer:
return {
"answer": direct_answer,
"next_state": state.get("next_state") or "ACTIVE",
"mcp_results": tool_context,
"rag": {"enabled": False, "skipped": True, "reason": "direct_mcp_answer"},
**self.transaction_state_patch(state),
}
rag_context, rag_metadata = await self._retrieve_rag_context(state)
informational_patch = infer_informational_context(
str(rag_metadata.get("query") or state.get("sanitized_input") or state.get("user_text") or ""),
state.get("invoice_detail"),
) if rag_metadata.get("enabled") else {}
if rag_metadata.get("enabled"):
await self._emit_ic(
"IC.VAS_RAG_CONTEXT_RETRIEVED",
state,
{
"document_count": rag_metadata.get("document_count"),
"graph_neighbors": rag_metadata.get("graph_neighbors"),
"latency_ms": rag_metadata.get("latency_ms"),
},
component="agent.vas.rag",
)
# Prepara ConversationSummaryMemory antes de montar o prompt.
# O build_messages() do framework injeta resumo + últimas mensagens quando habilitado.
await self.prepare_memory_context(state)
messages = self.build_messages(
state,
system_prompt=apply_agent_profile_prompt(
state,
load_domain_prompt("vas"),
),
mcp_results=tool_context,
rag_context=rag_context,
rag_metadata=rag_metadata,
)
answer = await self._invoke_llm_cached(state, "VasAgent", messages)
result = {
"answer": f"[VasAgent] {answer}",
"next_state": "VAS_ACTIVE",
"mcp_results": tool_context,
"rag": rag_metadata,
"memory_context_metadata": state.get("memory_context_metadata"),
**informational_patch,
**self.transaction_state_patch(state),
}
await self._emit_ic(
"IC.VAS_AGENT_COMPLETED",
state,
{
"answer_chars": len(result.get("answer") or ""),
"has_mcp_results": bool(tool_context),
"rag_enabled": bool(rag_metadata.get("enabled")),
"memory_context": state.get("memory_context_metadata"),
},
component="agent.vas.completed",
)
return result
async def _collect_tool_context(self, state):
return await self._collect_mcp_context(state)

1
app/domain/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Business domains hosted by the framework-native application."""

View File

@@ -0,0 +1,4 @@
"""TIM Contas business domain for the framework-native application."""
from .service import ContasDomainService
__all__ = ["ContasDomainService"]

566
app/domain/contas/client.py Normal file
View File

@@ -0,0 +1,566 @@
from __future__ import annotations
import json
import os
import time
import uuid
from pathlib import Path
from typing import Any
from urllib.parse import quote
import requests
from .integrations.secure_pdf_crypto import encrypt_secure_pdf_value
from .parsers import parse_tim_bill_pdf
class TimApiError(RuntimeError):
def __init__(self, message: str, *, status_code: int | None = None, body: Any = None, attempts: list[dict[str, Any]] | None = None):
super().__init__(message)
self.status_code = status_code
self.body = body
self.attempts = list(attempts or [])
class TimApiClient:
"""Thin TIM integration adapter.
This class deliberately contains no agent, LangGraph, memory, routing or LLM logic.
Those responsibilities belong to agent_framework_oci.
"""
FIXTURES = Path(__file__).with_name("fixtures")
def __init__(self) -> None:
self.mock = os.getenv("TIM_USE_MOCK_GATEWAY", "true").lower() in {"1", "true", "yes", "on"} or os.getenv("TIM_GATEWAY_MODE", "mock").lower() == "mock"
self.timeout = int(os.getenv("TIM_GATEWAY_DEFAULT_TIMEOUT", "30"))
self.max_retries = int(os.getenv("TIM_GATEWAY_RETRY_MAX_RETRIES", "3"))
self.backoff = float(os.getenv("TIM_GATEWAY_RETRY_BACKOFF_FACTOR", "0.5"))
def fixture(self, name: str) -> Any:
path = self.FIXTURES / f"{name}.json"
return json.loads(path.read_text(encoding="utf-8"))
def _headers(self, *, client_id: str | None = None, auth: str | None = None, extra: dict[str, str] | None = None) -> dict[str, str]:
h = {"Content-Type": "application/json", "Accept": "application/json"}
if client_id:
h["client_id"] = client_id
if auth:
h["Authorization"] = auth
if extra:
h.update({k: v for k, v in extra.items() if v not in (None, "")})
return h
def request(self, method: str, url: str, *, payload: dict[str, Any] | None = None, params: dict[str, Any] | None = None, headers: dict[str, str] | None = None, timeout: int | None = None, attempt_log: list[dict[str, Any]] | None = None) -> Any:
if not url:
raise TimApiError("Endpoint TIM não configurado")
last: Exception | None = None
attempts = max(1, self.max_retries)
for attempt in range(attempts):
started = time.monotonic()
try:
resp = requests.request(method, url, json=payload, params=params, headers=headers or {}, timeout=timeout or self.timeout)
if resp.status_code in {204, 205}:
if attempt_log is not None:
attempt_log.append({"attempt": attempt + 1, "success": True, "status_code": resp.status_code, "latency_ms": int((time.monotonic()-started)*1000), "api_url": url})
return {"status": resp.status_code, "message": "OK"}
resp.raise_for_status()
if attempt_log is not None:
attempt_log.append({"attempt": attempt + 1, "success": True, "status_code": resp.status_code, "latency_ms": int((time.monotonic()-started)*1000), "api_url": url})
if not resp.content:
return {"status": resp.status_code, "message": "OK"}
ctype = resp.headers.get("content-type", "").lower()
if "json" in ctype:
return resp.json()
return {"status": resp.status_code, "raw_content": resp.content, "content_type": ctype}
except requests.RequestException as exc:
last = exc
if attempt_log is not None:
response = getattr(exc, "response", None)
attempt_log.append({"attempt": attempt + 1, "success": False, "status_code": getattr(response, "status_code", None), "latency_ms": int((time.monotonic()-started)*1000), "error": str(exc), "api_url": url})
if attempt >= attempts - 1:
break
time.sleep(self.backoff * (2 ** attempt))
response = getattr(last, "response", None)
status_code = getattr(response, "status_code", None)
body = None
if response is not None:
try:
body = response.json()
except Exception:
body = getattr(response, "text", None)
raise TimApiError(str(last), status_code=status_code, body=body, attempts=list(attempt_log or []))
@staticmethod
def _attach_transport(result: Any, *, operation: str, attempts: list[dict[str, Any]]) -> Any:
metadata = {
"rct_operation": operation,
"attempts": list(attempts),
"api_response_payload": result if isinstance(result, (dict, list, str, int, float, bool)) else str(result),
}
if isinstance(result, dict):
out = dict(result)
out["_transport"] = metadata
return out
return {"data": result, "_transport": metadata}
def consultar_faturas(self, msisdn: str) -> Any:
if self.mock:
return self.fixture("complete_invoices")
url = os.getenv("TIM_COMPLETE_INVOICES_URL", "")
return self.request("POST", url, payload={"msisdn": msisdn}, headers=self._headers(client_id=None, auth=os.getenv("TIM_COMPLETE_INVOICES_AUTH", ""), extra={"ClientID": os.getenv("TIM_COMPLETE_INVOICES_CLIENT_ID", "AIAGENTCR")}))
def billing_analysis(self, msisdn: str, **context: Any) -> Any:
if self.mock:
return self.fixture("divergencia")
base = os.getenv("TIM_DIVERGENCIA_URL", "").rstrip("/")
url = f"{base}/{quote(msisdn)}"
auth = os.getenv("TIM_DIVERGENCIA_AUTH", "") or os.getenv("TIM_SECURE_PDF_AUTH", "")
headers = self._headers(client_id=None, auth=auth, extra={
"clientID": os.getenv("TIM_DIVERGENCIA_CLIENT_ID", "AIAGENTCR"),
})
params = {"channel": context.get("channel") or "AIAGENTCR"}
attempts: list[dict[str, Any]] = []
try:
result = self.request("GET", url, params=params, headers=headers, timeout=int(os.getenv("TIM_DIVERGENCIA_TIMEOUT", "120")), attempt_log=attempts)
return self._attach_transport(result, operation="base_conhecimento", attempts=attempts)
except TimApiError as exc:
if not exc.attempts:
exc.attempts = attempts
raise
def consultar_vas(self, msisdn: str) -> Any:
if self.mock:
return self.fixture("query_vas")
base = os.getenv("TIM_URL_CONSULTA_VAS", "")
url = base.replace("{msisdn}", quote(msisdn))
return self.request("GET", url, headers=self._headers(client_id=None, auth=os.getenv("TIM_CONSULTA_AUTH", ""), extra={"clientId": os.getenv("TIM_CONSULTA_CLIENT_ID", "AIAAGENTCR")}))
def historico_vas(self, msisdn: str) -> Any:
if self.mock:
return self.fixture("vas_history")
base = os.getenv("TIM_VAS_HISTORY_URL", "")
sep = "&" if "?" in base else "?"
url = f"{base}{sep}msisdn={quote(msisdn)}"
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_VAS_HISTORY_AUTH", ""),
extra={
"clientId": os.getenv("TIM_VAS_HISTORY_CLIENT_ID", os.getenv("TIM_DEFAULT_CLIENT_ID", "CHAT")),
"messageId": os.getenv("TIM_VAS_HISTORY_MESSAGE_ID", "") or str(uuid.uuid4()),
},
)
return self.request("GET", url, headers=headers, timeout=int(os.getenv("TIM_VAS_HISTORY_TIMEOUT", "30")))
def bloquear_vas(self, msisdn: str, service: dict[str, Any]) -> Any:
if self.mock:
return self.fixture("block_vas")
url = os.getenv("TIM_URL_BLOQUEIO_VAS", "")
digits = "".join(ch for ch in str(msisdn) if ch.isdigit())
normalized = digits[2:] if len(digits) == 13 else digits
app_id = str(service.get("appId") or service.get("app_id") or "")
csp_id = str(service.get("cspId") or service.get("csp_id") or os.getenv("TIM_DEFAULT_CSP_ID", "740"))
op = os.getenv("TIM_BLOQUEIO_OPERATION_TYPE", "block")
base = {"Customer": {"Msisdn": normalized}, "AppId": app_id, "CspId": csp_id, "TypeOperation": op}
payloads = [
{"customer": {"msisdn": normalized}, "appId": app_id, "cspId": csp_id, "typeOperation": op},
{"input": base, "Input": base},
{"vasBlock": {"msisdn": normalized, "appId": app_id, "cspId": csp_id, "type": op}},
]
mode = os.getenv("TIM_BLOQUEIO_PAYLOAD_MODE", "auto").strip().lower()
if mode == "vasblock": payloads = [payloads[2], payloads[0]]
elif mode == "input": payloads = [payloads[1], payloads[0]]
elif mode == "pmid": payloads = [payloads[0], payloads[1]]
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_BLOQUEIO_AUTH", ""),
extra={
"Accept-Encoding": os.getenv("TIM_BLOQUEIO_ACCEPT_ENCODING", "gzip,deflate"),
"clientId": os.getenv("TIM_BLOQUEIO_CLIENT_ID", "AIAGENTCR"),
"messageId": str(uuid.uuid4()),
},
)
last = None
for index, payload in enumerate(payloads):
try:
return self.request("POST", url, payload=payload, headers=headers, timeout=int(os.getenv("TIM_BLOQUEIO_TIMEOUT", "30")))
except TimApiError as exc:
last = exc
if exc.status_code != 400 or index >= len(payloads) - 1:
raise
raise last or TimApiError("Bloqueio VAS falhou")
def cancelar_vas(self, msisdn: str, service: dict[str, Any], *, protocol: str = "") -> Any:
if self.mock:
return self.fixture("cancel_vas")
url = os.getenv("TIM_CANCELAMENTO_URL", "")
payload = {
"channel": "AIAGENTCR",
"msisdn": msisdn,
"appId": str(service.get("appId") or service.get("app_id") or ""),
"cspId": str(service.get("cspId") or service.get("csp_id") or os.getenv("TIM_DEFAULT_CSP_ID", "740")),
"interactionProtocol": protocol,
}
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_CANCELAMENTO_AUTH", ""),
extra={
"clientId": os.getenv("TIM_CANCELAMENTO_CLIENT_ID", os.getenv("TIM_DEFAULT_CLIENT_ID", "AIAGENTCR")),
"messageId": str(uuid.uuid4()),
"AuthorizationOAM": os.getenv("TIM_CANCELAMENTO_AUTHORIZATION_OAM", ""),
"Cn_field": os.getenv("TIM_CANCELAMENTO_CN_FIELD", ""),
"Type_field": os.getenv("TIM_CANCELAMENTO_TYPE_FIELD", ""),
},
)
attempts: list[dict[str, Any]] = []
result = self.request("DELETE", url, payload=payload, headers=headers, timeout=int(os.getenv("TIM_CANCELAMENTO_TIMEOUT", "30")), attempt_log=attempts)
return self._attach_transport(result, operation="cancela_vas", attempts=attempts)
def contrato(self, msisdn: str) -> Any:
if self.mock:
return self.fixture("contrato")
base = os.getenv("TIM_CONTRATO_URL", "").rstrip("/")
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_CONTRATO_AUTH", ""),
extra={"clientId": os.getenv("TIM_CONTRATO_CLIENT_ID", os.getenv("TIM_DEFAULT_CLIENT_ID", "CHAT"))},
)
return self.request("GET", f"{base}/{quote(msisdn)}", headers=headers, timeout=int(os.getenv("TIM_CONTRATO_TIMEOUT", "30")))
def profile_full(self, msisdn: str) -> Any:
if self.mock:
# contract fixture carries representative customer identity in local mode.
return self.fixture("contrato")
url = os.getenv("TIM_PROFILE_FULL_URL", "").replace("{msisdn}", quote(msisdn))
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_PROFILE_FULL_AUTH", ""),
extra={"ClientID": os.getenv("TIM_PROFILE_FULL_CLIENT_ID", "AIAGENTCR")},
)
return self.request("GET", url, headers=headers)
def abrir_protocolo(self, payload: dict[str, Any]) -> Any:
"""Registra protocolo V2 preservando o contrato externo do Contas original.
As workflow actions trabalham com um payload lógico/achatado. O adapter é
responsável por converter esse modelo para o contrato PMid/Siebel, sem
contaminar o domínio ou o WorkflowRuntime com detalhes HTTP.
"""
if self.mock:
return self.fixture("protocol")
data = dict(payload or {})
msisdn = str(data.get("msisdn") or data.get("assetId") or "")
service_status = str(data.get("requestStatus") or data.get("status") or "")
message_id = str(
data.get("messageId")
or data.get("interactionCallId")
or data.get("ura_call_id")
or data.get("session_id")
or uuid.uuid4()
)
body = {
"socialSecNo": str(data.get("socialSecNo") or data.get("social_sec_no") or ""),
"channel": "AIAGENTCR",
"customerId": str(data.get("customerId") or data.get("customer_id") or ""),
"assetId": str(data.get("assetId") or data.get("asset_id") or msisdn),
"customerName": str(data.get("customerName") or data.get("customer_name") or ""),
"customerEmail": str(data.get("customerEmail") or data.get("customer_email") or ""),
"customerPhone1": str(data.get("customerPhone1") or data.get("customer_phone1") or ""),
"accessType": str(data.get("accessType") or data.get("access_type") or ""),
"serviceRequest": {
"userId": str(data.get("serviceRequestUserId") or data.get("service_request_user_id") or ""),
"reason1": str(data.get("reason1") or ""),
"reason2": str(data.get("reason2") or ""),
"reason3": str(data.get("reason3") or ""),
"status": service_status,
"notes": str(data.get("serviceRequestNotes") or data.get("service_request_notes") or data.get("notes") or ""),
"type": str(data.get("type") or "CLIENTE"),
},
"interaction": {
"protocol": str(data.get("interactionProtocol") or data.get("interaction_protocol") or ""),
"flagSms": True,
"source": "AIAGENTCR",
"callId": str(data.get("interactionCallId") or data.get("interaction_call_id") or ""),
"crmSource": "Siebel Pós",
"reasonId": str(data.get("interactionReasonId") or data.get("interaction_reason_id") or ""),
"amount": str(data.get("interactionAmount") or data.get("interaction_amount") or ""),
"directionContact": str(data.get("directionContact") or data.get("direction_contact") or "FROM-CLIENT"),
"requestFlag": False,
"requestSla": str(data.get("interactionRequestSla") or data.get("interaction_request_sla") or ""),
"status": str(data.get("status") or service_status),
},
}
headers = self._headers(
client_id=None,
auth=str(data.get("Authorization") or os.getenv("TIM_PROTOCOL_AUTH", "")),
extra={
"clientId": str(data.get("clientId") or data.get("client_id") or os.getenv("TIM_PROTOCOL_CLIENT_ID", "BFFDIGITAL")),
"messageId": message_id,
"Authorizationoam": str(data.get("authorization_oam") or os.getenv("TIM_PROTOCOL_AUTHORIZATION_OAM", "")),
"Cn_field": str(data.get("cn_field") or os.getenv("TIM_PROTOCOL_CN_FIELD", "")),
"Type_field": str(data.get("type_field") or os.getenv("TIM_PROTOCOL_TYPE_FIELD", "")),
},
)
attempts: list[dict[str, Any]] = []
result = self.request(
"POST",
os.getenv("TIM_PROTOCOL_URL", ""),
payload=body,
headers=headers,
timeout=int(os.getenv("TIM_PROTOCOL_TIMEOUT", "30")),
attempt_log=attempts,
)
operation = str(data.get("rct_operation") or "")
return self._attach_transport(result, operation=operation, attempts=attempts) if operation else result
def contestar(self, payload: dict[str, Any]) -> Any:
if self.mock:
return self.fixture("contestacao_tool")
data = dict(payload)
data.setdefault("userId", "AIAGENTCR")
data.setdefault("customerIdCurrent", data.get("customerId") or "")
data.setdefault("customerType", "2")
data.setdefault("customerStatus", "1")
status_map = {"ABERTA": "0", "ABERTO": "0", "OPEN": "0", "FECHADA": "1", "FECHADO": "1", "CLOSED": "1"}
raw_status = str(data.get("invoiceStatus") or "").strip()
data["invoiceStatus"] = status_map.get(raw_status.upper(), raw_status or "0")
data.setdefault("invoiceAmountOpen", "0")
data.setdefault("invoiceAmount", "0")
due = "".join(ch for ch in str(data.get("invoiceDueDate") or "") if ch.isdigit())
if len(due) == 8 and str(data.get("invoiceDueDate") or "").startswith(tuple(str(y) for y in range(19, 22))):
# YYYYMMDD already normalized
pass
elif len(due) == 8:
# common ddMMyyyy -> yyyyMMdd
due = due[4:]+due[2:4]+due[:2]
data["invoiceDueDate"] = due
data.setdefault("contestationType", "0")
data.setdefault("adjustReason", "SERVICO_NAO_SOLICITADO")
data.setdefault("observation", data.pop("description", ""))
data.setdefault("refundOption", "0")
data.setdefault("doubleRefund", False)
data.setdefault("manualContaCertaIndicator", False)
client_id = str(data.pop("clientId", "") or os.getenv("TIM_CUSTOMER_CONTESTATION_CLIENT_ID", "AIAGENTCR"))
message_id = str(data.pop("messageId", "") or uuid.uuid4())
user_id = str(data.get("userId") or "AIAGENTCR").upper()
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_CUSTOMER_CONTESTATION_AUTH", ""),
extra={"clientId": client_id, "messageId": message_id, "X-Agent-Id": user_id},
)
attempts: list[dict[str, Any]] = []
result = self.request("POST", os.getenv("TIM_CUSTOMER_CONTESTATION_URL", ""), payload=data, headers=headers, timeout=int(os.getenv("TIM_CUSTOMER_CONTESTATION_TIMEOUT", "30")), attempt_log=attempts)
return self._attach_transport(result, operation="contestacao", attempts=attempts)
def status_sr(self, payload: dict[str, Any]) -> Any:
if self.mock:
return self.fixture("service_request_status")
data = dict(payload)
service_request = data.get("serviceRequest") if isinstance(data.get("serviceRequest"), dict) else {
"status": data.get("status") or "",
"notes": data.get("notes") or "",
"protocolNumber": data.get("protocolNumber") or data.get("protocol") or "",
"auditResult": data.get("auditResult") or "",
"auditReasonResult": data.get("auditReasonResult") or "",
"auditConvenienceTime": data.get("auditConvenienceTime") or "",
"auditSubStatus": data.get("auditSubStatus") or "",
}
for key in ("reason1", "reason2", "reason3", "auditChecks", "attachments"):
if data.get(key): service_request[key] = data[key]
body = {"channel": data.get("channel") or "AIAGENTCR", "serviceRequest": service_request}
for key in ("msisdn", "customers", "date"):
if data.get(key): body[key] = data[key]
message_id = str(data.get("messageId") or data.get("ura_call_id") or data.get("session_id") or uuid.uuid4())
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_SERVICE_REQUEST_STATUS_AUTH", ""),
extra={
"clientId": str(data.get("clientId") or os.getenv("TIM_SERVICE_REQUEST_STATUS_CLIENT_ID", "AIAGENTCR")),
"messageId": message_id,
"Authorizationoam": data.get("authorization_oam") or os.getenv("TIM_SERVICE_REQUEST_STATUS_AUTHORIZATION_OAM", ""),
"Cn_field": data.get("cn_field") or os.getenv("TIM_SERVICE_REQUEST_STATUS_CN_FIELD", ""),
"Type_field": data.get("type_field") or os.getenv("TIM_SERVICE_REQUEST_STATUS_TYPE_FIELD", ""),
},
)
attempts: list[dict[str, Any]] = []
result = self.request(
"POST", os.getenv("TIM_SERVICE_REQUEST_STATUS_URL", ""), payload=body, headers=headers,
timeout=int(os.getenv("TIM_SERVICE_REQUEST_STATUS_TIMEOUT", "30")), attempt_log=attempts
)
return self._attach_transport(result, operation="", attempts=attempts)
def tracking(self, payload: dict[str, Any]) -> Any:
if self.mock:
return self.fixture("tracking_activities")
data = dict(payload)
invoice = data.get("invoice") if isinstance(data.get("invoice"), dict) else {
"emissionDate": data.get("invoiceEmissionDate") or "",
"expirationDate": data.get("invoiceExpirationDate") or "",
"number": data.get("invoiceNumber") or "",
"status": data.get("invoiceStatus") or "",
"openAmount": data.get("invoiceOpenAmount") or "",
"totalAmount": data.get("invoiceTotalAmount") or "",
}
body = {
"channel": data.get("channel") or os.getenv("TIM_TRACKING_ACTIVITIES_CHANNEL", "AIAGENTCR"),
"customer": {"socialSecNo": data.get("socialSecNo") or "", "msisdn": data.get("msisdn") or ""},
"protocolNumber": data.get("protocolNumber") or "",
"invoice": invoice,
"activity": {
"type": data.get("activityType") or "",
"status": data.get("activityStatus") or "",
"id": data.get("activityId") or "",
},
"user": {"login": data.get("userLogin") or os.getenv("TIM_TRACKING_ACTIVITIES_USER_LOGIN", "SIEBELPOS_INBOUND")},
}
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_TRACKING_ACTIVITIES_AUTH", ""),
extra={"clientId": data.get("clientId") or os.getenv("TIM_TRACKING_ACTIVITIES_CLIENT_ID", "AIAGENTCR")},
)
return self.request("POST", os.getenv("TIM_TRACKING_ACTIVITIES_URL", ""), payload=body, headers=headers, timeout=int(os.getenv("TIM_TRACKING_ACTIVITIES_TIMEOUT", "30")))
def sms(self, msisdn: str, message: str, **context: Any) -> Any:
if self.mock:
return self.fixture("sms")
payload: dict[str, Any] = {
"msisdn": msisdn,
"senderAddress": context.get("sender_address") or os.getenv("TIM_SMS_SENDER_ADDRESS", "324"),
"senderName": context.get("sender_name") or os.getenv("TIM_SMS_SENDER_NAME", "TIM Brasil"),
"message": message,
"longURL": context.get("long_url") or message,
}
if context.get("notify_url"):
payload["receiptRequest"] = {"notifyURL": context["notify_url"]}
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_SMS_AUTH", ""),
extra={"clientId": os.getenv("TIM_SMS_CLIENT_ID", "AIAGENTCR")},
)
attempts: list[dict[str, Any]] = []
result = self.request("POST", os.getenv("TIM_SMS_URL", ""), payload=payload, headers=headers, timeout=int(os.getenv("TIM_SMS_TIMEOUT", "30")), attempt_log=attempts)
return self._attach_transport(result, operation="sgr_codbar", attempts=attempts)
def profile_bill(self, msisdn: str) -> Any:
"""Perfil de faturamento usando o mesmo contrato CompleteInvoices do original."""
if self.mock:
return self.fixture("profile_bill")
url = os.getenv("TIM_URL_PERFIL_FATURA", "") or os.getenv("TIM_COMPLETE_INVOICES_URL", "")
return self.request(
"POST",
url,
payload={"msisdn": msisdn},
headers=self._headers(
client_id=None,
auth=os.getenv("TIM_PROFILE_BILL_AUTH", "") or os.getenv("TIM_COMPLETE_INVOICES_AUTH", ""),
extra={"ClientID": os.getenv("TIM_PROFILE_BILL_CLIENT_ID", "AIAGENTCR")},
),
timeout=int(os.getenv("TIM_PROFILE_BILL_TIMEOUT", "30")),
)
def line_info(self, msisdn: str) -> Any:
"""Consulta dados cadastrais da linha/dependente e preserva o CPF correto."""
if self.mock:
payload = self.fixture("contrato")
else:
url = os.getenv("TIM_PROFILE_FULL_URL", "").replace("{msisdn}", quote(msisdn))
payload = self.request(
"GET",
url,
headers=self._headers(
client_id=None,
auth=os.getenv("TIM_PROFILE_FULL_AUTH", ""),
extra={"ClientID": os.getenv("TIM_PROFILE_FULL_CLIENT_ID", "AIAGENTCR")},
),
)
def extract(value: Any) -> str:
if not isinstance(value, dict):
return ""
direct = value.get("socialSecNo")
if direct not in (None, ""):
return str(direct).strip()
for key in ("customer", "contract", "billingProfile", "subscriber"):
found = extract(value.get(key))
if found:
return found
return ""
return {"social_sec_no": extract(payload), "raw": payload if isinstance(payload, dict) else {}}
def bill_pdf(
self,
msisdn: str,
invoice_id: str,
customer_id: str,
*,
include_danfe: bool = False,
output: str = "",
) -> Any:
"""Recupera a fatura detalhada via POST e normaliza seu PDF.
Esta operação é distinta de ``secure_pdf`` e corresponde ao BillPdfCommand
original, usado por pró-rata, matching e explicação detalhada de fatura.
"""
if self.mock:
parsed = self.fixture("invoice_pdf_include_danfe_true" if include_danfe else "invoice_pdf_include_danfe_false")
return {
"status": "SUCCESS",
"message": "Fatura recuperada com sucesso",
"file_content": None,
"file_name": output or "antiga.pdf",
"parsed_content": parsed,
}
url = os.getenv("TIM_BILL_PDF_URL", "") or os.getenv("TIM_SECURE_PDF_URL", "") or os.getenv("TIM_URL_INVOICE_RECOVER", "")
payload = {
"invoiceId": encrypt_secure_pdf_value(invoice_id),
"customerId": encrypt_secure_pdf_value(customer_id),
"invoiceType": "DETALHADA",
}
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_BILL_PDF_AUTH", "") or os.getenv("TIM_SECURE_PDF_AUTH", ""),
extra={
"clientId": os.getenv("TIM_BILL_PDF_CLIENT_ID", "AIAGENTCR"),
"Accept": "application/pdf",
},
)
result = self.request(
"POST",
url,
payload=payload,
headers=headers,
timeout=int(os.getenv("TIM_BILL_PDF_TIMEOUT", os.getenv("TIM_INVOICE_RECOVER_TIMEOUT", "30"))),
)
raw_content = result.get("raw_content") if isinstance(result, dict) else None
parsed = result.get("parsed_content") if isinstance(result, dict) else None
if raw_content and parsed is None:
parsed = parse_tim_bill_pdf(raw_content, include_danfe=include_danfe)
return {
"status": "SUCCESS" if raw_content or parsed is not None else str((result or {}).get("status") if isinstance(result, dict) else ""),
"message": "Fatura recuperada com sucesso" if raw_content or parsed is not None else "Resposta de fatura sem conteúdo",
"file_content": raw_content,
"file_name": output or "antiga.pdf",
"parsed_content": parsed,
"raw": result,
}
def secure_pdf(self, msisdn: str, invoice_id: str, customer_id: str = "") -> Any:
if self.mock:
return self.fixture("invoice_pdf_include_danfe_false")
url = os.getenv("TIM_URL_INVOICE_RECOVER", "")
params = {
"invoiceId": encrypt_secure_pdf_value(invoice_id),
"msisdn": encrypt_secure_pdf_value(msisdn),
"customerId": encrypt_secure_pdf_value(customer_id),
}
return self.request(
"GET",
url,
params=params,
headers=self._headers(
client_id=None,
auth=os.getenv("TIM_SECURE_PDF_AUTH", ""),
extra={"clientId": os.getenv("TIM_INVOICE_RECOVER_CLIENT_ID", "AIAGENTCR")},
),
timeout=int(os.getenv("TIM_INVOICE_RECOVER_TIMEOUT", "30")),
)

View File

@@ -0,0 +1,201 @@
from __future__ import annotations
from calendar import monthrange
from datetime import date, datetime
import re
from typing import Any
OPEN_STATUSES = {
"em aberto", "a vencer", "em atraso", "atrasada", "atrasado", "vencida", "vencido",
"aberto", "aberto deb aut", "aberto cc", "aberto pix", "open", "unpaid",
}
PAID_STATUSES = {"pago", "paga", "paid", "quitada", "quitado"}
CONTESTED_STATUSES = {"contestada", "contestado", "em contestacao", "em contestação", "disputed"}
PAYMENT_TYPE_LABELS = {1: "debito_automatico", 2: "fatura", 3: "cartao_credito", 4: "incobraveis"}
PAYMENT_TYPES_REQUIRE_SMS = {1, 3}
def _norm(value: Any) -> str:
return " ".join(str(value or "").strip().casefold().replace("_", " ").split())
def _invoice_id(item: dict[str, Any]) -> str:
return str(item.get("invoiceId") or item.get("invoice_id") or item.get("invoiceNumber") or item.get("number") or "").strip()
def payment_items(payload: Any) -> list[dict[str, Any]]:
if not isinstance(payload, dict):
return []
rows = payload.get("paymentItems") or payload.get("payment_items") or payload.get("invoices") or []
return [dict(x) for x in rows if isinstance(x, dict)] if isinstance(rows, (list, tuple)) else []
def select_invoice(payload: Any, invoice_id: Any = "") -> dict[str, Any]:
rows = payment_items(payload)
wanted = str(invoice_id or "").strip()
if wanted:
for row in rows:
if _invoice_id(row) == wanted:
return row
return {}
return rows[0] if rows else {}
def invoice_status(item: dict[str, Any]) -> str:
return str(item.get("invoiceStatus") or item.get("invoice_status") or item.get("status") or "").strip()
def is_unpaid_status(value: Any) -> bool:
text = _norm(value)
return text in OPEN_STATUSES or any(k in text for k in ("aberto", "atras", "vencid", "unpaid"))
def is_paid_status(value: Any) -> bool:
text = _norm(value)
return text in PAID_STATUSES or text.startswith("pag")
def is_contested_status(value: Any) -> bool:
text = _norm(value)
return text in CONTESTED_STATUSES or "contest" in text
def complete_invoices_context(payload: Any, invoice_id: Any = "") -> dict[str, Any]:
source = payload if isinstance(payload, dict) else {}
billing = source.get("billingProfile") or source.get("billing_profile") or {}
billing = billing if isinstance(billing, dict) else {}
raw_pt = billing.get("paymentTypeId") if billing.get("paymentTypeId") is not None else billing.get("payment_type_id")
try:
payment_type_id = int(raw_pt) if raw_pt is not None else None
except (TypeError, ValueError):
payment_type_id = None
method = str(
source.get("payment_method") or source.get("method_payment") or source.get("forma_pagamento")
or billing.get("paymentMethod") or billing.get("methodPayment") or PAYMENT_TYPE_LABELS.get(payment_type_id, "")
).strip().lower()
rows = payment_items(source)
selected = select_invoice(source, invoice_id)
statuses = [invoice_status(x) for x in rows if invoice_status(x)]
selected_status = invoice_status(selected)
if invoice_id:
has_open = bool(selected and is_unpaid_status(selected_status))
else:
has_open = any(is_unpaid_status(x) for x in statuses)
return {
"invoice": selected,
"invoice_status": selected_status,
"invoice_statuses": statuses,
"has_open_bill": has_open,
"payment_type_id": payment_type_id,
"payment_method": method,
"method_payment": method,
"forma_pagamento": method,
"requires_sms": payment_type_id in PAYMENT_TYPES_REQUIRE_SMS,
"requer_sms": payment_type_id in PAYMENT_TYPES_REQUIRE_SMS,
}
def _is_dacc(method: Any) -> bool:
text = _norm(method)
return any(k in text for k in ("dacc", "debito automatico", "débito automático", "cartao", "cartão", "parcelamento"))
def resolve_refund_option(*, payment_method: str, has_open_bill: bool, invoice_statuses: list[str]) -> dict[str, Any]:
is_dacc = _is_dacc(payment_method)
is_contested = any(is_contested_status(x) for x in invoice_statuses)
is_paid = any(is_paid_status(x) for x in invoice_statuses) and not has_open_bill
is_unpaid = bool(has_open_bill) or any(is_unpaid_status(x) for x in invoice_statuses)
boleto = bool(is_unpaid and not is_dacc and not is_contested)
reason = "credito_conta_futura"
if boleto:
reason = "fatura_nao_paga"
elif is_dacc:
reason = "forma_pagamento_dacc_cartao_parcelamento"
elif is_contested:
reason = "fatura_ja_contestada"
elif is_paid:
reason = "fatura_paga"
return {
"refund_option": "1" if boleto else "0",
"format_text": "sms" if boleto else "conta_futura",
"resolution_type": "new_boleto" if boleto else "credit_bill",
"decision_reason": reason,
"is_dacc": is_dacc,
"is_paid": is_paid,
"is_unpaid": is_unpaid,
"is_contested": is_contested,
}
def parse_date(value: Any) -> datetime | None:
text = str(value or "").strip()
if not text:
return None
for fmt in ("%Y-%m-%d", "%Y%m%d", "%d/%m/%Y", "%d-%m-%Y", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%SZ"):
try:
return datetime.strptime(text[:20] if "T" in fmt else text, fmt)
except ValueError:
continue
digits = re.sub(r"\D", "", text)
if len(digits) >= 8:
for fmt in ("%Y%m%d", "%d%m%Y"):
try: return datetime.strptime(digits[:8], fmt)
except ValueError: pass
return None
def due_day(value: Any) -> int:
parsed = parse_date(value)
if parsed:
return parsed.day
m = re.search(r"(?:^|[-/])(\d{1,2})$", str(value or "").strip())
return int(m.group(1)) if m else 0
def billing_cutoff_reference(cutoff_day: int, *, reference_datetime: datetime | None = None) -> tuple[bool, datetime | None]:
if cutoff_day <= 0:
return False, None
ref = reference_datetime or datetime.now()
day = min(cutoff_day, monthrange(ref.year, ref.month)[1])
cutoff = datetime(ref.year, ref.month, day)
if ref < cutoff:
month = ref.month - 1
year = ref.year
if month == 0:
month, year = 12, year - 1
cutoff = datetime(year, month, min(cutoff_day, monthrange(year, month)[1]))
return ref >= cutoff, cutoff
def next_cutoff_from_cut_date(cut_date: datetime, *, reference_datetime: datetime | None = None) -> tuple[bool, datetime]:
ref = reference_datetime or datetime.now()
month = cut_date.month + 1
year = cut_date.year
if month == 13:
month, year = 1, year + 1
nxt = datetime(year, month, min(cut_date.day, monthrange(year, month)[1]))
return ref > nxt, nxt
def manual_conta_certa_from_evidence(*, dependent_invoice_item: bool = False, invoice_item: dict[str, Any] | None = None, contract: Any = None, reference_datetime: datetime | None = None) -> bool:
if dependent_invoice_item:
return True
row = invoice_item or {}
for key in ("cutDate", "cutoffDate", "cut_date", "cutoff_date"):
if row.get(key):
parsed = parse_date(row.get(key))
if parsed:
return next_cutoff_from_cut_date(parsed, reference_datetime=reference_datetime)[0]
try:
return billing_cutoff_reference(int(row.get(key)), reference_datetime=reference_datetime)[0]
except (TypeError, ValueError):
pass
payload = contract if isinstance(contract, dict) else {}
billing = payload.get("billing_profile") or payload.get("billingProfile") or {}
billing = billing if isinstance(billing, dict) else {}
date_info = billing.get("date") if isinstance(billing.get("date"), dict) else {}
try:
cutoff_day = int(date_info.get("cutoffDay") or 0)
except (TypeError, ValueError):
cutoff_day = 0
return billing_cutoff_reference(cutoff_day, reference_datetime=reference_datetime)[0] if cutoff_day else False

View File

@@ -0,0 +1,5 @@
{
"status": 200,
"message": "OK",
"operation": "block"
}

View File

@@ -0,0 +1,5 @@
{
"status": 200,
"message": "OK",
"operation": "cancel"
}

View File

@@ -0,0 +1,25 @@
{
"billingProfile": {
"paymentTypeId": 2,
"customer": {
"id": "MOCK-CUSTOMER-0001",
"customerId": "MOCK-CUSTOMER-0001",
"document": "12345678909",
"name": "Cliente Mock"
}
},
"paymentItems": [
{
"invoiceId": "3000131180",
"invoiceStatus": "fechada",
"status": "fechada",
"barCode": "34191790010104351004791020150008291070026000",
"dueDate": "2026-04-15",
"customerId": "MOCK-CUSTOMER-0001",
"customer": {
"id": "MOCK-CUSTOMER-0001",
"customerId": "MOCK-CUSTOMER-0001"
}
}
]
}

View File

@@ -0,0 +1,128 @@
{
"success": true,
"protocolo_id": "PRT-TESTE-0001",
"protocol_number": "PRT-TESTE-0001",
"contestacao_protocol": "PRT-TESTE-0001",
"protocol_closed": true,
"codigo_boleto": "34191.79001 01043.510047 91020.150008 3 12340000001499",
"barcode": "34191.79001 01043.510047 91020.150008 3 12340000001499",
"contestation_id": "20259",
"forma_pagamento": "debito_conta",
"tipo_fatura": "debito_conta",
"payment_method": "debito_conta",
"sms_enviado": false,
"sms_sent": false,
"format_text": "sms",
"resolution_type": "new_boleto",
"data_credito_proxima_fatura": "05/04/2026",
"items": [
{
"item_name": "Tamboro Mensal",
"item_type": "VAS_AVULSO",
"claimed_amount": "14.99",
"validated_amount": "14.99"
},
{
"item_name": "Tim Fashion",
"item_type": "VAS_AVULSO",
"claimed_amount": "10.00",
"validated_amount": "10.00"
},
{
"item_name": "Neymar Jr",
"item_type": "VAS_AVULSO",
"claimed_amount": "12.00",
"validated_amount": "12.00"
}
],
"body": {
"barcode": "34191.79001 01043.510047 91020.150008 3 12340000001499",
"contestationId": "20259",
"itemsResponse": [
{
"correctAccountStatus": "CRIAR",
"itemName": "Tamboro Mensal",
"message": "Contestação criada/atualizada com sucesso",
"status": "INICIADA"
},
{
"correctAccountStatus": "CRIAR",
"itemName": "Tim Fashion",
"message": "Contestação criada/atualizada com sucesso",
"status": "INICIADA"
},
{
"correctAccountStatus": "CRIAR",
"itemName": "Neymar Jr",
"message": "Contestação criada/atualizada com sucesso",
"status": "INICIADA"
}
],
"sr": "PRT-TESTE-0001"
},
"items_response": [
{
"correctAccountStatus": "CRIAR",
"itemName": "Tamboro Mensal",
"message": "Item não existe na fatura com o valor informado",
"status": "INICIADA"
},
{
"correctAccountStatus": "CRIAR",
"itemName": "Tim Fashion",
"message": "Contestação criada/atualizada com sucesso",
"status": "INICIADA"
},
{
"correctAccountStatus": "CRIAR",
"itemName": "Neymar Jr",
"message": "Contestação criada/atualizada com sucesso",
"status": "INICIADA"
}
],
"contested_items": [
{
"correctAccountStatus": "CRIAR",
"itemName": "Tim Fashion",
"message": "Contestação criada/atualizada com sucesso",
"status": "INICIADA"
},
{
"correctAccountStatus": "CRIAR",
"itemName": "Neymar Jr",
"message": "Contestação criada/atualizada com sucesso",
"status": "INICIADA"
}
],
"not_contested_items": [
],
"validated_by_actions": true,
"validated_items": [
{
"item_name": "Tamboro Mensal",
"item_type": "VAS_AVULSO",
"claimed_amount": "14.99",
"validated_amount": "14.99"
},
{
"item_name": "Tim Fashion",
"item_type": "VAS_AVULSO",
"claimed_amount": "10.00",
"validated_amount": "10.00"
},
{
"item_name": "Neymar Jr",
"item_type": "VAS_AVULSO",
"claimed_amount": "12.00",
"validated_amount": "12.00"
}
],
"validation_summary": {
"requested_items_count": 3,
"items_response_count": 3,
"contested_items_count": 3,
"not_contested_items_count": 0
},
"contested_invoice_amount_open": "36.99",
"contested_invoice_amount": "36.99"
}

View File

@@ -0,0 +1,16 @@
{
"contract": {
"status": "ACTIVE",
"planName": "TIM Black C Light Disney 7 0"
},
"billingProfile": {
"paymentTypeId": 2,
"paymentType": "fatura",
"customer": {
"id": "MOCK-CUSTOMER-0001",
"customerId": "MOCK-CUSTOMER-0001",
"document": "12345678909",
"name": "Cliente Mock"
}
}
}

View File

@@ -0,0 +1,207 @@
{
"invoiceExplanation": "Analisando a sua fatura atual em relação a passada, o valor variou em R$ 46.99.\n\nHouve a cobrança dos seguintes serviços de valor adicionado:\n* Cobrança Tamboro Mensal no valor de R$ 14.99 no dia 01/11/25.\n* Cobrança TIM Fashion Mensal no valor de R$ 10.00 no dia 01/11/25.\n* Cobrança Neymar Jr no valor de R$ 12.00 no dia 01/11/25.\n* Cobrança Youtube Premium no valor de R$ 10.00 no dia 02/11/25.",
"invoiceVariation": [
{
"value": "46.99",
"type": "servicos_contratados_de_parceiros",
"desc": "Serviços de valor adicionado",
"nItems": "10",
"items": [
{
"type": "servicos_contratados_de_parceiros",
"desc": "Tamboro Mensal",
"value": "19.99",
"date": "2025-10-02T00:00:00.000Z",
"invoice": "2025-10-20T00:00:00.000Z",
"status": "Ativo"
},
{
"type": "servicos_contratados_de_parceiros",
"desc": "VOD + Canais abertos",
"value": "10.0",
"date": "2025-10-01T00:00:00.000Z",
"invoice": "2025-10-20T00:00:00.000Z",
"status": "Ativo"
},
{
"type": "servicos_contratados_de_parceiros",
"desc": "VOD + Canais abertos",
"value": "10.0",
"date": "2025-10-03T00:00:00.000Z",
"invoice": "2025-10-20T00:00:00.000Z",
"status": "Ativo"
},
{
"type": "servicos_contratados_de_parceiros",
"desc": "Tamboro Mensal",
"value": "19.99",
"date": "2025-11-02T00:00:00.000Z",
"invoice": "2025-11-20T00:00:00.000Z",
"status": "Ativo"
},
{
"type": "servicos_contratados_de_parceiros",
"desc": "VOD + Canais abertos",
"value": "10.0",
"date": "2025-11-01T00:00:00.000Z",
"invoice": "2025-11-20T00:00:00.000Z",
"status": "Ativo"
},
{
"type": "servicos_contratados_de_parceiros",
"desc": "VOD + Canais abertos",
"value": "10.0",
"date": "2025-11-03T00:00:00.000Z",
"invoice": "2025-11-20T00:00:00.000Z",
"status": "Ativo"
},
{
"type": "servicos_contratados_de_parceiros",
"desc": "Tamboro Mensal",
"value": "14.99",
"date": "2025-11-01T00:00:00.000Z",
"invoice": "2025-11-20T00:00:00.000Z",
"status": "Ativo"
},
{
"type": "servicos_contratados_de_parceiros",
"desc": "TIM Fashion Mensal",
"value": "10.0",
"date": "2025-11-01T00:00:00.000Z",
"invoice": "2025-11-20T00:00:00.000Z",
"status": "Ativo"
},
{
"type": "servicos_contratados_de_parceiros",
"desc": "Neymar Jr",
"value": "12.0",
"date": "2025-11-01T00:00:00.000Z",
"invoice": "2025-11-20T00:00:00.000Z",
"status": "Ativo"
},
{
"type": "servicos_contratados_de_parceiros",
"desc": "Youtube Premium",
"value": "10.0",
"date": "2025-11-02T00:00:00.000Z",
"invoice": "2025-11-20T00:00:00.000Z",
"status": "Ativo"
}
]
}
],
"currentInvoice": [
{
"value": "176.98",
"type": "plano",
"desc": "Plano",
"nItems": "2",
"items": [
{
"type": "plano",
"desc": "TIM Black A 8.0",
"value": "104.99",
"days": "31",
"contestable": false
},
{
"type": "plano",
"desc": "TIM CTRL Redes Sociais 8.0",
"value": "71.99",
"contestable": false
}
]
},
{
"value": "76.98",
"type": "servicos_contratados_de_parceiros",
"desc": "Serviços de valor adicionado",
"nItems": "6",
"items": [
{
"type": "servicos_contratados_de_parceiros",
"desc": "Tamboro Mensal",
"value": "14.99",
"date": "2025-11-01T00:00:00.000Z",
"contestable": true
},
{
"type": "servicos_contratados_de_parceiros",
"desc": "Tamboro Mensal",
"value": "19.99",
"date": "2025-11-02T00:00:00.000Z",
"contestable": true
},
{
"type": "servicos_contratados_de_parceiros",
"desc": "TIM Fashion Mensal",
"value": "10.0",
"date": "2025-11-01T00:00:00.000Z",
"contestable": true
},
{
"type": "servicos_contratados_de_parceiros",
"desc": "VOD + Canais abertos",
"value": "10.0",
"date": "2025-11-01T00:00:00.000Z",
"contestable": true
},
{
"type": "servicos_contratados_de_parceiros",
"desc": "VOD + Canais abertos",
"value": "10.0",
"date": "2025-11-03T00:00:00.000Z",
"contestable": true
},
{
"type": "servicos_contratados_de_parceiros",
"desc": "Neymar Jr",
"value": "12.0",
"date": "2025-11-01T00:00:00.000Z",
"contestable": true
}
]
},
{
"value": "20.0",
"type": "streaming",
"desc": "Streamings",
"nItems": "2",
"items": [
{
"type": "streaming",
"desc": "Youtube Premium",
"value": "10.0",
"contestable": false
},
{
"type": "streaming",
"desc": "Paramount+",
"value": "10.0",
"contestable": false
}
]
},
{
"value": "0.46",
"type": "juros_e_multa_por_atraso_no_pagamento",
"desc": "Juros / Multas",
"nItems": "2",
"items": [
{
"type": "juros_e_multa_por_atraso_no_pagamento",
"desc": "JUROS: (VENC 10/11/25, PAGO EM 11/10/25)",
"value": "0.26",
"contestable": false
},
{
"type": "juros_e_multa_por_atraso_no_pagamento",
"desc": "MULTAS: (VENC 10/11/25, PAGO EM 11/10/25)",
"value": "0.2",
"contestable": false
}
]
}
],
"messageId": "mock-billing-analysis-0001"
}

View File

@@ -0,0 +1,407 @@
{
"Fatura Resumo": [
{
"desc": "PERÍODO",
"period": "14/10 a 13/11"
},
{
"desc": "EMISSÃO",
"emissao": "20/11/2025"
},
{
"desc": "Planos Contratados",
"value": 176.98
},
{
"desc": "Itens eventuais",
"value": 46.99
},
{
"desc": "JUROS",
"value": 0.26
},
{
"desc": "Multas",
"value": 0.2
},
{
"desc": "Total geral",
"value": 207.43
}
],
"1199999999": {
"Planos": {
"TIM Black A 8.0": {
"period": "14/10 a 13/11",
"days": 31,
"msisdn": "1199999999",
"valor_final": 104.99,
"descontos": [
{
"desc": "Desc Fidel 80 TIM Black A compartilhado 8.0 1/12",
"value": -80.0,
"installment": "1/12"
},
{
"desc": "Desc Esp TIM Black A compartilhado 8.0",
"value": -5.0,
"installment": null
}
],
"total_descontos": -85.0,
"valor_bruto": 189.99
},
"TIM CTRL Redes Sociais 8.0": {
"days": null,
"msisdn": "1199999999",
"valor_final": 71.99,
"is_controle": true,
"descontos": [
{
"desc": "Desc Esp TIM CTRL Redes Sociais 8.0 1",
"value": -3.0,
"installment": null
},
{
"desc": "Desc Fidel 33 TIM CTRL Redes Sociais 8.0 8/12",
"value": -33.0,
"installment": "8/12"
}
],
"total_descontos": -36.0,
"valor_bruto": 107.99
}
},
"Outros Valores": [
{
"desc": "MULTAS: (VENC 10/11/25, PAGO EM 11/10/25)",
"value": 0.2,
"msisdn": "1199999999"
},
{
"desc": "JUROS: (VENC 10/11/25, PAGO EM 11/11/25)",
"value": 0.26,
"msisdn": "1199999999"
}
],
"SVA Detalhe Total": [
{
"desc": "Tamboro Mensal",
"period": "01/11/25",
"value": 14.99,
"msisdn": "1199999999",
"classe": "avulso",
"verb": "cancelar"
},
{
"desc": "Tamboro Mensal",
"period": "02/11/25",
"value": 19.99,
"msisdn": "1199999999",
"classe": "avulso",
"verb": "cancelar"
},
{
"desc": "TIM Fashion Mensal",
"period": "01/11/25",
"value": 10.00,
"msisdn": "1199999999",
"classe": "avulso",
"verb": "cancelar"
},
{
"desc": "VOD + Canais abertos",
"period": "01/11/25",
"value": 10.00,
"msisdn": "1199999999",
"classe": "avulso",
"verb": "cancelar"
},
{
"desc": "VOD + Canais abertos",
"period": "03/11/25",
"value": 10.00,
"msisdn": "1199999999",
"classe": "avulso",
"verb": "cancelar"
},
{
"desc": "Neymar Jr",
"period": "01/11/25",
"value": 12.00,
"msisdn": "1199999999",
"classe": "avulso",
"verb": "cancelar"
},
{
"desc": "Youtube Premium",
"period": "02/11/25",
"value": 10,
"msisdn": "1199999999",
"estrategico": true,
"classe": "estrategico",
"verb": "falar sobre"
},
{
"desc": "Paramount+",
"period": "02/11/25",
"value": 10,
"msisdn": "1199999999",
"estrategico": true,
"classe": "estrategico",
"verb": "falar sobre"
}
],
"Mensalidades Adicionais":
[
{
"desc": "Apple Music SVA Mes",
"value": 5.0,
"msisdn": "1199999999",
"estrategico": true,
"classe": "estrategico",
"verb": "falar sobre"
},
{
"desc": "Apple Music Dados Mes",
"value": 5.0,
"msisdn": "1199999999",
"estrategico": true,
"classe": "estrategico",
"verb": "falar sobre"
},
{
"desc": "Plugin 5G Plus",
"value": 5.0
}
],
"Serviços Bundle Inclusos": [
{
"desc": "Minutos Locais e DDD com 41",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"franchise": "Ilimitado",
"consumption": "43m30s",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Pacote Américas Promocional",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Tim Music",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Fluid Premium",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Fit Me App",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Busuu 2",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "ITGame Light",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "EXA Segurança Premium",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "EXA Cloud 500GB",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Aya Audiobooks Premium",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Bancah Premium + Jornais",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Aya Ensinah Premium",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "6GB Internet",
"days": null,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Minutos Locais e DDD com 41 - Ilimitado",
"days": null,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Fluid Light",
"days": null,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "EXA Segurança + Proteção Stand",
"days": null,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Aya Ensinah Premium",
"days": null,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Aya Books Premium",
"days": null,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Bancah Jornais II",
"days": null,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
}
],
"Cobranças de Terceiros":[
{
"desc": "Seguro Celular Proteção Total",
"value": 5.0,
"msisdn": "1199999999"
}
]
},
"vocalized_msisdn": {
"1199999999": "nove nove nove nove"
},
"91998188119": {
"Planos": {
"TIM Black B Light 8.0": {
"period": "25/04 a 24/05",
"days": 30,
"msisdn": "91998188119",
"valor_final": 95.99,
"descontos": [
{
"desc": "Desc Fidel 30 TIM Black B Light 8.0 7/12",
"value": -30.0,
"installment": "7/12"
},
{
"desc": "Desc Basic R$10 TIM Black B Light 8.0 4/6",
"value": -10.0,
"installment": "4/6"
}
],
"total_descontos": -40.0,
"valor_bruto": 135.99
}
},
"SVA Detalhe Total": [
{
"desc": "TIM Fashion Mensal",
"period": "30/04/25",
"value": 10.0,
"msisdn": "91998188119",
"classe": "avulso",
"verb": "cancelar"
},
{
"desc": "Tamboro Mensal",
"period": "10/05/25",
"value": 12.99,
"msisdn": "91998188119",
"classe": "avulso",
"verb": "cancelar"
},
{
"desc": "Tamboro Mensal",
"period": "12/05/25",
"value": 7.99,
"msisdn": "91998188119",
"classe": "avulso",
"verb": "cancelar"
}
]
}
}

View File

@@ -0,0 +1,858 @@
{
"Fatura Resumo": [
{
"desc": "PERÍODO",
"period": "14/10 a 13/11"
},
{
"desc": "EMISSÃO",
"emissao": "20/11/2025"
},
{
"desc": "Planos Contratados",
"value": 176.98
},
{
"desc": "Itens eventuais",
"value": 46.99
},
{
"desc": "JUROS",
"value": 0.26
},
{
"desc": "Multas",
"value": 0.2
},
{
"desc": "Total geral",
"value": 197.43
}
],
"DANFE-COM": {
"Planos": {
"TIM Black A 8.0": [
{
"desc": "TIM Black A 8.0",
"unit": "UN",
"qty": 1,
"preco_unit": 98.69,
"pis_cofins": 1.9,
"bc_icms": 52.13,
"aliq_icms": "17%",
"icms": 8.86,
"valor_bruto": 98.69,
"total_descontos": -46.56,
"valor_final": 52.13,
"descontos": [
{
"desc": "Desc Fidel 80 TIM Black A compartilhado 8.0",
"unit": "UN",
"qty": 1,
"preco_unit": -41.56,
"pis_cofins": null,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"value": -41.56
},
{
"desc": "Desc Esp TIM Black A compartilhado 8.0",
"unit": "UN",
"qty": 1,
"preco_unit": -5.0,
"pis_cofins": null,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"value": -5.0
}
]
},
{
"desc": "Aya Ensinah Premium",
"unit": "UN",
"qty": 1,
"preco_unit": 11.0,
"pis_cofins": 0.0,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"valor_bruto": 11.0,
"total_descontos": -4.63,
"valor_final": 6.37,
"descontos": [
{
"desc": "Desc Fidel 80 Aya Ensinah Premium",
"unit": "UN",
"qty": 1,
"preco_unit": -4.63,
"pis_cofins": null,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"value": -4.63
}
]
},
{
"desc": "Bancah Premium + Jornais",
"unit": "UN",
"qty": 1,
"preco_unit": 12.9,
"pis_cofins": 0.27,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"valor_bruto": 12.9,
"total_descontos": -5.43,
"valor_final": 7.47,
"descontos": [
{
"desc": "Desc Fidel 80 Bancah Premium + Jornais",
"unit": "UN",
"qty": 1,
"preco_unit": -5.43,
"pis_cofins": null,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"value": -5.43
}
]
},
{
"desc": "Aya Audiobooks Premium",
"unit": "UN",
"qty": 1,
"preco_unit": 28.0,
"pis_cofins": 0.0,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"valor_bruto": 28.0,
"total_descontos": -11.79,
"valor_final": 16.21,
"descontos": [
{
"desc": "Desc Fidel 80 Aya Audiobooks Premium",
"unit": "UN",
"qty": 1,
"preco_unit": -11.79,
"pis_cofins": null,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"value": -11.79
}
]
},
{
"desc": "EXA Cloud 500GB",
"unit": "UN",
"qty": 1,
"preco_unit": 4.5,
"pis_cofins": 0.24,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"valor_bruto": 4.5,
"total_descontos": -1.9,
"valor_final": 2.6,
"descontos": [
{
"desc": "Desc Fidel 80 EXA Cloud 500GB",
"unit": "UN",
"qty": 1,
"preco_unit": -1.9,
"pis_cofins": null,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"value": -1.9
}
]
},
{
"desc": "EXA Segurança Premium",
"unit": "UN",
"qty": 1,
"preco_unit": 6.9,
"pis_cofins": 0.37,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"valor_bruto": 6.9,
"total_descontos": -2.9,
"valor_final": 4.0,
"descontos": [
{
"desc": "Desc Fidel 80 EXA Segurança Premium",
"unit": "UN",
"qty": 1,
"preco_unit": -2.9,
"pis_cofins": null,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"value": -2.9
}
]
},
{
"desc": "ITGame Light",
"unit": "UN",
"qty": 1,
"preco_unit": 4.2,
"pis_cofins": 0.22,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"valor_bruto": 4.2,
"total_descontos": -1.77,
"valor_final": 2.43,
"descontos": [
{
"desc": "Desc Fidel 80 ITGame Light",
"unit": "UN",
"qty": 1,
"preco_unit": -1.77,
"pis_cofins": null,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"value": -1.77
}
]
},
{
"desc": "Busuu 2",
"unit": "UN",
"qty": 1,
"preco_unit": 5.0,
"pis_cofins": 0.27,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"valor_bruto": 5.0,
"total_descontos": -2.1,
"valor_final": 2.9,
"descontos": [
{
"desc": "Desc Fidel 80 Busuu 2",
"unit": "UN",
"qty": 1,
"preco_unit": -2.1,
"pis_cofins": null,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"value": -2.1
}
]
},
{
"desc": "Fit Me App",
"unit": "UN",
"qty": 1,
"preco_unit": 4.2,
"pis_cofins": 0.22,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"valor_bruto": 4.2,
"total_descontos": -1.77,
"valor_final": 2.43,
"descontos": [
{
"desc": "Desc Fidel 80 Fit Me App",
"unit": "UN",
"qty": 1,
"preco_unit": -1.77,
"pis_cofins": null,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"value": -1.77
}
]
},
{
"desc": "Fluid Premium",
"unit": "UN",
"qty": 1,
"preco_unit": 4.7,
"pis_cofins": 0.25,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"valor_bruto": 4.7,
"total_descontos": -1.98,
"valor_final": 2.72,
"descontos": [
{
"desc": "Desc Fidel 80 Fluid Premium",
"unit": "UN",
"qty": 1,
"preco_unit": -1.98,
"pis_cofins": null,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"value": -1.98
}
]
},
{
"desc": "Tim Music",
"unit": "UN",
"qty": 1,
"preco_unit": 9.9,
"pis_cofins": 0.53,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"valor_bruto": 9.9,
"total_descontos": -4.17,
"valor_final": 5.73,
"descontos": [
{
"desc": "Desc Fidel 80 Tim Music",
"unit": "UN",
"qty": 1,
"preco_unit": -4.17,
"pis_cofins": null,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"value": -4.17
}
]
}
],
"TIM CTRL Redes Sociais 8.0": [
{
"desc": "TIM CTRL Redes Sociais 8.0",
"unit": "UN",
"qty": 1,
"preco_unit": 56.09,
"pis_cofins": 1.31,
"bc_icms": 35.96,
"aliq_icms": "17%",
"icms": 6.11,
"valor_bruto": 56.09,
"total_descontos": -20.13,
"valor_final": 35.96,
"descontos": [
{
"desc": "Desc Fidel 33 TIM CTRL Redes Sociais 8.0",
"unit": "UN",
"qty": 1,
"preco_unit": -17.13,
"pis_cofins": null,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"value": -17.13
},
{
"desc": "Desc Esp TIM CTRL Redes Sociais 8.0",
"unit": "UN",
"qty": 1,
"preco_unit": -3.0,
"pis_cofins": null,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"value": -3.0
}
]
},
{
"desc": "Bancah Jornais II",
"unit": "UN",
"qty": 1,
"preco_unit": 6.5,
"pis_cofins": 0.17,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"valor_bruto": 6.5,
"total_descontos": -1.99,
"valor_final": 4.51,
"descontos": [
{
"desc": "Desc Fidel 33 Bancah Jornais II",
"unit": "UN",
"qty": 1,
"preco_unit": -1.99,
"pis_cofins": null,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"value": -1.99
}
]
},
{
"desc": "VOD + Canais Abertos",
"unit": "UN",
"qty": 1,
"preco_unit": 6.5,
"pis_cofins": 0.17,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"valor_bruto": 6.5,
"total_descontos": -1.99,
"valor_final": 4.51,
"descontos": [
{
"desc": "VOD + Canais Abertos",
"unit": "UN",
"qty": 1,
"preco_unit": -1.99,
"pis_cofins": null,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"value": -1.99
}
]
},
{
"desc": "Aluguel de filmes 1",
"unit": "UN",
"qty": 1,
"preco_unit": 6.5,
"pis_cofins": 0.17,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"valor_bruto": 6.5,
"total_descontos": -1.99,
"valor_final": 4.51,
"descontos": [
{
"desc": "Aluguel de filmes 1",
"unit": "UN",
"qty": 1,
"preco_unit": -1.99,
"pis_cofins": null,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"value": -1.99
}
]
},
{
"desc": "Aya Books Premium",
"unit": "UN",
"qty": 1,
"preco_unit": 28.0,
"pis_cofins": 0.0,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"valor_bruto": 28.0,
"total_descontos": -8.56,
"valor_final": 19.44,
"descontos": [
{
"desc": "Desc Fidel 33 Aya Books Premium",
"unit": "UN",
"qty": 1,
"preco_unit": -8.56,
"pis_cofins": null,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"value": -8.56
}
]
},
{
"desc": "Aya Ensinah Premium",
"unit": "UN",
"qty": 1,
"preco_unit": 11.0,
"pis_cofins": 0.0,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"valor_bruto": 11.0,
"total_descontos": -3.36,
"valor_final": 7.64,
"descontos": [
{
"desc": "Desc Fidel 33 Aya Ensinah Premium",
"unit": "UN",
"qty": 1,
"preco_unit": -3.36,
"pis_cofins": null,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"value": -3.36
}
]
},
{
"desc": "EXA Segurança + Proteção Stand",
"unit": "UN",
"qty": 1,
"preco_unit": 4.8,
"pis_cofins": 0.3,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"valor_bruto": 4.8,
"total_descontos": -1.47,
"valor_final": 3.33,
"descontos": [
{
"desc": "Desc Fidel 33 EXA Segurança + Proteção Stand",
"unit": "UN",
"qty": 1,
"preco_unit": -1.47,
"pis_cofins": null,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"value": -1.47
}
]
},
{
"desc": "Fluid Light",
"unit": "UN",
"qty": 1,
"preco_unit": 1.6,
"pis_cofins": 0.1,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"valor_bruto": 1.6,
"total_descontos": -0.49,
"valor_final": 1.11,
"descontos": [
{
"desc": "Desc Fidel 33 Fluid Light",
"unit": "UN",
"qty": 1,
"preco_unit": -0.49,
"pis_cofins": null,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"value": -0.49
}
]
},
{
"desc": "Serviços de Valor Adicionado Conteúdo",
"unit": "UN",
"qty": 1,
"preco_unit": 14.99,
"pis_cofins": 1.39,
"bc_icms": null,
"aliq_icms": null,
"icms": null,
"valor_bruto": 14.99,
"total_descontos": 0.0,
"valor_final": 14.99,
"descontos": []
}
]
},
"total_geral": 191.97
},
"1199999999": {
"Planos": {
"TIM Black A 8.0": {
"period": "14/10 a 13/11",
"days": 31,
"msisdn": "1199999999",
"valor_final": 104.99,
"descontos": [
{
"desc": "Desc Fidel 80 TIM Black A compartilhado 8.0 1/12",
"value": -80.0,
"installment": "1/12"
},
{
"desc": "Desc Esp TIM Black A compartilhado 8.0",
"value": -5.0,
"installment": null
}
],
"total_descontos": -85.0,
"valor_bruto": 189.99
},
"TIM CTRL Redes Sociais 8.0": {
"days": null,
"msisdn": "1199999999",
"valor_final": 71.99,
"is_controle": true,
"descontos": [
{
"desc": "Desc Esp TIM CTRL Redes Sociais 8.0 1",
"value": -3.0,
"installment": null
},
{
"desc": "Desc Fidel 33 TIM CTRL Redes Sociais 8.0 8/12",
"value": -33.0,
"installment": "8/12"
}
],
"total_descontos": -36.0,
"valor_bruto": 107.99
}
},
"Outros Valores": [
{
"desc": "MULTAS: (VENC 10/11/25, PAGO EM 11/10/25)",
"value": 0.2,
"msisdn": "1199999999"
},
{
"desc": "JUROS: (VENC 10/11/25, PAGO EM 11/10/25)",
"value": 0.26,
"msisdn": "1199999999"
}
],
"SVA Detalhe Total": [
{
"desc": "Tamboro Mensal",
"period": "01/11/25",
"value": 14.99,
"msisdn": "1199999999",
"classe": "avulso",
"verb": "cancelar"
},
{
"desc": "Tim Fashion",
"period": "01/11/25",
"value": 10.00,
"msisdn": "1199999999",
"classe": "avulso",
"verb": "cancelar"
},
{
"desc": "Neymar Jr",
"period": "01/11/25",
"value": 12.00,
"msisdn": "1199999999",
"classe": "avulso",
"verb": "cancelar"
},
{
"desc": "Youtube Premium",
"period": "02/11/25",
"value": 10,
"msisdn": "1199999999",
"estrategico": true,
"classe": "estrategico",
"verb": "falar sobre"
},
{
"desc": "Paramount+",
"period": "02/11/25",
"value": 10,
"msisdn": "1199999999",
"estrategico": true,
"classe": "estrategico",
"verb": "falar sobre"
},
{
"desc": "Apple Music SVA Mes",
"value": 5.0,
"msisdn": "1199999999",
"estrategico": true,
"classe": "estrategico",
"verb": "falar sobre"
},
{
"desc": "Apple Music Dados Mes",
"value": 5.0,
"msisdn": "1199999999",
"estrategico": true,
"classe": "estrategico",
"verb": "falar sobre"
}
],
"Serviços Bundle Inclusos": [
{
"desc": "Minutos Locais e DDD com 41",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"franchise": "Ilimitado",
"consumption": "43m30s",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Pacote Américas Promocional",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Tim Music",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Fluid Premium",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Fit Me App",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Busuu 2",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "ITGame Light",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "EXA Segurança Premium",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "EXA Cloud 500GB",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Aya Audiobooks Premium",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Bancah Premium + Jornais",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Aya Ensinah Premium",
"period": "14/10 a 13/11",
"days": 31,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "6GB Internet",
"days": null,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Minutos Locais e DDD com 41 - Ilimitado",
"days": null,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Fluid Light",
"days": null,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "EXA Segurança + Proteção Stand",
"days": null,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Aya Ensinah Premium",
"days": null,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Aya Books Premium",
"days": null,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
},
{
"desc": "Bancah Jornais II",
"days": null,
"value": "Incluído",
"msisdn": "1199999999",
"classe": "bundle",
"verb": "falar sobre"
}
]
},
"vocalized_msisdn": {
"1199999999": "nove nove nove nove"
}
}

View File

@@ -0,0 +1,5 @@
{
"billingProfile": {
"paymentTypeId": 2
}
}

View File

@@ -0,0 +1,4 @@
{
"interactionProtocol": "1234567890",
"status": "OPENED"
}

View File

@@ -0,0 +1,68 @@
{
"products": [
{
"appId": "1001",
"cspId": "740",
"ippId": "IPP-1001",
"name": "TIM Fashion Mensal",
"can": {
"cancel": true
},
"status": "ACTIVE",
"details": {
"valor": "10,00",
"can": {
"cancel": true
}
}
},
{
"appId": "1002",
"cspId": "740",
"ippId": "IPP-1002",
"name": "Aya Audiobooks Premium",
"can": {
"cancel": true
},
"status": "ACTIVE",
"details": {
"valor": "9,99",
"can": {
"cancel": true
}
}
},
{
"appId": "1003",
"cspId": "740",
"ippId": "IPP-1003",
"name": "Neymar Jr",
"can": {
"cancel": true
},
"status": "ACTIVE",
"details": {
"valor": "12,00",
"can": {
"cancel": true
}
}
},
{
"appId": "1004",
"cspId": "740",
"ippId": "IPP-1004",
"name": "Tamboro Mensal",
"can": {
"cancel": true
},
"status": "ACTIVE",
"details": {
"valor": "14,99",
"can": {
"cancel": true
}
}
}
]
}

View File

@@ -0,0 +1,4 @@
{
"status": 204,
"message": "Service Request atualizada com sucesso"
}

View File

@@ -0,0 +1,3 @@
{
"resourceURL": "mock://sms/AUTO"
}

View File

@@ -0,0 +1,5 @@
{
"status": 202,
"message": "TrackingActivities registrado com sucesso",
"trackingId": "MOCK-TRACKING-0001"
}

View File

@@ -0,0 +1,30 @@
{
"services": [
{
"activationChannel": "APP",
"activationDate": "2026-06-09T20:07:59.000Z",
"appId": 14395,
"billDescription": "HBO Max Standard",
"canCancel": true,
"canResendEvent": false,
"cspDescription": "HBO",
"cspId": 825,
"description": "Max mensal fatura",
"eventDate": "2026-06-09T20:07:59.000Z",
"eventType": "activation",
"externalCall": {
"id": "OCSG",
"name": "OCSG",
"type": "OCSG"
},
"largeAccount": "323",
"lastEventDate": "2026-06-09T20:07:59.000Z",
"name": "Max mensal fatura",
"paymentMethod": "Fatura",
"price": 44.9,
"provider": "HBO",
"providerContact": "Missing - Missing",
"subscriptionType": "MENSAL"
}
]
}

1060
app/domain/contas/ic_tags.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
from __future__ import annotations
import re
from typing import Any
_STRATEGIC = (
"paramount", "netflix", "disney", "hbo", "youtube premium", "aya ",
"deezer", "globoplay", "truecaller", "food balance",
)
def _norm(value: Any) -> str:
text = str(value or "").casefold()
text = re.sub(r"[^a-z0-9áàâãéèêíìîóòôõúùûç+ ]+", " ", text)
return " ".join(text.split())
def _iter_invoice_rows(invoice_detail: Any):
if not isinstance(invoice_detail, dict):
return
for _, sections in invoice_detail.items():
if not isinstance(sections, dict):
continue
for section, rows in sections.items():
if not isinstance(rows, list):
continue
for row in rows:
if isinstance(row, dict):
yield str(section or ""), row
def infer_informational_context(query: str, invoice_detail: Any = None) -> dict[str, list[str]]:
"""Infere a marca informacional usada pela finalização após uma consulta RAG.
Não executa RAG e não guarda sessão. Apenas transforma a pergunta + evidência
de fatura em metadata de domínio que o grafo/framework poderá persistir.
"""
q = _norm(query)
if not q:
return {}
names: list[str] = []
types: list[str] = []
for section, row in _iter_invoice_rows(invoice_detail):
name = str(row.get("desc") or row.get("name") or row.get("description") or "").strip()
if not name:
continue
n = _norm(name)
if n and (n in q or q in n):
names.append(name)
sec = _norm(section)
if "sva" in sec or "servico" in sec or "serviço" in sec:
types.append("avulso")
# Serviços estratégicos podem ser reconhecidos mesmo sem detalhe da fatura.
if not names:
for alias in _STRATEGIC:
if _norm(alias) in q:
names.append(alias.strip().title())
types.append("estrategico")
break
if not names:
return {}
dedup_names = list(dict.fromkeys(names))
dedup_types = list(dict.fromkeys(types))
return {
"informational_service_names": dedup_names,
"informational_vas_types": dedup_types,
"informational_rag_vas_types": dedup_types,
}

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
import base64
from urllib.parse import quote_plus, unquote_plus
from cryptography.hazmat.decrepit.ciphers.algorithms import Blowfish
from cryptography.hazmat.primitives.ciphers import Cipher, modes
from cryptography.hazmat.primitives.padding import PKCS7
_SECURE_PDF_KEY = b"T1mC0nT4"
def encrypt_secure_pdf_value(value: str) -> str:
plaintext = str(value).encode("utf-8")
padder = PKCS7(Blowfish.block_size).padder()
padded = padder.update(plaintext) + padder.finalize()
encryptor = Cipher(Blowfish(_SECURE_PDF_KEY), modes.ECB()).encryptor()
ciphertext = encryptor.update(padded) + encryptor.finalize()
encoded = base64.b64encode(ciphertext).decode("ascii")
return quote_plus(encoded, safe="")
def decrypt_secure_pdf_value(value: str) -> str:
encoded = unquote_plus(str(value))
ciphertext = base64.b64decode(encoded, validate=True)
decryptor = Cipher(Blowfish(_SECURE_PDF_KEY), modes.ECB()).decryptor()
padded = decryptor.update(ciphertext) + decryptor.finalize()
unpadder = PKCS7(Blowfish.block_size).unpadder()
plaintext = unpadder.update(padded) + unpadder.finalize()
return plaintext.decode("utf-8")

View File

@@ -0,0 +1,228 @@
from __future__ import annotations
import asyncio
import json
import os
import time
from dataclasses import dataclass
from typing import Any
from agent_framework.cache.cache import Cache
from .client import TimApiClient
@dataclass(slots=True)
class InvoiceContext:
msisdn: str
invoice_id: str = ""
complete_invoices: dict[str, Any] | None = None
billing_analysis: dict[str, Any] | None = None
invoice_detail: Any = None
customer_id: str = ""
error: str | None = None
cache_hit: bool = False
business_events: list[dict[str, Any]] | None = None
errors: dict[str, str] | None = None
metadata: dict[str, Any] | None = None
def as_dict(self) -> dict[str, Any]:
return {
"msisdn": self.msisdn,
"invoice_id": self.invoice_id,
"complete_invoices_payload": self.complete_invoices,
"billing_analysis": self.billing_analysis,
"invoice_detail": self.invoice_detail,
"customer_id": self.customer_id,
"invoice_context_error": self.error,
"invoice_context_cache_hit": self.cache_hit,
"invoice_context_business_events": list(self.business_events or []),
"invoice_context_errors": dict(self.errors or {}),
"invoice_context_metadata": dict(self.metadata or {}),
}
class InvoiceContextService:
"""Session-scoped invoice prefetch backed by the framework cache.
This replaces the legacy InvoiceContextProvider without owning session,
thread, LangGraph or LLM infrastructure. The cache implementation and TTL
belong to ``agent_framework``; this class only knows which TIM evidence is
useful to the Contas domain.
"""
def __init__(self, client: TimApiClient, cache: Cache, *, ttl_seconds: int | None = None) -> None:
self.client = client
self.cache = cache
self.ttl_seconds = ttl_seconds or int(os.getenv("TIM_INVOICE_CONTEXT_TTL_SECONDS", "1800"))
self._inflight: dict[str, asyncio.Task[InvoiceContext]] = {}
self._inflight_lock = asyncio.Lock()
@staticmethod
def _key(*, session_id: str, msisdn: str, invoice_id: str) -> str | None:
# No global cache without a session: prevents cross-customer leakage.
sid = str(session_id or "").strip()
if not sid:
return None
return f"contas:invoice-context:{sid}:{msisdn}:{invoice_id or 'latest'}"
@staticmethod
def _extract_identity(complete: Any, requested_invoice_id: str = "") -> tuple[str, str]:
if not isinstance(complete, dict):
return "", requested_invoice_id
billing = complete.get("billingProfile") or complete.get("billing_profile") or {}
customer = billing.get("customer") if isinstance(billing, dict) else {}
customer_id = str((customer or {}).get("customerId") or (customer or {}).get("id") or "") if isinstance(customer, dict) else ""
items = complete.get("paymentItems") or complete.get("payment_items") or []
invoice_id = requested_invoice_id
if not invoice_id and isinstance(items, list):
first = next((x for x in items if isinstance(x, dict)), {})
invoice_id = str(first.get("invoiceId") or first.get("invoiceNumber") or "") if isinstance(first, dict) else ""
return customer_id, invoice_id
@staticmethod
def _event(code: str, *, msisdn: str, invoice_id: str, session_id: str, message_id: str = "", api_status_code: int = 0, error: str = "", channel_id: str = "URA", ura_call_id: str = "") -> dict[str, Any]:
payload = {
"tag": code,
"agentId": os.getenv("TIM_AGENT_ID", "contas"),
"gsm": msisdn,
"sessionId": session_id,
"messageId": message_id,
"channelId": channel_id or "URA",
"uraCallId": ura_call_id or "",
"agentSpecificData": json.dumps({"billingId": invoice_id}, ensure_ascii=False) if invoice_id else "",
"billingId": invoice_id or "",
"apiStatusCode": int(api_status_code or 0),
}
if error:
payload["error"] = error
return {"code": code, "payload": payload, "component": "invoice_context_prefetch"}
async def _result_event_once(self, code: str, *, session_id: str, msisdn: str, invoice_id: str, message_id: str, api_status_code: int = 0, error: str = "") -> list[dict[str, Any]]:
if not session_id:
return [self._event(code, msisdn=msisdn, invoice_id=invoice_id, session_id=session_id, message_id=message_id, api_status_code=api_status_code, error=error)]
marker = f"contas:invoice-context:event-result:{code}:{session_id}:{msisdn}:{invoice_id or 'latest'}"
if await self.cache.get(marker):
return []
await self.cache.set(marker, True, ttl_seconds=self.ttl_seconds)
return [self._event(code, msisdn=msisdn, invoice_id=invoice_id, session_id=session_id, message_id=message_id, api_status_code=api_status_code, error=error)]
async def _started_event_once(self, *, session_id: str, msisdn: str, invoice_id: str, message_id: str) -> list[dict[str, Any]]:
if not session_id:
return [self._event("CVN.002", msisdn=msisdn, invoice_id=invoice_id, session_id=session_id, message_id=message_id)]
marker = f"contas:invoice-context:event-start:{session_id}:{msisdn}:{invoice_id or 'latest'}"
if await self.cache.get(marker):
return []
await self.cache.set(marker, True, ttl_seconds=self.ttl_seconds)
return [self._event("CVN.002", msisdn=msisdn, invoice_id=invoice_id, session_id=session_id, message_id=message_id)]
@staticmethod
async def _timed_to_thread(name: str, fn: Any, *args: Any, **kwargs: Any) -> tuple[Any, str, float]:
started = time.perf_counter()
try:
value = await asyncio.to_thread(fn, *args, **kwargs)
return value, "", round((time.perf_counter() - started) * 1000, 3)
except Exception as exc:
return None, f"{type(exc).__name__}: {exc}", round((time.perf_counter() - started) * 1000, 3)
async def _fetch(
self, *, session_id: str, msisdn: str, invoice_id: str, include_detail: bool, message_id: str
) -> InvoiceContext:
events = await self._started_event_once(
session_id=session_id, msisdn=msisdn, invoice_id=invoice_id, message_id=message_id
)
fetch_started = time.perf_counter()
complete_result, billing_result = await asyncio.gather(
self._timed_to_thread("complete_invoices", self.client.consultar_faturas, msisdn),
self._timed_to_thread("billing_analysis", self.client.billing_analysis, msisdn, invoice_id=invoice_id),
)
complete, complete_error, complete_ms = complete_result
billing, billing_error, billing_ms = billing_result
errors: dict[str, str] = {}
if complete_error:
errors["complete_invoices"] = complete_error
if billing_error:
errors["billing_analysis"] = billing_error
error_parts = [f"{k}: {v}" for k, v in errors.items()]
billing_status = 0
customer_id, resolved_invoice_id = self._extract_identity(complete, invoice_id)
event_invoice_id = resolved_invoice_id or invoice_id
if billing is None:
events.extend(await self._result_event_once("CVN.007", msisdn=msisdn, invoice_id=event_invoice_id, session_id=session_id, message_id=message_id, api_status_code=billing_status, error=error_parts[-1] if error_parts else "billing_analysis failed"))
else:
events.extend(await self._result_event_once("CVN.006", msisdn=msisdn, invoice_id=event_invoice_id, session_id=session_id, message_id=message_id))
detail = None
detail_ms = 0.0
if include_detail and resolved_invoice_id:
detail, detail_error, detail_ms = await self._timed_to_thread(
"invoice_detail", self.client.bill_pdf, msisdn, resolved_invoice_id, customer_id,
include_danfe=True, output="json"
)
if detail_error:
errors["invoice_detail"] = detail_error
error_parts.append(f"invoice_detail: {detail_error}")
metadata = {
"cache_hit": False,
"fetch_elapsed_ms": round((time.perf_counter() - fetch_started) * 1000, 3),
"task_timings": {
"complete_invoices": complete_ms,
"billing_analysis": billing_ms,
**({"invoice_detail": detail_ms} if include_detail else {}),
},
}
return InvoiceContext(
msisdn=msisdn, invoice_id=resolved_invoice_id,
complete_invoices=complete if isinstance(complete, dict) else None,
billing_analysis=billing if isinstance(billing, dict) else None,
invoice_detail=detail, customer_id=customer_id,
error="; ".join(error_parts) or None, cache_hit=False, business_events=events, errors=errors, metadata=metadata,
)
async def get(
self, *, session_id: str, msisdn: str, invoice_id: str = "",
use_cache: bool = True, include_detail: bool = False, message_id: str = "",
) -> InvoiceContext:
key = self._key(session_id=session_id, msisdn=msisdn, invoice_id=invoice_id)
if use_cache and key:
cached = await self.cache.get(key)
if isinstance(cached, dict) and (not include_detail or cached.get("invoice_detail") is not None):
cached = dict(cached)
cached["cache_hit"] = True
cached["business_events"] = [] # cache hit must not duplicate business effects
metadata = dict(cached.get("metadata") or {})
metadata["cache_hit"] = True
fetched_at = float(cached.get("_fetched_at") or time.time())
metadata["cache_age_ms"] = max(0.0, round((time.time() - fetched_at) * 1000, 3))
cached["metadata"] = metadata
return InvoiceContext(**{k: cached.get(k) for k in InvoiceContext.__dataclass_fields__})
task_key = (f"{key}:detail={int(include_detail)}" if key else f"nocache:{session_id}:{msisdn}:{invoice_id}:{include_detail}")
if use_cache and key:
async with self._inflight_lock:
task = self._inflight.get(task_key)
if task is None:
task = asyncio.create_task(self._fetch(session_id=session_id, msisdn=msisdn, invoice_id=invoice_id, include_detail=include_detail, message_id=message_id))
self._inflight[task_key] = task
try:
ctx = await task
finally:
async with self._inflight_lock:
if self._inflight.get(task_key) is task and task.done():
self._inflight.pop(task_key, None)
else:
ctx = await self._fetch(session_id=session_id, msisdn=msisdn, invoice_id=invoice_id, include_detail=include_detail, message_id=message_id)
if key:
await self.cache.set(key, {
"msisdn": ctx.msisdn, "invoice_id": ctx.invoice_id,
"complete_invoices": ctx.complete_invoices, "billing_analysis": ctx.billing_analysis,
"invoice_detail": ctx.invoice_detail, "customer_id": ctx.customer_id,
"error": ctx.error, "cache_hit": False,
"errors": dict(ctx.errors or {}), "metadata": dict(ctx.metadata or {}),
"_fetched_at": time.time(),
}, ttl_seconds=self.ttl_seconds)
return ctx

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any, Literal, Optional
@dataclass(frozen=True, slots=True)
class ResolvedInvoiceItem:
canonical_name: str
tool_category: Optional[Literal["cancelar_vas_avulso", "vas_estrategico", "pro_rata"]]
item_type: Literal["avulso", "estrategico", "bundle", "plano", "out_of_scope"]
msisdn: str
value: Optional[Decimal]
section: str
charge_date: Optional[str] = None
raw_entry: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class MentionedItem:
desc: str
msisdn: Optional[str] = None
value: Optional[str] = None
date: Optional[str] = None
def is_period_range(period: Any) -> bool:
text = str(period)
return " a " in text or "~" in text

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
"""Matcher conservador de nomes de item para o InvoiceResolver.
Porta o scoring determinístico do Contas original (grafia + fonética), mas não
mantém LangChain/gateway próprio. O fallback conversacional é responsabilidade do
agent_framework: quando mais de um candidato permanece plausível, o resultado é
ambíguo e o runtime pede clarificação ao cliente.
"""
from __future__ import annotations
import unicodedata
from typing import Any
try:
import jellyfish # type: ignore
except Exception: # pragma: no cover - fallback usado em ambientes mínimos
jellyfish = None
from difflib import SequenceMatcher
from . import string_metrics as _fallback_metrics
from .invoice_resolver import ItemMatcherError
_TOP_K = 10
_MIN_TOKEN_LEN = 3
_GENERIC_TOKENS = frozenset({"app", "premium", "plus", "light", "mensal", "mes", "dados", "sva"})
def _normalize(text: str) -> str:
decomposed = unicodedata.normalize("NFKD", str(text).lower())
return "".join(c for c in decomposed if not unicodedata.combining(c))
def _phrase_sim(a: str, b: str) -> float:
if jellyfish is not None:
return jellyfish.jaro_winkler_similarity(a, b)
return _fallback_metrics.jaro_winkler_similarity(a, b)
def _significant_tokens(text: str) -> list[str]:
return [t for t in text.split() if len(t) >= _MIN_TOKEN_LEN and t not in _GENERIC_TOKENS]
def _token_sim(mention: str, candidate: str) -> float:
tokens = _significant_tokens(candidate)
if not tokens:
return _phrase_sim(mention, candidate)
return max(_phrase_sim(mention, t) for t in tokens)
def _code_sim(a: str, b: str) -> float:
if not a or not b:
return 0.0
if jellyfish is not None:
distance = jellyfish.levenshtein_distance(a, b)
return 1.0 - distance / max(len(a), len(b))
distance = _fallback_metrics.levenshtein_distance(a, b)
return 1.0 - distance / max(len(a), len(b))
def _phon_sim(mention: str, candidate: str) -> float:
code_m = jellyfish.metaphone(mention) if jellyfish is not None else _fallback_metrics.metaphone(mention)
if not code_m:
return 0.0
tokens = _significant_tokens(candidate) or [candidate]
return max(_code_sim(code_m, jellyfish.metaphone(token) if jellyfish is not None else _fallback_metrics.metaphone(token)) for token in tokens)
def _token_pair_sim(a: str, b: str) -> float:
"""Combina evidência ortográfica e fonética para um par de tokens.
O pequeno bônus pela segunda evidência resolve transcrições curtas como
``apou`` -> ``apple`` sem transformar prefixos puramente gráficos em match.
"""
jw = _phrase_sim(a, b)
code_a = jellyfish.metaphone(a) if jellyfish is not None else _fallback_metrics.metaphone(a)
code_b = jellyfish.metaphone(b) if jellyfish is not None else _fallback_metrics.metaphone(b)
ph = _code_sim(code_a, code_b)
return min(1.0, max(jw, ph) + 0.15 * min(jw, ph))
def _token_alignment_sim(mention: str, candidate: str) -> float:
mention_tokens = [t for t in mention.split() if len(t) >= 2]
candidate_tokens = [t for t in candidate.split() if len(t) >= _MIN_TOKEN_LEN and t not in _GENERIC_TOKENS]
if not mention_tokens or not candidate_tokens:
return 0.0
# Cada token reconhecido pelo ASR precisa encontrar seu melhor correspondente.
# A média impede que um token genérico perfeito (ex.: ``tim``) esconda o
# discriminante errado (``miusic`` vs ``games``).
return sum(max(_token_pair_sim(mt, ct) for ct in candidate_tokens) for mt in mention_tokens) / len(mention_tokens)
def _grafia_sim(mention: str, candidate: str) -> float:
return max(_phrase_sim(mention, candidate), _token_sim(mention, candidate))
class SimilarityItemMatcher:
"""Matcher síncrono compatível com ``InvoiceResolver.ItemMatcherLLM``.
A implementação é deliberadamente fail-closed: só resolve automaticamente
quando o melhor candidato tem score alto e margem suficiente. Empates
plausíveis são devolvidos juntos para o framework pedir clarificação.
"""
def __init__(self, *, accept_threshold: float = 0.78, ambiguity_margin: float = 0.06, top_k: int = _TOP_K) -> None:
self.accept_threshold = float(accept_threshold)
self.ambiguity_margin = float(ambiguity_margin)
self.top_k = int(top_k)
def score(self, mention: str, candidate: str) -> float:
nm, nd = _normalize(mention), _normalize(candidate)
return max(_grafia_sim(nm, nd), _phon_sim(nm, nd), _token_alignment_sim(nm, nd))
def best_similarity(self, mention: str, candidates: list[str]) -> float:
if not candidates:
return 0.0
return max(self.score(mention, candidate) for candidate in candidates)
def ranked(self, mention: str, candidates: list[str]) -> list[tuple[str, float]]:
ranked = [(candidate, self.score(mention, candidate)) for candidate in candidates]
ranked.sort(key=lambda pair: pair[1], reverse=True)
return ranked[: self.top_k]
def match(self, mention: str, candidates: list[str], *, callbacks: list[Any] | None = None) -> list[str]:
del callbacks
if not candidates:
return []
ranked = self.ranked(mention, candidates)
if not ranked or ranked[0][1] < self.accept_threshold:
return []
best = ranked[0][1]
plausible = [name for name, score in ranked if score >= self.accept_threshold and best - score <= self.ambiguity_margin]
return plausible or [ranked[0][0]]
__all__ = ["SimilarityItemMatcher"]

View File

@@ -0,0 +1,16 @@
from __future__ import annotations
import io
from typing import Any
from .bill_parser import TimBillParser
from .bill_processor import PDFBillingProcessor
def parse_tim_bill_pdf(pdf_content: bytes, *, include_danfe: bool = False) -> dict[str, Any]:
pdf_bytes = io.BytesIO(pdf_content)
parser = TimBillParser()
dfs = parser.parse_pdf(pdf_bytes)
processor = PDFBillingProcessor(include_danfe=include_danfe)
return processor.normalize(dfs)
__all__ = ["parse_tim_bill_pdf", "TimBillParser", "PDFBillingProcessor"]

View File

@@ -0,0 +1,533 @@
from __future__ import annotations
import io
import re
import unicodedata as ud
from pathlib import Path
from typing import Dict, Union, Any
import pandas as pd
import pdfplumber
###############################################################################
# Normalização #
###############################################################################
_DASH_CHARS = "\u2010\u2011\u2012\u2013\u2014\u2212"
_NBSP_CHARS = "\u00A0\u202F\u2007"
_dash_trans = str.maketrans({c: "-" for c in _DASH_CHARS})
_nbsp_trans = str.maketrans({c: " " for c in _NBSP_CHARS})
def _normalize_line(s: str) -> str:
s = s.translate(_dash_trans).translate(_nbsp_trans)
s = ud.normalize("NFKC", s)
return re.sub(r"\s{2,}", " ", s.strip())
def _parse_money(value: str) -> float | None:
value = value.strip()
if value == "-":
return None
return float(value.replace(".", "").replace(",", "."))
###############################################################################
# Regex Cabeçalhos #
###############################################################################
RX_MSISDN_HEADER = re.compile(r"Vantagens que seu plano oferece:\s*(?P<msisdn>\d{2}\s\d{5}-\d{4})", re.I)
RX_MSISDN_SEU_NUM = re.compile(r"SEU\s+NÚMERO\s+TIM\s+(?P<msisdn>\d{2}\s\d{5}-\d{4})", re.I)
RX_MSISDN_DETALHE = re.compile(r"Detalhamento de Serviços\s+N[°º]\s*(?P<msisdn>\d{2}\s\d{5}-\d{4})", re.I)
SECTION_HEADERS: Dict[str, str] = {
"IGNORE Ilimitados": r"^Detalhamento de Serviços Ilimitados",
"IGNORE Detalhamento": r"^Detalhamento de Serviç[io]s\b",
"Fatura Resumo": r"^FATURA\s+RESUMO",
"DANFE-COM": r"^DANFE-COM\b",
"Plano": r"^Plano\b",
"Mensalidades Adicionais": r"^MENSALIDADES\s+ADICIONAIS",
"Itens Eventuais": r"^ITENS\s+EVENTUAIS",
"TIM Viagem": r"^TIM\s+VIAGEM",
"SVA Detalhe Total": r"^Serviços\s+de\s+Valor\s+Adicionado\s+Total",
"Desconto Franquia": r"^Desconto\(s\)\s+Franquia",
"Desconto SVA": r"^Desconto\(s\)\s+Serviç[io]s",
"Franquia": r"^Franquia\s*\(s\)",
"SVA": r"^Serviços\s+de\s+valor\s+adicionado\(SVA\)",
"Chamadas Rede TIM": r"^CHAMADAS\s+DENTRO\s+DA\s+REDE\s+TIM",
"Chamadas Fora Rede TIM": r"^CHAMADAS\s+FORA\s+DA\s+REDE\s+TIM",
"Outros Valores": r"^OUTROS\s+VALORES",
"Deduções": r"^DEDUÇÕES",
"Roaming Internacional": r"^ROAMING\s+INTERNACIONAL",
"Cobranças de Terceiros": r"^COBRANÇAS\s+DE\s+TERCEIROS",
"Débitos de outras operadoras": r"^DÉBITOS\s+DE\s+OUTRAS\s+OPERADORAS"
}
SECTION_REGEX = {k: re.compile(v, re.I) for k, v in SECTION_HEADERS.items()}
RX_SECTION_TOTAL = re.compile(r"(?:R\$\s*)?(-?[\d\.]+,\d{2})")
# Patterns que encerram a seção atual sem iniciar uma nova
RX_SECTION_BREAK = re.compile(
r"^(Nota Fiscal de Servi|SEUS\s+DADOS|ITEM\s+QTDE\s+ICMS|TOTAL\s+TIM|"
r"Ficou\s+com\s+dúvidas|Bancos\s+Conveniados|Reservado\s+ao\s+fisco|"
r"Tipo:\s+N\s+-\s+Normal)",
re.I,
)
###############################################################################
# Regex Linhas #
###############################################################################
RX_PERIOD = r"(?P<period>(?:\d{2}/\d{2}\s+a\s+\d{2}/\d{2}|-))"
RX_DAYS = r"(?P<days>(?:\d+|-))"
RX_VALUE = r"(?P<value>-?\d+,\d{2}|Incluído)"
RX_PARCEL = r"(?P<parcel>(?:\d+/\d+|-))"
# --- Plano / Desconto / Chamadas (layout padrão: QTY DESC PARCELA PERIOD DIAS VALOR) ---
RX_SIMPLE = re.compile(rf"^\s*(?P<qty>\d+)\s+(?P<desc>.+?)\s+{RX_PARCEL}\s+{RX_PERIOD}\s+{RX_DAYS}\s+{RX_VALUE}\s*$")
RX_CONSUMPTION = re.compile(rf"^\s*(?P<qty>\d+)\s+(?P<desc>.+?)\s+{RX_PARCEL}\s+(?P<franchise>Ilimitado|-)\s+(?P<consumption>\d{{1,3}}m\d{{2}}s)\s+{RX_PERIOD}\s+{RX_DAYS}\s+{RX_VALUE}\s*$")
RX_SUBTOTAL = re.compile(r"^Subtotal\s+(?P<value>-?\d+,\d{2})\s*$", re.I)
RX_DETAIL = re.compile(r"^\d+\s+.+?\s+(-?\d+,\d{2})\s*$")
# Fatura Resumo
RX_RESUMO = re.compile(r"^\s*(?P<desc>.+?)\s+R\$\s*(?P<value>-?\d+,\d{2})\s*$")
RX_TOTAL_GERAL = re.compile(r"^Total\s+geral\s+R\$\s*(?P<value>-?\d+,\d{2})", re.I)
RX_FATURA_METADATA = re.compile(
r"FATURA\s+PER[ÍI]ODO\s+EMISS[ÃA]O\s+POSTAGEM\s+"
r"(?P<fatura>\S+)\s+"
r"(?P<period>\d{2}/\d{2}\s+a\s+\d{2}/\d{2})\s+"
r"(?P<emissao>\d{2}/\d{2}/\d{4})\s+"
r"(?P<postagem>\d{2}/\d{2}/\d{4})",
re.I,
)
RX_DANFE_TOTAL = re.compile(r"^Total\s+geral\s+R\$\s*(?P<value>-?\d+(?:\.\d{3})*,\d{2})", re.I)
RX_DANFE_ROW = re.compile(
r"^(?P<desc>.+?)\s+"
r"(?P<unit>[A-Z]{2})\s+"
r"(?P<qty>\d+(?:,\d+)?)\s+"
r"(?P<preco_unit>-?\d+(?:\.\d{3})*,\d{2})\s+"
r"(?P<pis_cofins>-?\d+(?:\.\d{3})*,\d{2}|-)\s+"
r"(?P<bc_icms>-?\d+(?:\.\d{3})*,\d{2}|-)\s+"
r"(?P<aliq_icms>\d+(?:,\d+)?%|-)\s+"
r"(?P<icms>-?\d+(?:\.\d{3})*,\d{2}|-)\s+"
r"(?P<value>-?\d+(?:\.\d{3})*,\d{2})$"
)
# --- Itens Eventuais (layout: QTY DESC PARCELA FRANQUIA CONSUMO PERIODO DIAS VALOR) ---
# Com consumo real (ex: 19,77GB) ou consumo zerado ("0")
RX_EVENTUAIS = re.compile(
r"^\s*(?P<qty>\d+)\s+(?P<desc>.+?)\s+-\s+-\s+"
r"(?P<consumption>(?:\S+(?:GB|MB|KB)|0))\s+-\s+-\s+"
r"(?P<value>-?\d+,\d{2})\s*$"
)
# Sem consumo (todos "-")
RX_EVENTUAIS_NO_CONS = re.compile(
r"^\s*(?P<qty>\d+)\s+(?P<desc>.+?)"
r"(?:\s+-){5}\s+"
r"(?P<value>-?\d+,\d{2})\s*$"
)
# --- SVA Detalhe Total (layout: # DATA HORA ORIGEM DESC NUMERO TIPO PACOTE - - VALOR) ---
# Exemplo: 1 09/04/25 - 02:54:18 RJ AREA 21 TIM Saude Mensal 00700001003511 N FP - - 14,99
RX_SVA_DETAIL = re.compile(
r"^\s*(?P<seq>\d+)\s+"
r"(?P<date>\d{2}/\d{2}/\d{2})\s+-\s+(?P<time>\d{2}:\d{2}:\d{2})\s+"
r"\S+\s+AREA\s+\d{2}\s+" # origem (ex: RJ AREA 21)
r"(?P<desc>.+?)\s+"
r"(?P<number>\d{10,})\s+"
r"[A-Z/]+\s+[A-Z]+\s+"
r"-\s+-\s+"
r"(?P<value>-?\d+,\d{2})\s*$"
)
# Linha de total SVA (ex: "3 - 2 2 29,98")
RX_SVA_SUMMARY = re.compile(
r"^\s*(?P<seq>\d+)\s+-\s+\d+\s+\d+\s+(?P<value>-?\d+,\d{2})\s*$"
)
# --- Informações Complementares (layout: QTY DESC PARCELA PERÍODO DIAS VALOR) ---
RX_INFO_COMPL = re.compile(
r"^\s*(?P<qty>\d+)\s+(?P<desc>.+?)\s+"
r"(?P<parcel>(?:\d+/\d+|-))\s+"
r"(?P<period>\d{2}/\d{2}\s+a\s+\d{2}/\d{2})\s+"
r"(?P<days>\d+)\s+"
r"(?P<value>-?\d+,\d{2})\s*$"
)
# --- Chamadas resumo (layout: QTY DESC PARCELA FRANQUIA CONSUMO PERIODO DIAS VALOR) ---
RX_CHAMADAS = re.compile(
r"^\s*(?P<qty>\d+)\s+(?P<desc>.+?)\s+-\s+-\s+"
r"(?P<consumption>\d{1,3}m\d{2}s)\s+-\s+-\s+"
r"(?P<value>-?\d+,\d{2})\s*$"
)
# --- Mensalidades Adicionais (layout: QTY DESC PARCELA FRANQUIA CONSUMO PERIODO DIAS VALOR) ---
# Ex.: "1 Apple Music SVA Mes - - 0 - - 21,40" (consumo "0" e sem período)
# "1 Internet 30GB - - - 25/04 a 24/05 30 0,00"
RX_MENSALIDADE = re.compile(
rf"^\s*(?P<qty>\d+)\s+(?P<desc>.+?)\s+{RX_PARCEL}\s+"
rf"(?P<franchise>Ilimitado|-)\s+(?P<consumption>\S+)\s+"
rf"{RX_PERIOD}\s+{RX_DAYS}\s+{RX_VALUE}\s*$"
)
###############################################################################
# Parser #
###############################################################################
class TimBillParser:
"""Extrai e estrutura faturas TIM."""
def __init__(self, *, x_tolerance: float = 1.5, y_tolerance: float = 3.0):
self.x_tol = x_tolerance
self.y_tol = y_tolerance
self.data: Dict[str, pd.DataFrame] = {}
# ---------------- API pública ----------------
def parse_pdf(self, pdf_bytes: io.BytesIO) -> Dict[str, pd.DataFrame]:
# 1. Extrai texto bruto de todas as páginas
with pdfplumber.open(pdf_bytes) as pdf:
raw_text = "\n".join(
page.extract_text(x_tolerance=self.x_tol,
y_tolerance=self.y_tol) or ""
for page in pdf.pages
)
# ------------------------------------------------------------------
# 2. Captura o bloco FATURA RESUMO inteiro
bloco_pat = re.compile(
r"FATURA\s+RESUMO(?P<body>.*?)Total\s+geral\s+R\$\s*(?P<total>-?[\d\.,]+)",
re.S | re.I,
)
resumo_items = []
resumo_metadata = self._extract_fatura_metadata(raw_text)
if resumo_metadata.get("period"):
resumo_items.append(
dict(qty=None, desc="PERÍODO", parcel=None, period=resumo_metadata["period"],
days=None, value=None, franchise=None, consumption=None, msisdn=None,
emissao=None, section_total=None)
)
if resumo_metadata.get("emissao"):
resumo_items.append(
dict(qty=None, desc="EMISSÃO", parcel=None, period=None,
days=None, value=None, franchise=None, consumption=None, msisdn=None,
emissao=resumo_metadata["emissao"], section_total=None)
)
m_resumo = bloco_pat.search(raw_text)
if m_resumo:
corpo = m_resumo.group("body")
total_val = float(m_resumo.group("total").replace(".", "").replace(",", "."))
for ln in corpo.splitlines():
ln = _normalize_line(ln)
if not ln:
continue
m_ln = re.match(r"(.+?)\s+R\$\s*([-\d\.,]+)", ln)
if m_ln:
resumo_items.append(
dict(qty=None, desc=m_ln.group(1), parcel=None, period=None,
days=None,
value=float(m_ln.group(2).replace(".", "").replace(",", ".")),
franchise=None, consumption=None, msisdn=None,
section_total=None)
)
resumo_items.append(
dict(qty=None, desc="Total geral", parcel=None, period=None,
days=None, value=total_val, franchise=None, consumption=None,
msisdn=None, section_total=None)
)
# 3. Remove o bloco para que não polua a etapa linha-a-linha
raw_text = raw_text.replace(m_resumo.group(0), "")
# ------------------------------------------------------------------
# 4. Processa o restante normalmente
dfs = self._parse_text(raw_text)
if resumo_items:
dfs["Fatura Resumo"] = pd.DataFrame(resumo_items)
self.data = dfs
return dfs
# ---------------- interno -------------------
def _extract_fatura_metadata(self, text: str) -> dict[str, str]:
normalized_lines = [_normalize_line(line) for line in text.splitlines()]
normalized_text = " ".join(line for line in normalized_lines if line)
if m := RX_FATURA_METADATA.search(normalized_text):
return {
"period": m.group("period"),
"emissao": m.group("emissao"),
}
return {}
def _parse_text(self, text: str) -> Dict[str, pd.DataFrame]:
"""Quebra o texto extraído em DataFrames por seção."""
buf: Dict[str, list] = {k: [] for k in SECTION_HEADERS if not k.startswith("IGNORE")}
current_sec = current_msisdn = None
current_total: float | None = None
last_sva_detail_item: dict | None = None
for raw in text.splitlines():
line = _normalize_line(raw)
if not line:
continue
# cabeçalho de MSISDN (3 variantes)
for rx_ms in (RX_MSISDN_HEADER, RX_MSISDN_SEU_NUM, RX_MSISDN_DETALHE):
if (m := rx_ms.search(line)):
current_msisdn = m.group("msisdn").replace(" ", "")
break
else:
m = None
if m:
continue
# section break — encerra seção atual sem iniciar outra
if RX_SECTION_BREAK.match(line):
current_sec = None
continue
# detecta novo cabeçalho (a menos que estejamos em Fatura Resumo)
if current_sec != "Fatura Resumo":
sec = next((s for s, rx in SECTION_REGEX.items() if rx.match(line)), None)
else:
sec = None
if (sec in ("IGNORE Ilimitados", "IGNORE Detalhamento")):
current_sec = None
continue
if sec is not None:
current_sec = sec
last_sva_detail_item = None
mt = RX_SECTION_TOTAL.search(line)
current_total = float(mt.group(1).replace(".", "").replace(",", ".")) if mt else None
continue
if not current_sec:
if line[0].isdigit():
item = self._parse_sva_detail(line)
if item:
current_sec = "SVA Detalhe Total"
current_total = None
item["msisdn"] = current_msisdn
item["section_total"] = None
buf.setdefault(current_sec, []).append(item)
last_sva_detail_item = item
continue
# pula cabeçalhos de página
if re.match(r"^Página\s+\d+\s+de\s+\d+", line, re.I):
continue
if current_sec == "DANFE-COM":
item = self._parse_danfe(line)
if item:
item["msisdn"] = None
item["section_total"] = current_total
buf.setdefault(current_sec, []).append(item)
if item.get("is_total"):
current_sec = None
continue
if current_sec == "SVA Detalhe Total":
if self._is_sva_header_line(line):
continue
if RX_SVA_SUMMARY.match(line):
last_sva_detail_item = None
continue
if line[0].isdigit():
item = self._parse_sva_detail(line)
if item:
item["msisdn"] = current_msisdn
item["section_total"] = current_total
buf.setdefault(current_sec, []).append(item)
last_sva_detail_item = item
continue
if last_sva_detail_item:
last_sva_detail_item["desc"] = (
f"{last_sva_detail_item['desc']} {line}"
).strip()
continue
# -------- linhas padrão --------
if not (line[0].isdigit() or line.lower().startswith("subtotal")):
continue
item = self._parse_item_line(line, current_sec)
if item:
item["msisdn"] = current_msisdn
item["section_total"] = current_total
buf.setdefault(current_sec, []).append(item)
return {s: pd.DataFrame(lst) for s, lst in buf.items() if lst}
# ------------------------------------------------------------------
def _parse_item_line(self, line: str, section: str = "") -> dict | None:
"""Parseia uma linha de item de acordo com a seção."""
# Ignora subtotal
if RX_SUBTOTAL.match(line):
return None
# ── Mensalidades Adicionais ──
if section == "Mensalidades Adicionais":
return self._parse_mensalidades(line)
# ── Itens Eventuais ──
if section == "Itens Eventuais":
return self._parse_eventuais(line)
# ── SVA Detalhe Total ──
if section == "SVA Detalhe Total":
return self._parse_sva_detail(line)
# ── DANFE-COM ──
if section == "DANFE-COM":
return self._parse_danfe(line)
# ── Chamadas Rede TIM / Chamadas Fora Rede TIM ──
if section.startswith("Chamadas"):
return self._parse_chamadas(line)
# ── Plano / Franquia / SVA / Desconto Franquia / Desconto SVA (layout padrão) ──
return self._parse_standard(line)
# ------------------------------------------------------------------
def _parse_danfe(self, line: str) -> dict | None:
"""DANFE-COM: linhas da tabela de itens e total geral."""
if line.upper().startswith("ITENS "):
return None
if (m := RX_DANFE_TOTAL.match(line)):
return {
"desc": "Total geral",
"unit": None,
"qty": None,
"preco_unit": None,
"pis_cofins": None,
"bc_icms": None,
"aliq_icms": None,
"icms": None,
"value": _parse_money(m.group("value")),
"is_total": True,
}
if (m := RX_DANFE_ROW.match(line)):
d = m.groupdict()
return {
"desc": d["desc"].strip(),
"unit": d["unit"],
"qty": float(d["qty"].replace(",", ".")),
"preco_unit": _parse_money(d["preco_unit"]),
"pis_cofins": _parse_money(d["pis_cofins"]),
"bc_icms": _parse_money(d["bc_icms"]),
"aliq_icms": None if d["aliq_icms"] == "-" else d["aliq_icms"],
"icms": _parse_money(d["icms"]),
"value": _parse_money(d["value"]),
"is_total": False,
}
return None
# ------------------------------------------------------------------
def _parse_standard(self, line: str) -> dict | None:
"""Regex padrão: QTY DESC PARCELA [FRANCHISE CONSUMPTION] PERIOD DAYS VALUE."""
for rx in (RX_CONSUMPTION, RX_SIMPLE):
if (m := rx.match(line)):
d = m.groupdict()
# --- corrige parcel dentro de desc (caso d["parcel"] == "-") ----
if d["parcel"] == "-":
tail = re.search(r"\b(\d+/\d+)$", d["desc"])
if tail:
d["parcel"] = tail.group(1)
d["desc"] = d["desc"][: tail.start()].rstrip(" -")
# ----------------------------------------------------------------
# limpa trailing dashes e valores de franquia residuais do desc
d["desc"] = re.sub(r"(\s+-\s+\d+(?:GB|MB|KB))(?:\s+-)*\s*$|(?:\s+-)+\s*$", "", d["desc"]).strip()
value = d["value"]
d["qty"] = float(d["qty"])
d["days"] = None if d.get("days") in (None, "-") else int(d["days"])
d["_is_included_value"] = value == "Incluído"
d["value"] = 0.0 if d["_is_included_value"] else float(value.replace(",", "."))
return d
return None
# ------------------------------------------------------------------
def _parse_eventuais(self, line: str) -> dict | None:
"""Itens Eventuais: QTY DESC PARCELA FRANQUIA CONSUMO PERIODO DIAS VALUE."""
# Tenta com consumo real (ex: 19,77GB)
if (m := RX_EVENTUAIS.match(line)):
return {
"qty": float(m.group("qty")),
"desc": m.group("desc").strip(),
"parcel": None,
"period": None,
"days": None,
"value": float(m.group("value").replace(",", ".")),
"franchise": None,
"consumption": None if m.group("consumption") == "0" else m.group("consumption"),
}
# Tenta sem consumo (todos "-")
if (m := RX_EVENTUAIS_NO_CONS.match(line)):
return {
"qty": float(m.group("qty")),
"desc": m.group("desc").strip(),
"parcel": None,
"period": None,
"days": None,
"value": float(m.group("value").replace(",", ".")),
"franchise": None,
"consumption": None,
}
return None
# ------------------------------------------------------------------
def _parse_mensalidades(self, line: str) -> dict | None:
"""Mensalidades Adicionais: QTY DESC PARCELA FRANQUIA CONSUMO PERIODO DIAS VALOR."""
if (m := RX_MENSALIDADE.match(line)):
d = m.groupdict()
d["qty"] = float(d["qty"])
d["desc"] = d["desc"].strip()
d["parcel"] = None if d["parcel"] == "-" else d["parcel"]
d["franchise"] = None if d["franchise"] == "-" else d["franchise"]
d["consumption"] = None if d["consumption"] in ("-", "0") else d["consumption"]
d["period"] = None if d["period"] == "-" else d["period"]
d["days"] = None if d["days"] == "-" else int(d["days"])
value = d["value"]
d["_is_included_value"] = value == "Incluído"
d["value"] = 0.0 if d["_is_included_value"] else float(value.replace(",", "."))
return d
# Layout de 6 colunas (sem franquia/consumo) → regex padrão
return self._parse_standard(line)
# ------------------------------------------------------------------
def _is_sva_header_line(self, line: str) -> bool:
return bool(
re.match(r"^(DURAÇÃO/VOLUME|#\s+DATA\s*/\s*HORA)\b", line, re.I)
)
# ------------------------------------------------------------------
def _parse_sva_detail(self, line: str) -> dict | None:
"""SVA Detalhe Total: linhas com data/hora e linha de totalização."""
# Linha de detalhe com data/hora
if (m := RX_SVA_DETAIL.match(line)):
return {
"qty": 1.0,
"desc": m.group("desc").strip(),
"parcel": None,
"period": m.group("date"),
"days": None,
"value": float(m.group("value").replace(",", ".")),
"franchise": None,
"consumption": None,
}
return None
# ------------------------------------------------------------------
def _parse_chamadas(self, line: str) -> dict | None:
"""Chamadas Rede TIM / Fora Rede: QTY DESC - - CONSUMPTION - - VALUE."""
if (m := RX_CHAMADAS.match(line)):
return {
"qty": float(m.group("qty")),
"desc": m.group("desc").strip(),
"parcel": None,
"period": None,
"days": None,
"value": float(m.group("value").replace(",", ".")),
"franchise": None,
"consumption": m.group("consumption"),
}
# Fallback para o padrão (algumas chamadas usam formato padrão)
return self._parse_standard(line)

View File

@@ -0,0 +1,743 @@
from __future__ import annotations
import re
import unicodedata as ud
from typing import Any, Dict, List, Tuple
import pandas as pd
# ---------------------------------------------------------------------------
# Regex para linha de VAS
# ---------------------------------------------------------------------------
RX_VAS_LINE = re.compile(
r"""
^\d+\s+ # índice "#"
(?P<date>\d{2}/\d{2}/\d{2}) # DD/MM/YY
\s+-\s+\d{2}:\d{2}:\d{2}\s+ # " - HH:MM:SS "
[A-Z]{2}\sAREA\s\d+\s+ # "SP AREA 11"
(?P<name>.+?)\s+ # nome do serviço
\d{8,} # número chamado
""",
re.X,
)
# ---------------------------------------------------------------------------
class PDFBillingProcessor:
"""
Pós-processamento dos DataFrames gerados pelo TimBillParser.
"""
SECTIONS = [
"Fatura Resumo",
"Plano",
"DANFE-COM",
"Descontos",
"Itens Eventuais",
"Mensalidades Adicionais",
"TIM Viagem",
"Outros Valores",
"Chamadas Rede TIM",
"SVA Detalhe Total",
"Serviços Bundle Inclusos",
"Deduções",
"Roaming Internacional",
"Cobranças de Terceiros",
"Débitos de outras operadoras",
]
STRATEGIC_SERVICE_SECTIONS = {
"SVA Detalhe Total",
"Itens Eventuais",
"Serviços Bundle Inclusos",
"Mensalidades Adicionais",
"Outros Valores",
"Cobranças de Terceiros",
}
SECTION_CLASS_MAP = {
"SVA Detalhe Total": "avulso",
"Itens Eventuais": "avulso",
"Serviços Bundle Inclusos": "bundle",
}
CLASSE_VERB_MAP = {
"avulso": "cancelar",
"bundle": "falar sobre",
"estrategico": "falar sobre",
}
STRATEGIC_SERVICES = [
"Apple Music",
"Deezer",
"Disney",
"Fuze",
"Forge",
"HBO",
"Looke",
"Max Mensal",
"Netflix",
"Paramount",
"TIM Cloud Gaming",
"YouTube",
"Globoplay",
"Amazon Prime"
]
DIGIT_WORDS = {
"0": "zero",
"1": "um",
"2": "dois",
"3": "tres",
"4": "quatro",
"5": "cinco",
"6": "seis",
"7": "sete",
"8": "oito",
"9": "nove",
}
def __init__(self, *, include_danfe: bool = False):
self.include_danfe = include_danfe
self._strategic_service_patterns = [
re.compile(rf"(?:^|\s){re.escape(self._service_match_key(service))}(?:\s|$)")
for service in self.STRATEGIC_SERVICES
]
def remove_parentheses(self, text: str) -> str:
return re.sub(r'\s*\([^)]*\)', '', str(text)).strip()
def remove_plan_recognition_marker(self, text: str) -> str:
return re.sub(
r"\s*\(\s*\d+\s*/\s*P[ÓO]S\s*/\s*SMP\s*\)",
"",
str(text),
flags=re.I,
).strip()
def _format_plan_version_number(self, text: str) -> str:
return re.sub(r"(?<!\d)(\d)\s+(\d)(?!\d)", r"\1.\2", str(text))
def _format_msisdn(self, text: Any) -> str:
return re.sub(r"\D", "", str(text))
def _vocalize_digits(self, digits: str) -> str:
return " ".join(self.DIGIT_WORDS[d] for d in digits if d in self.DIGIT_WORDS)
def _collect_msisdns(self, node: Any, found: set) -> None:
"""Percorre recursivamente o payload coletando todos os msisdn presentes."""
if isinstance(node, dict):
for key, value in node.items():
if key == "msisdn" and value is not None:
msisdn = self._format_msisdn(value)
if msisdn:
found.add(msisdn)
else:
self._collect_msisdns(value, found)
elif isinstance(node, list):
for item in node:
self._collect_msisdns(item, found)
def _build_vocalized_msisdn(self, final_output: Dict[str, Any]) -> Dict[str, str]:
"""Mapeia cada msisdn da fatura para a vocalização dos seus 4 últimos dígitos."""
found: set = set()
for key, value in final_output.items():
if re.fullmatch(r"\d+", str(key)):
found.add(self._format_msisdn(key))
self._collect_msisdns(value, found)
return {
msisdn: self._vocalize_digits(msisdn[-4:])
for msisdn in sorted(found)
}
def _clean_plan_name(self, desc: str, *, format_version_number: bool = True) -> str:
text = self.remove_parentheses(self.remove_plan_recognition_marker(desc))
if format_version_number:
text = self._format_plan_version_number(text)
return text
def _discount_match_key(self, desc: str) -> str:
text = self._clean_plan_name(desc)
text = ud.normalize("NFKD", text)
text = "".join(ch for ch in text if not ud.combining(ch))
text = text.casefold()
text = re.sub(r"\b\d+/\d+\b", " ", text)
text = re.sub(r"\b\d+\b", " ", text)
text = re.sub(r"[^a-z]+", " ", text)
return re.sub(r"\s+", " ", text).strip()
def _service_match_key(self, desc: str) -> str:
text = ud.normalize("NFKD", str(desc))
text = "".join(ch for ch in text if not ud.combining(ch))
text = text.casefold()
text = re.sub(r"[^a-z0-9]+", " ", text)
return re.sub(r"\s+", " ", text).strip()
def _is_strategic_service(self, desc: Any) -> bool:
desc_key = self._service_match_key(str(desc or ""))
return any(pattern.search(desc_key) for pattern in self._strategic_service_patterns)
def _is_controle_plan(self, desc: Any) -> bool:
desc_key = self._service_match_key(str(desc or ""))
return bool(re.search(r"\b(?:controle|ctrl|crtl)\b", desc_key))
def calculate_discount(self, desc_match: str, msisdn: str, descontos_df: pd.DataFrame) -> float:
# Filtra por msisdn e verifica se o desc_match está contido no desc
descontos_filtrados = descontos_df[
(descontos_df['msisdn'] == msisdn) &
(descontos_df['desc'].str.lower().str.contains(desc_match.lower(), na=False))
]
return descontos_filtrados['value'].sum()
# ------------------------------------------------------------------
def normalize(self, raw: Dict[str, pd.DataFrame]) -> Dict[str, Any]:
all_msisdns = set()
for sec, df in raw.items():
if sec != "Fatura Resumo" and "msisdn" in df.columns:
all_msisdns.update(df["msisdn"].dropna().astype(str).unique())
final_output: Dict[str, Any] = {}
# Processa Fatura Resumo primeiro (independente de MSISDN)
if "Fatura Resumo" in raw:
fatura_raw = {"Fatura Resumo": raw["Fatura Resumo"]}
fatura_dfs = self._clean_dataframes(fatura_raw)
fatura_json = self._build_final_json(fatura_dfs)
if "Fatura Resumo" in fatura_json:
final_output["Fatura Resumo"] = fatura_json["Fatura Resumo"]
if self.include_danfe and "DANFE-COM" in raw:
danfe_json = self._build_danfe_payload(raw["DANFE-COM"], raw)
if danfe_json:
final_output["DANFE-COM"] = danfe_json
if not all_msisdns:
# Se não houver MSISDNs, processa tudo como global
dfs = self._clean_dataframes(raw)
dfs = self._process_plano(dfs)
dfs = self._process_eventuais(dfs)
dfs = self._process_vas(dfs)
dfs = self._process_descontos(dfs)
fallback_json = self._build_final_json(dfs)
for k, v in fallback_json.items():
if k not in final_output:
final_output[k] = v
final_output["vocalized_msisdn"] = self._build_vocalized_msisdn(final_output)
return final_output
for msisdn in all_msisdns:
msisdn_raw = {}
for sec, df in raw.items():
if sec == "Fatura Resumo":
continue
if "msisdn" in df.columns:
mask = df["msisdn"].astype(str) == msisdn
filtered_df = df[mask].copy()
if not filtered_df.empty:
msisdn_raw[sec] = filtered_df
else:
# Seções sem MSISDN podem ser globais? (Ex: Fatura Resumo já tratada)
pass
if msisdn_raw:
dfs = self._clean_dataframes(msisdn_raw)
dfs = self._process_plano(dfs)
dfs = self._process_eventuais(dfs)
dfs = self._process_vas(dfs)
dfs = self._process_descontos(dfs)
msisdn_json = self._build_final_json(dfs)
if msisdn_json:
final_output[self._format_msisdn(msisdn)] = msisdn_json
final_output["vocalized_msisdn"] = self._build_vocalized_msisdn(final_output)
return final_output
def _clean_dataframes(self, raw: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
# 1) remove value == 0 exceto de fatura resumo e plano (que pode ter o bundle)
dfs = {
sec: (df if sec in ["Fatura Resumo", "Plano"] else df[df.value != 0.0]).copy()
for sec, df in raw.items() if "value" in df.columns
}
# 2) drop colunas totalmente NaN
dfs = {
sec: df.dropna(axis=1, how="all")
for sec, df in dfs.items() if not df.dropna(axis=1, how="all").empty
}
# 3) remove sufixo " - -" do campo desc
for df in dfs.values():
if "desc" in df.columns:
df["desc"] = df["desc"].str.replace(r"\s*- -\s*$", "", regex=True)
df["desc"] = df["desc"].str.strip()
return dfs
def _process_plano(self, dfs: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
if "Plano" not in dfs:
return dfs
plano_df = dfs["Plano"]
if 'msisdn' in plano_df.columns:
plano_df['msisdn'] = plano_df['msisdn'].astype(str)
# Separa somente itens que vieram textualmente como "Incluído".
# Planos dependentes podem aparecer como R$ 0,00 e continuam sendo Plano.
if "_is_included_value" in plano_df.columns:
included_mask = plano_df["_is_included_value"].fillna(False).astype(bool)
else:
included_mask = plano_df.value == 0.0
bundle_df = plano_df[included_mask].copy()
plano_df = plano_df[~included_mask].copy()
if not bundle_df.empty:
bundle_df["value"] = "Incluído"
if "section_total" in bundle_df.columns:
bundle_df = bundle_df.drop(columns=["section_total"])
dfs["Serviços Bundle Inclusos"] = bundle_df
# Seleciona planos principais
mask_plan = plano_df["desc"].str.contains(r"PÓS/SMP", case=False, na=False)
if mask_plan.sum() == 0:
mask_plan = plano_df["desc"].str.match(r"(?i)^tim", case=False)
# Seleciona descontos
descontos_df = plano_df[plano_df['desc'].apply(lambda x: 'desc' in str(x).lower() and 'tim' in str(x).lower())]
plano_df = plano_df[mask_plan].copy()
plano_df["desc"] = plano_df["desc"].apply(self.remove_plan_recognition_marker)
dfs["_descontos_temp"] = descontos_df
if plano_df.empty:
dfs.pop("Plano", None)
else:
dfs["Plano"] = plano_df
return dfs
def _process_eventuais(self, dfs: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
if "Itens Eventuais" in dfs:
ie_df = dfs["Itens Eventuais"]
mask_remove = (ie_df["value"] == 0) | (ie_df["desc"].str.contains("Serviços de Valor Adicionado Conteúdo", case=False, na=False))
ie_df = ie_df[~mask_remove].copy()
if ie_df.empty:
dfs.pop("Itens Eventuais")
else:
dfs["Itens Eventuais"] = ie_df
return dfs
def _process_vas(self, dfs: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
"""Remove de 'Mensalidades Adicionais' os serviços recorrentes que já vêm
detalhados (com data de ativação) em 'SVA Detalhe Total', evitando duplicata.
A seção 'Serviços de Valor Adicionado Total' do PDF é o detalhamento que
reúne tanto os itens eventuais quanto as mensalidades recorrentes; quando o
mesmo serviço aparece nas duas seções, mantemos a cópia detalhada do SVA."""
sva_df = dfs.get("SVA Detalhe Total")
if sva_df is None or sva_df.empty or "desc" not in sva_df.columns:
return dfs
detalhados = {
(self._format_msisdn(rec.get("msisdn")), self._service_match_key(rec.get("desc")))
for rec in sva_df.to_dict(orient="records")
}
for section in ("Mensalidades Adicionais", "Itens Eventuais"):
df = dfs.get(section)
if df is None or df.empty or "desc" not in df.columns:
continue
mask_dup = df.apply(
lambda r: (
self._format_msisdn(r.get("msisdn")),
self._service_match_key(r.get("desc")),
) in detalhados,
axis=1,
)
if mask_dup.any():
df = df[~mask_dup].copy()
if df.empty:
dfs.pop(section, None)
else:
dfs[section] = df
return dfs
def _process_descontos(self, dfs: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
if "_descontos_temp" in dfs:
descontos_df = dfs.pop("_descontos_temp")
if not descontos_df.empty:
if "section_total" in descontos_df.columns:
descontos_df = descontos_df.drop(columns=["section_total"])
descontos_df['installment'] = descontos_df['desc'].str.extract(r'(\d+/\d+)')
descontos_df['desc'] = descontos_df['desc'].apply(self._format_plan_version_number)
if "Descontos" in dfs and not dfs["Descontos"].empty:
dfs["Descontos"] = pd.concat(
[dfs["Descontos"], descontos_df],
ignore_index=True,
)
else:
dfs["Descontos"] = descontos_df
return dfs
def _clean_record(self, rec: Dict[str, Any]) -> Dict[str, Any]:
redundant_values = {
"qty": [1.0, 1],
"parcel": ["-", None],
"franchise": [None, "null", "-"],
"consumption": [None, "null"],
"period": ["-", None],
"value": [None],
"emissao": [None, "null", "-"],
}
cleaned_rec = {}
for k, v in rec.items():
if k == "section_total" or k.startswith("_"):
continue
if k == "msisdn":
v = self._format_msisdn(v)
if pd.isna(v):
v = None
elif k == "value" and isinstance(v, (int, float)):
v = self._round_money(v)
elif k == "days" and isinstance(v, float) and v.is_integer():
v = int(v)
if k in redundant_values and v in redundant_values[k]:
continue
cleaned_rec[k] = v
return cleaned_rec
def _records_for_section(self, df: pd.DataFrame) -> List[Dict[str, Any]]:
return [
self._clean_record(rec)
for rec in df.to_dict(orient="records")
]
def _split_strategic_records(
self,
df: pd.DataFrame,
section: str,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
records = self._records_for_section(df)
if section not in self.STRATEGIC_SERVICE_SECTIONS:
return records, []
regular_records = []
strategic_records = []
for record in records:
if "desc" in record and self._is_strategic_service(record["desc"]):
record["estrategico"] = True
strategic_records.append(record)
else:
regular_records.append(record)
return regular_records, strategic_records
def _annotate_class(
self,
records: List[Dict[str, Any]],
section: str,
) -> List[Dict[str, Any]]:
section_classe = self.SECTION_CLASS_MAP.get(section)
if not section_classe:
return records
for rec in records:
# Itens estratégicos dobrados nesta seção (commit 7fc38a4e) não
# herdam a classe da seção (avulso): mantêm classe=estrategico e
# verbo "falar sobre". Os demais seguem o default da seção.
classe = "estrategico" if rec.get("estrategico") is True else section_classe
rec["classe"] = classe
verb = self.CLASSE_VERB_MAP.get(classe)
if verb:
rec["verb"] = verb
return records
def _round_money(self, value: float) -> float:
return round(float(value) + 0.0, 2)
def _is_discount_desc(self, desc: str) -> bool:
return bool(re.match(r"(?i)^\s*desc(?:onto)?\b", desc))
def _danfe_record(self, rec: Dict[str, Any]) -> Dict[str, Any]:
cleaned = {}
for k, v in rec.items():
if k in {"section_total", "msisdn", "is_total"}:
continue
if pd.isna(v):
v = None
elif k in {"preco_unit", "pis_cofins", "bc_icms", "icms", "value"} and isinstance(v, (int, float)):
v = self._round_money(v)
elif k == "qty" and isinstance(v, float) and v.is_integer():
v = int(v)
cleaned[k] = v
return cleaned
def _danfe_item_payload(self, rec: Dict[str, Any]) -> Dict[str, Any]:
item = self._danfe_record(rec)
valor_bruto = self._round_money(item.pop("value", 0.0) or 0.0)
item["valor_bruto"] = valor_bruto
item["total_descontos"] = 0.0
item["valor_final"] = valor_bruto
item["descontos"] = []
return item
def _danfe_discount_payload(self, rec: Dict[str, Any]) -> Dict[str, Any]:
discount = self._danfe_record(rec)
if "value" in discount and discount["value"] is not None:
discount["value"] = self._round_money(discount["value"])
return discount
def _desc_matches_key(self, desc_key: str, target_key: str) -> bool:
return bool(
desc_key
and target_key
and (desc_key == target_key or desc_key.startswith(target_key) or target_key.startswith(desc_key))
)
def _discount_matches_item(self, discount_desc: str, item_desc: str) -> bool:
discount_key = self._discount_match_key(discount_desc)
item_key = self._discount_match_key(item_desc)
return bool(item_key and item_key in discount_key)
def _build_danfe_payload(
self,
danfe_df: pd.DataFrame,
raw: Dict[str, pd.DataFrame],
) -> Dict[str, Any]:
if danfe_df.empty:
return {}
total_geral = None
item_records = []
for rec in danfe_df.to_dict(orient="records"):
if bool(rec.get("is_total")):
total_geral = self._round_money(rec.get("value", 0.0) or 0.0)
else:
item_records.append(rec)
plan_names: List[str] = []
if "Plano" in raw and "desc" in raw["Plano"].columns:
plano_df = raw["Plano"]
mask_plan = plano_df["desc"].str.contains(r"PÓS/SMP", case=False, na=False)
if mask_plan.sum() == 0:
mask_plan = plano_df["desc"].str.match(r"(?i)^tim", na=False)
for desc in plano_df.loc[mask_plan, "desc"].dropna().astype(str):
plan_name = self._clean_plan_name(desc, format_version_number=False)
if plan_name not in plan_names:
plan_names.append(plan_name)
if not plan_names:
for rec in item_records:
desc = str(rec.get("desc", ""))
desc_key = self._discount_match_key(desc)
if desc_key.startswith("tim ") and not self._is_discount_desc(desc):
plan_name = self._clean_plan_name(desc, format_version_number=False)
if plan_name.casefold() != "tim music" and plan_name not in plan_names:
plan_names.append(plan_name)
plan_matchers = [(name, self._discount_match_key(name)) for name in plan_names]
other_keys: List[str] = []
if "Outros Valores" in raw and "desc" in raw["Outros Valores"].columns:
for desc in raw["Outros Valores"]["desc"].dropna().astype(str):
key = self._discount_match_key(desc)
if key and key not in other_keys:
other_keys.append(key)
payload: Dict[str, Any] = {"Planos": {name: [] for name in plan_names}}
if total_geral is not None:
payload["total_geral"] = total_geral
outros_itens: List[Dict[str, Any]] = []
current_plan: str | None = None
last_item_by_plan: Dict[str, Dict[str, Any]] = {}
for rec in item_records:
desc = str(rec.get("desc", ""))
desc_key = self._discount_match_key(desc)
matched_plan = next(
(
name
for name, plan_key in plan_matchers
if self._desc_matches_key(desc_key, plan_key)
),
None,
)
is_known_other = any(
self._desc_matches_key(desc_key, other_key)
for other_key in other_keys
)
is_discount = self._is_discount_desc(desc)
if matched_plan and not is_discount:
current_plan = matched_plan
item = self._danfe_item_payload(rec)
payload["Planos"].setdefault(current_plan, []).append(item)
last_item_by_plan[current_plan] = item
continue
if is_known_other and not is_discount:
outros_itens.append(self._danfe_item_payload(rec))
continue
if is_discount:
target_item = None
if current_plan:
for item in reversed(payload["Planos"].get(current_plan, [])):
if self._discount_matches_item(desc, str(item.get("desc", ""))):
target_item = item
break
if target_item is None:
target_item = last_item_by_plan.get(current_plan)
if target_item is None:
outros_itens.append(self._danfe_discount_payload(rec))
continue
discount = self._danfe_discount_payload(rec)
target_item["descontos"].append(discount)
continue
if current_plan:
item = self._danfe_item_payload(rec)
payload["Planos"].setdefault(current_plan, []).append(item)
last_item_by_plan[current_plan] = item
else:
outros_itens.append(self._danfe_item_payload(rec))
for items in payload["Planos"].values():
for item in items:
item["descontos"].sort(
key=lambda discount: abs(float(discount.get("value") or 0.0)),
reverse=True,
)
total_descontos = sum(
float(discount.get("value") or 0.0)
for discount in item["descontos"]
)
item["total_descontos"] = self._round_money(total_descontos)
item["valor_final"] = self._round_money(
float(item.get("valor_bruto") or 0.0) + total_descontos
)
if outros_itens:
payload["Outros Itens"] = outros_itens
return payload
def _match_discount_to_plan(
self,
discount: Dict[str, Any],
plans: List[Tuple[str, Dict[str, Any], str]],
) -> str | None:
discount_key = self._discount_match_key(str(discount.get("desc", "")))
matches = [
(plan_name, plan)
for plan_name, plan, plan_key in plans
if plan_key and plan_key in discount_key
]
if not matches:
return None
discount_period = discount.get("period")
if discount_period:
for plan_name, plan in matches:
if plan.get("period") == discount_period:
return plan_name
return matches[0][0]
def _build_plan_payload(
self,
plano_df: pd.DataFrame | None,
descontos_df: pd.DataFrame | None,
) -> tuple[Dict[str, Dict[str, Any]], List[Dict[str, Any]]]:
if plano_df is None or plano_df.empty:
return {}, self._records_for_section(descontos_df) if descontos_df is not None else []
plan_records = self._records_for_section(plano_df)
discount_records = self._records_for_section(descontos_df) if descontos_df is not None else []
payload: Dict[str, Dict[str, Any]] = {}
plan_matchers: List[Tuple[str, Dict[str, Any], str]] = []
for plan in plan_records:
desc = plan.get("desc")
if not desc:
continue
plan_name = self._clean_plan_name(str(desc))
plan_payload = {}
for field in ("period", "days", "msisdn"):
if field in plan:
plan_payload[field] = plan[field]
valor_bruto = self._round_money(plan.get("value", 0.0))
plan_payload["valor_final"] = valor_bruto
for field, value in plan.items():
if field not in {"desc", "period", "days", "msisdn", "value"}:
plan_payload[field] = value
if self._is_controle_plan(desc):
plan_payload["is_controle"] = True
plan_payload["descontos"] = []
plan_payload["total_descontos"] = 0.0
plan_payload["valor_bruto"] = valor_bruto
payload[plan_name] = plan_payload
plan_matchers.append((plan_name, plan, self._discount_match_key(plan_name)))
unmatched_discounts: List[Dict[str, Any]] = []
for discount in discount_records:
plan_name = self._match_discount_to_plan(discount, plan_matchers)
if plan_name is None:
unmatched_discounts.append(discount)
continue
discount_payload = {
"desc": discount.get("desc"),
"value": discount.get("value"),
"installment": discount.get("installment"),
}
payload[plan_name]["descontos"].append(discount_payload)
for plan in payload.values():
total_descontos = sum(
float(discount.get("value") or 0.0)
for discount in plan["descontos"]
)
plan["total_descontos"] = self._round_money(total_descontos)
valor_final = self._round_money(
float(plan.get("valor_bruto") or 0.0) + total_descontos
)
plan["valor_final"] = valor_final
return payload, unmatched_discounts
def _build_final_json(self, dfs: Dict[str, pd.DataFrame]) -> Dict[str, Any]:
out: Dict[str, Any] = {}
plano_payload, descontos_restantes = self._build_plan_payload(
dfs.get("Plano"),
dfs.get("Descontos"),
)
for sec in self.SECTIONS:
if sec == "DANFE-COM":
continue
if sec == "Plano" and plano_payload:
out["Planos"] = plano_payload
continue
if sec == "Descontos" and plano_payload:
if descontos_restantes:
out[sec] = descontos_restantes
continue
if sec in dfs:
section_records, strategic_records = self._split_strategic_records(
dfs[sec],
sec,
)
all_records = section_records + strategic_records
if all_records:
out[sec] = self._annotate_class(all_records, sec)
return out

View File

@@ -0,0 +1,305 @@
#!/usr/bin/env python3
"""Transformacao da fatura normalizada (dict JSON) -> formato textual em blocos.
Formato economico em tokens (~72% menor que o JSON indentado), pensado para enviar
a uma LLM. API publica: `to_text(data)` e a flag `USE_TEXT_FORMAT`.
"""
from __future__ import annotations
import json
import logging
import os
import re
from typing import Any
logger = logging.getLogger(__name__)
# Liga o formato textual da fatura no prompt. Default conceitual: False;
# mantido True nesta fase de rollout. Override por env, sem edicao de codigo.
USE_TEXT_FORMAT = os.getenv("TIM_INVOICE_DETAIL_AS_TEXT", "true").lower() == "true"
# ---------------------------------------------------------------------------
# secao -> (acao, classe). O cabecalho usa o proprio nome da secao (mantido como no JSON).
SEC_META = {
"SVA Detalhe Total": ("cancelar", "avulso"),
"Itens Eventuais": ("cancelar", "avulso"),
"Serviços Bundle Inclusos": ("falar sobre", "bundle"),
}
# Secoes que rendem a acao por ITEM (sem "| ação=... | classe" no header).
# Item estrategico (estrategico=True) usa "falar sobre"; avulso, "cancelar".
PER_ITEM_ACTION_SECTIONS = {
"SVA Detalhe Total",
"Itens Eventuais",
"Mensalidades Adicionais",
}
# Secoes cujo header recebe o rotulo "| cobrados a parte" (servicos avulsos
# contratados separadamente). A acao continua por item.
COBRADOS_A_PARTE_SECTIONS = {"SVA Detalhe Total", "Itens Eventuais"}
# secao do JSON -> rotulo no cabecalho do prompt. "SVA Detalhe Total" e "Itens
# Eventuais" sao a MESMA categoria (avulsos cobrados a parte) e saem sob um unico
# cabecalho "Itens Eventuais". "Serviços Bundle Inclusos" vira "Serviços Inclusos
# no Plano": a palavra "bundle" e tecnica/interna e nao deve chegar ao prompt (nem
# ao LLM). As chaves do JSON normalizado NAO mudam nos dois casos.
SECTION_LABELS = {
"SVA Detalhe Total": "Itens Eventuais",
"Serviços Bundle Inclusos": "Benefícios do Plano",
}
# classe interna -> rotulo exibido no cabecalho (ver SEC_META). Mesma logica do
# SECTION_LABELS: so a APRESENTACAO muda, a classe interna ("bundle") segue igual
# em todo o resto do backend (bill_processor, invoice_resolver, tools, etc.).
CLASSE_LABELS = {"bundle": "incluso"}
_RX_OUTROS = re.compile(r"^\s*([^:()]+?)\s*:\s*\((.+)\)\s*$")
def _fmt_money(value: Any) -> str:
if isinstance(value, (int, float)):
return f"{value:.2f}"
return str(value)
def _snake(desc: Any) -> str:
return re.sub(r"\s+", "_", str(desc).strip().lower())
def is_period_range(period: Any) -> bool:
"""True quando ``period`` é uma FAIXA (ciclo da fatura, ex.: "14/10 a 13/11"),
não uma data única de cobrança. Compartilhado com o resolver, que usa o mesmo
critério para decidir se uma entry expõe ``charge_date`` (data única) — assim o
que o resolver enxerga como cobrança datada casa exatamente o ``data=`` do render
lean do classificador."""
text = str(period)
return " a " in text or "~" in text
def _item_acao(item: dict[str, Any], section: str) -> str | None:
"""Verbo de acao por item: estrategico -> 'falar sobre'; senao, o default da secao."""
if item.get("estrategico"):
return "falar sobre"
acao, _ = SEC_META.get(section, (None, None))
return acao
def _render_item(item: dict[str, Any], section: str) -> str:
"""<nome solto> | campo=valor | ... (com tratamento especial p/ Outros Valores)."""
desc = str(item.get("desc", "")).strip()
value = item.get("value")
if section == "Outros Valores":
match = _RX_OUTROS.match(desc)
if match:
parts = [match.group(1).strip().lower()]
if value is not None and value != "Incluído":
parts.append(f"valor={_fmt_money(value)}")
parts.append(f'ref="{match.group(2).strip()}"')
return " | ".join(parts)
parts = [desc]
if value is not None and value != "Incluído":
parts.append(f"valor={_fmt_money(value)}")
period = item.get("period")
if period and not is_period_range(period):
parts.append(f"data={period}")
if item.get("franchise"):
parts.append(f"franquia={item['franchise']}")
if item.get("consumption"):
parts.append(f"consumo={item['consumption']}")
if item.get("installment"):
parts.append(f"parcela={item['installment']}")
return " | ".join(parts)
# Seções omitidas no modo enxuto (intent classifier): o classificador só precisa
# dos NOMES dos itens por linha/seção, não de totais, planos nem juros/multas.
_LEAN_SKIP_SECTIONS = frozenset({"Fatura Resumo", "Planos", "Outros Valores"})
def to_text(data: dict[str, Any], *, lean: bool = False) -> str:
"""Converte o JSON normalizado de uma fatura no formato textual em blocos.
``lean=True`` (formato do intent classifier): mantém a estrutura LINHA/seção,
o NOME dos itens e o ``valor``/``data`` por item — descarta "Fatura Resumo",
"Planos", "Outros Valores" e os demais campos por-item (ação, type, franquia,
consumo). O ``valor``/``data`` distinguem cobranças duplicadas do mesmo nome na
mesma linha (desambiguação de cobrança). O orquestrador usa ``lean=False``
(formato completo, com ações)."""
lines: list[str] = []
msisdn_count = 0
# FATURA (achatada) — omitida no modo enxuto.
resumo = data.get("Fatura Resumo")
if not lean and isinstance(resumo, list):
lines.append("Fatura Resumo")
for item in resumo:
desc = item.get("desc", "?")
if "period" in item:
lines.append(f" {_snake(desc)}={item['period']}")
elif "emissao" in item:
lines.append(f" {_snake(desc)}={item['emissao']}")
elif "value" in item:
lines.append(f" {_snake(desc)}={_fmt_money(item['value'])}")
lines.append("")
# LINHAS (MSISDN)
for key, block in data.items():
if key in ("Fatura Resumo", "vocalized_msisdn"):
continue
if not isinstance(block, dict):
continue
msisdn_count += 1
lines.append(f"LINHA {key}")
# Planos — omitido no modo enxuto.
planos = block.get("Planos")
if not lean and isinstance(planos, dict) and planos:
lines.append("Planos")
for nome, plano in planos.items():
bits = [f"nome={nome}"]
if plano.get("period"):
bits.append(f"período={plano['period']}")
if plano.get("days") is not None:
bits.append(f"dias={plano['days']}")
bits.append(f"valor_final={_fmt_money(plano.get('valor_final'))}")
if plano.get("valor_bruto") not in (None, plano.get("valor_final")):
bits.append(f"valor_bruto={_fmt_money(plano.get('valor_bruto'))}")
if plano.get("total_descontos"):
bits.append(f"total_desc={_fmt_money(plano.get('total_descontos'))}")
if plano.get("is_controle"):
bits.append("controle=sim")
lines.append(" " + " | ".join(bits))
descontos = plano.get("descontos") or []
if descontos:
lines.append(" descontos:")
for desconto in descontos:
dbits = [
f"nome={desconto.get('desc')}",
f"valor={_fmt_money(desconto.get('value'))}",
]
if desconto.get("installment"):
dbits.append(f"parcela={desconto['installment']}")
lines.append(" " + " | ".join(dbits))
# Seções agrupadas pelo RÓTULO do cabeçalho (``SECTION_LABELS``): as que
# compartilham rótulo saem sob um único cabeçalho, na posição da primeira
# delas. Cada item guarda a seção de ORIGEM, que é quem decide a ação/classe
# por item — o rótulo é só apresentação.
groups: dict[str, tuple[str, list[tuple[str, dict[str, Any]]]]] = {}
for section, items in block.items():
if section == "Planos" or not isinstance(items, list):
continue
if lean and section in _LEAN_SKIP_SECTIONS:
continue
label = SECTION_LABELS.get(section, section)
_, entries = groups.setdefault(label, (section, []))
entries.extend((section, item) for item in items)
for label, (first_section, entries) in groups.items():
acao, classe = SEC_META.get(first_section, (None, None))
header = label
# No modo enxuto o cabeçalho é só o nome da seção (sem ação/classe).
if not lean and first_section in COBRADOS_A_PARTE_SECTIONS:
header += " | cobrados a parte"
elif acao and first_section not in PER_ITEM_ACTION_SECTIONS and not lean:
header += f" | ação={acao} | {CLASSE_LABELS.get(classe, classe)}"
lines.append(header)
for section, item in entries:
if lean:
# Nome do item + valor + data da cobrança (sem ação/type/
# franquia/consumo). O valor/data por item permitem o classifier
# DISTINGUIR N cobranças do mesmo nome na mesma linha (mesmo
# desc/msisdn, períodos diferentes) na desambiguação de cobrança
# duplicada — o msisdn vem do cabeçalho ``LINHA``. Itens
# ``Incluído`` (bundle) e períodos em faixa (ciclo da fatura, não
# data de cobrança) seguem só com o nome, como antes.
desc = str(item.get("desc", "")).strip()
if not desc:
continue
parts = [desc]
value = item.get("value")
if value is not None and value != "Incluído":
parts.append(f"valor={_fmt_money(value)}")
period = item.get("period")
if period and not is_period_range(period):
parts.append(f"data={period}")
lines.append(" " + " | ".join(parts))
continue
line = " " + _render_item(item, section)
if section in PER_ITEM_ACTION_SECTIONS:
item_acao = _item_acao(item, section)
if item_acao:
line += f" | ação={item_acao}"
if item.get("estrategico"):
line += " | type=estrategico"
lines.append(line)
lines.append("")
text = "\n".join(lines).rstrip()
if msisdn_count > 1:
text += "\n\nmultiplas_linhas = true"
else:
text += "\n\nmultiplas_linhas = false"
return text + "\n"
# ordem pedida pelo produto: avulso -> estrategico -> bundle
# (difere da ordem informacional bundle/estrategico/avulso usada em backend.py)
_VAS_PRODUCT_ORDER = ("avulso", "estrategico", "bundle")
def get_vas_product_names(data: dict[str, Any]) -> str:
"""Nomes dos VAS (avulso, estrategico, bundle — nessa ordem) da fatura
normalizada, deduplicados e separados por vírgula. Ex.: "Focus Mensal,
Tamboro Mensal". Ignora plano e demais seções não-VAS. Retorna "" se não houver."""
buckets: dict[str, list[str]] = {c: [] for c in _VAS_PRODUCT_ORDER}
seen: set[str] = set()
for key, block in data.items():
if key in ("Fatura Resumo", "vocalized_msisdn") or not isinstance(block, dict):
continue
for section, items in block.items():
if section == "Planos" or not isinstance(items, list):
continue
for item in items:
classe = item.get("classe")
if not classe and item.get("estrategico"):
classe = "estrategico"
if classe not in buckets:
continue
name = str(item.get("desc", "")).strip()
if not name or name in seen:
continue
seen.add(name)
buckets[classe].append(name)
ordered = [n for c in _VAS_PRODUCT_ORDER for n in buckets[c]]
return ", ".join(ordered)
def render_for_prompt(invoice_detail: Any, *, lean: bool = False) -> str | None:
"""Renderiza ``invoice_detail`` (dict ou str JSON) no formato textual ``to_text``
para uso no prompt. Fonte única compartilhada pelo orquestrador (system prompt)
e pelo intent classifier — garante que ambos leiam a MESMA fatura, sem drift.
``lean=True`` produz o formato enxuto do intent classifier (nomes + valor/data
por item, por linha/seção; ver :func:`to_text`); o orquestrador usa ``lean=False``.
Retorna ``None`` quando o formato textual está desligado (``USE_TEXT_FORMAT``
false) ou em qualquer falha de parse/render — cabe ao chamador decidir o
fallback (JSON cru no orquestrador; renderizador compacto no classifier).
Nunca levanta exceção: o prompt não pode quebrar por causa da fatura."""
if not (USE_TEXT_FORMAT and invoice_detail):
return None
try:
parsed = (
json.loads(invoice_detail)
if isinstance(invoice_detail, str)
else invoice_detail
)
if isinstance(parsed, dict) and parsed:
return to_text(parsed, lean=lean)
except Exception: # noqa: BLE001 — nunca quebrar o prompt
logger.warning("invoice_to_text.render_for_prompt falhou", exc_info=True)
return None

View File

@@ -0,0 +1,217 @@
from __future__ import annotations
import re
import unicodedata as ud
from datetime import date
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from typing import Any
_CENT = Decimal('0.01')
def decimal_from_any(value: Any) -> Decimal | None:
if value is None or isinstance(value, bool):
return None
if isinstance(value, Decimal):
return value
if isinstance(value, (int, float)):
return Decimal(str(value))
text = str(value or '').strip().replace('R$', '').replace(' ', '')
if not text:
return None
if ',' in text and '.' in text:
text = text.replace('.', '').replace(',', '.')
elif ',' in text:
text = text.replace(',', '.')
try:
return Decimal(text)
except (InvalidOperation, ValueError):
return None
def money(value: Decimal) -> Decimal:
return value.quantize(_CENT, rounding=ROUND_HALF_UP)
def amount_text(value: Decimal) -> str:
return f'{money(value):.2f}'
def normalize_match_text(value: Any) -> str:
text = re.sub(r'\s*\([^)]*\)', '', str(value or '')).strip()
text = ud.normalize('NFKD', text)
text = ''.join(ch for ch in text if not ud.combining(ch)).casefold()
return re.sub(r'\s+', ' ', re.sub(r'[^a-z0-9]+', ' ', text)).strip()
def same_plan_name(left: Any, right: Any) -> bool:
a, b = normalize_match_text(left), normalize_match_text(right)
return bool(a and b and (a == b or a in b or b in a))
def resolve_plano_controle(planos: list[dict[str, Any]]) -> tuple[dict[str, Any], dict[str, Any]] | None:
controls = [p for p in planos if isinstance(p, dict) and bool(p.get('is_controle'))]
if len(controls) != 1:
return None
control = controls[0]
other = next((p for p in planos if p is not control), None)
return (control, other) if isinstance(other, dict) else None
def resolve_liquid_value(plano: dict[str, Any]) -> Decimal | None:
for key in ('valor_final','valorFinal','valor_liquido','valorLiquido','net_value','netValue','subtotal','value_final'):
val = decimal_from_any(plano.get(key))
if val is not None:
return val
gross = next((decimal_from_any(plano.get(k)) for k in ('valor_bruto','valorBruto','gross_value','grossValue','valor_bruto_plano','valorBrutoPlano','preco_unit') if decimal_from_any(plano.get(k)) is not None), None)
discounts = next((decimal_from_any(plano.get(k)) for k in ('total_descontos','totalDescontos','discount_total') if decimal_from_any(plano.get(k)) is not None), None)
return gross + discounts if gross is not None and discounts is not None else None
def _parse_emission_year(invoice: dict[str, Any], emission: str='') -> int | None:
for text in [emission] + [str(x.get('emissao') or '') for x in invoice.get('Fatura Resumo', []) if isinstance(x, dict)]:
m = re.search(r'\b\d{2}/\d{2}/(?P<y>\d{4})\b', text)
if m:
return int(m.group('y'))
return None
def _period_text(invoice: dict[str, Any], period: str='') -> str:
if period:
return period
for item in invoice.get('Fatura Resumo', []) or []:
if isinstance(item, dict) and normalize_match_text(item.get('desc')) == 'periodo':
return str(item.get('period') or '').strip()
return ''
def _parse_period(text: str, year: int) -> tuple[date,date] | None:
m = re.search(r'(?P<sd>\d{2})/(?P<sm>\d{2})\s+a\s+(?P<ed>\d{2})/(?P<em>\d{2})', text)
if not m:
return None
sd,sm,ed,em = map(int, (m['sd'],m['sm'],m['ed'],m['em']))
sy = year - 1 if sm > em else year
try:
start,end = date(sy,sm,sd), date(year,em,ed)
except ValueError:
return None
return (start,end) if start <= end else None
def resolve_period_days(other: dict[str, Any], invoice: dict[str, Any], *, period: str='', emission: str='') -> tuple[int,int] | None:
year = _parse_emission_year(invoice, emission)
ptxt = _period_text(invoice, period)
if year is None or not ptxt:
return None
pr = _parse_period(ptxt, year)
if pr is None:
return None
cycle = (pr[1]-pr[0]).days + 1
raw = other.get('days') if other.get('days') is not None else other.get('dias')
try:
days_other = int(Decimal(str(raw)).to_integral_value(rounding=ROUND_HALF_UP))
except Exception:
return None
if days_other <= 0 or cycle <= 0:
return None
if days_other >= cycle:
return cycle, 1
return cycle, max(1, cycle-days_other)
def find_danfe_plan_items(danfe: dict[str, Any], control: dict[str, Any]) -> list[dict[str, Any]]:
plans = danfe.get('Planos')
if not isinstance(plans, dict):
return []
desc = control.get('desc')
for name, raw in plans.items():
if same_plan_name(name, desc) and isinstance(raw, list):
return [x for x in raw if isinstance(x, dict)]
return []
def build_contestation_items(danfe: dict[str, Any], control: dict[str, Any], refund: Decimal) -> tuple[list[dict[str, Any]], Decimal]:
remaining = money(refund)
out: list[dict[str, Any]] = []
for item in find_danfe_plan_items(danfe, control):
name = str(item.get('desc') or '').strip()
claimed = decimal_from_any(item.get('valor_final') if item.get('valor_final') is not None else item.get('valorFinal'))
if not name or claimed is None or claimed <= 0:
continue
claimed = money(claimed)
validated = min(remaining, claimed)
if validated <= 0:
continue
out.append({'itemName': name, 'itemType':'PRO_RATA', 'claimedAmount':float(claimed), 'validatedAmount':float(validated)})
remaining = money(remaining-validated)
if remaining <= 0:
break
return out, remaining
def human_validation_text(control: dict[str, Any], liquid: Decimal, cycle: int, control_days: int, used: Decimal, refund: Decimal, items: list[dict[str,Any]]) -> str:
name = re.sub(r'\s*\([^)]*\)', '', str(control.get('desc') or '')).strip() or 'Plano Controle'
parts=[]
for idx,item in enumerate(items,1):
claimed=decimal_from_any(item.get('claimedAmount')) or Decimal('0')
validated=decimal_from_any(item.get('validatedAmount')) or Decimal('0')
parts.append(f"{idx}. {item.get('itemName','')}: valor DANFE R$ {money(claimed):.2f}; valor a abater R$ {money(validated):.2f}")
return (
f'Validacao humana pro-rata:Plano Controle identificado: {name}. '
f'Base liquida do plano: R$ {money(liquid):.2f}. Calculo: ciclo de {cycle} dias, uso considerado de {control_days} dias; '
f'valor usado R$ {money(used):.2f}; valor a devolver R$ {money(refund):.2f}. '
f"Itens selecionados no DANFE, na ordem de abatimento: {'; '.join(parts) if parts else 'nenhum item gerado'}. "
)
def calculate_refund(*, planos: list[dict[str,Any]], invoice_detail: dict[str,Any], invoice_period: str='', invoice_emissao: str='') -> dict[str,Any]:
if len(planos) != 2:
raise ValueError('pro_rata exige exatamente dois planos para calcular devolucao.')
resolved = resolve_plano_controle(planos)
if resolved is None:
raise ValueError('Nao foi possivel identificar exatamente um Plano Controle.')
control, other = resolved
liquid = resolve_liquid_value(control)
if liquid is None or liquid <= 0:
raise ValueError('Valor liquido do Plano Controle ausente ou invalido.')
period_days = resolve_period_days(other, invoice_detail, period=invoice_period, emission=invoice_emissao)
if period_days is None:
raise ValueError('Periodo da fatura ou dos planos ausente ou invalido para calcular pro-rata.')
cycle, control_days = period_days
liquid = money(liquid)
used = (liquid / Decimal(cycle)) * Decimal(control_days)
refund = money(liquid-used)
danfe = invoice_detail.get('DANFE-COM')
if not isinstance(danfe, dict) or not danfe:
raise ValueError('DANFE-COM nao encontrado na fatura recuperada.')
items, remaining = build_contestation_items(danfe, control, refund)
if remaining > 0:
raise ValueError(f'Itens do DANFE insuficientes para cobrir devolucao de R$ {money(remaining):.2f}.')
total = money(sum((decimal_from_any(x.get('validatedAmount')) or Decimal('0') for x in items), Decimal('0')))
return {
'items': items,
'invoice_amount_open': amount_text(total),
'invoice_amount': amount_text(total),
'texto_validacao_humana': human_validation_text(control, liquid, cycle, control_days, used, refund, items),
'valor_liquido': amount_text(liquid), 'dias_ciclo': cycle, 'dias_controle': control_days,
'valor_usado': amount_text(money(used)), 'valor_devolver': amount_text(refund),
}
def payment_message(devolucao: dict[str,Any]) -> str:
items = devolucao.get('items') if isinstance(devolucao.get('items'), list) else []
total=Decimal('0'); plan=''
for item in items:
if not isinstance(item,dict): continue
if not plan: plan=str(item.get('itemName') or item.get('item_name') or '').strip()
total += decimal_from_any(item.get('validatedAmount') if item.get('validatedAmount') is not None else item.get('validated_amount')) or Decimal('0')
amount=f'{money(total):.2f}'.replace('.',',')
ptxt=f' {plan}' if plan else ''
proto=str(devolucao.get('protocolo_id') or '').strip()
proto_txt=f' Seu numero de protocolo e {proto}.' if proto else ''
due=str(devolucao.get('data_credito_proxima_fatura') or '').strip()
due_txt=f' na fatura com vencimento em {due}, considerando o seu ciclo de faturamento' if due else ' em uma proxima fatura'
if str(devolucao.get('format_text') or '').strip()=='sms':
barcode_txt='com o codigo de barras atualizado' if str(devolucao.get('barcode') or '').strip() else 'com as orientacoes para pagamento'
return f'Realizei a contestacao da fatura considerando o valor proporcional do Plano Controle{ptxt}. O valor contestado, de R$ {amount}, foi retirado da sua fatura. Enviamos uma mensagem {barcode_txt}, com prazo de 4 dias para pagamento.{proto_txt}'.strip()
return f'Realizei a contestacao considerando o valor proporcional do Plano Controle{ptxt}. O valor contestado, de R$ {amount}, ficou registrado como credito{due_txt}.{proto_txt}'.strip()

View File

@@ -0,0 +1,169 @@
from __future__ import annotations
import json
import logging
import os
from dataclasses import dataclass
from functools import lru_cache
from typing import Any
logger = logging.getLogger(__name__)
_TRIPLETS_ENV_VAR = "TIM_PROTOCOL_TRIPLETS_JSON"
_STAGES = ("open", "close")
_FIELDS = ("reason1", "reason2", "reason3")
@dataclass(frozen=True, slots=True)
class ProtocolTriplet:
reason1: str
reason2: str
reason3: str
_DEFAULT_CATALOG: dict[str, dict[str, ProtocolTriplet]] = {
"atendimento_geral": {
"open": ProtocolTriplet("Informação", "Conta", "Valor"),
"close": ProtocolTriplet("Informação", "Conta", "Serviço"),
},
"cancelamento_vas_avulso": {
"open": ProtocolTriplet(
"Solicitação", "Serviço VAS", "Ativação/Desativação"
),
"close": ProtocolTriplet(
"Solicitação", "Serviço VAS", "Ativação/Desativação"
),
},
"contestacao": {
"open": ProtocolTriplet("Informação", "Conta", "Serviço"),
"close": ProtocolTriplet("Reclamação", "Conta", "Valor"),
},
"vas_estrategico": {
"open": ProtocolTriplet("Informação", "Conta", "Serviço"),
"close": ProtocolTriplet("Informação", "Conta", "Serviço"),
},
"invoice_explanation_aceite_fechado": {
"open": ProtocolTriplet("Informação", "Conta", "Serviço"),
"close": ProtocolTriplet("Informação", "Conta", "Serviço"),
},
"finalizacao_informacional_fechada": {
"open": ProtocolTriplet("Informação", "Conta", "Serviço"),
"close": ProtocolTriplet("Informação", "Conta", "Esclarecimento"),
},
"valor_divergente": {
"open": ProtocolTriplet("Informação", "Conta", "Valor"),
"close": ProtocolTriplet("Informação", "Conta", "Valor"),
},
"pro_rata": {
"open": ProtocolTriplet("Informação", "Conta", "Valor"),
"close": ProtocolTriplet("Informação", "Conta", "Valor"),
},
"pro_rata_mensalidade": {
"open": ProtocolTriplet("Informação", "Conta", "Mensalidade"),
"close": ProtocolTriplet("Informação", "Conta", "Mensalidade"),
},
"pro_rata_reclamacao": {
"open": ProtocolTriplet("Reclamação", "Conta", "Valor"),
"close": ProtocolTriplet("Reclamação", "Conta", "Valor"),
},
"termino_desconto": {
"open": ProtocolTriplet("Informação", "Conta", "Serviço"),
"close": ProtocolTriplet("Informação", "Conta", "Serviço"),
},
"conta_certa_manual": {
"open": ProtocolTriplet(
"Processo Interno", "Conta", "Conta Certa - Venc {dia_vencimento}"
),
"close": ProtocolTriplet(
"Processo Interno", "Conta", "Conta Certa - Venc {dia_vencimento}"
),
},
}
class _SafeFormatDict(dict[str, Any]):
def __missing__(self, key: str) -> str:
return ""
def _normalize_text(value: Any) -> str:
return str(value or "").strip()
def _override_triplet(base: ProtocolTriplet, override: dict[str, Any]) -> ProtocolTriplet:
return ProtocolTriplet(
reason1=_normalize_text(override.get("reason1")) or base.reason1,
reason2=_normalize_text(override.get("reason2")) or base.reason2,
reason3=_normalize_text(override.get("reason3")) or base.reason3,
)
@lru_cache(maxsize=1)
def _catalog_with_overrides() -> dict[str, dict[str, ProtocolTriplet]]:
catalog = {
scenario: dict(stages)
for scenario, stages in _DEFAULT_CATALOG.items()
}
raw = os.getenv(_TRIPLETS_ENV_VAR, "").strip()
if not raw:
return catalog
try:
payload = json.loads(raw)
except json.JSONDecodeError:
logger.warning("triplets.override.invalid_json env=%s", _TRIPLETS_ENV_VAR)
return catalog
if not isinstance(payload, dict):
logger.warning("triplets.override.invalid_root env=%s", _TRIPLETS_ENV_VAR)
return catalog
for scenario, stage_payload in payload.items():
scenario_key = _normalize_text(scenario).lower()
if not scenario_key or not isinstance(stage_payload, dict):
continue
current = catalog.get(scenario_key, {})
for stage in _STAGES:
override = stage_payload.get(stage)
if not isinstance(override, dict):
continue
if stage in current:
current[stage] = _override_triplet(current[stage], override)
continue
if any(_normalize_text(override.get(field)) for field in _FIELDS):
current[stage] = ProtocolTriplet(
reason1=_normalize_text(override.get("reason1")),
reason2=_normalize_text(override.get("reason2")),
reason3=_normalize_text(override.get("reason3")),
)
if current:
catalog[scenario_key] = current
return catalog
def resolve_protocol_triplet(
scenario: str,
*,
stage: str,
context: dict[str, Any] | None = None,
fallback_scenario: str = "atendimento_geral",
) -> ProtocolTriplet:
stage_key = _normalize_text(stage).lower()
if stage_key not in _STAGES:
stage_key = "open"
scenario_key = _normalize_text(scenario).lower() or fallback_scenario
fallback_key = _normalize_text(fallback_scenario).lower() or "atendimento_geral"
catalog = _catalog_with_overrides()
stages = catalog.get(scenario_key) or catalog.get(fallback_key) or {}
raw_triplet = stages.get(stage_key)
if raw_triplet is None:
fallback_stages = catalog.get("atendimento_geral", {})
raw_triplet = fallback_stages.get(stage_key) or ProtocolTriplet("", "", "")
fmt_values = _SafeFormatDict(
{key: _normalize_text(value) for key, value in (context or {}).items()}
)
return ProtocolTriplet(
reason1=raw_triplet.reason1.format_map(fmt_values).strip(),
reason2=raw_triplet.reason2.format_map(fmt_values).strip(),
reason3=raw_triplet.reason3.format_map(fmt_values).strip(),
)

View File

@@ -0,0 +1,81 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from .ic_tags import RCTTag
class RCTOperation(StrEnum):
CANCELA_VAS = "cancela_vas"
SGR_CODBAR = "sgr_codbar"
CONTESTACAO = "contestacao"
DEF_VALOR_AJUSTE = "def_valor_ajuste"
REG_ATEND_VAS_AVULSO = "reg_atend_vas_avulso"
REG_ATEND_VAS_ESTRAT = "reg_atend_vas_estrat"
PDF_FATURA = "pdf_fatura"
BASE_CONHECIMENTO = "base_conhecimento"
REG_CHAMADO_BO_RT15 = "reg_chamado_bo_rt15"
@dataclass(frozen=True, slots=True)
class RCTPolicy:
success: tuple[str, ...] = ()
failure: tuple[str, ...] = ()
def _codes(*values) -> tuple[str, ...]:
return tuple(str(getattr(v, "value", v)) for v in values)
RCT_POLICIES: dict[RCTOperation, RCTPolicy] = {
RCTOperation.CANCELA_VAS: RCTPolicy(
success=_codes(RCTTag.CANCELA_VAS_OK, RCTTag.CANCELA_VAS_2X_OK, RCTTag.CANCELA_VAS_3X_OK),
failure=_codes(RCTTag.CANCELA_VAS_FAIL, RCTTag.CANCELA_VAS_2X_FAIL, RCTTag.CANCELA_VAS_3X_FAIL),
),
RCTOperation.SGR_CODBAR: RCTPolicy(
success=_codes(RCTTag.SGR_CODBAR_OK, RCTTag.SGR_CODBAR_2X_OK, RCTTag.SGR_CODBAR_3X_OK),
failure=_codes(RCTTag.SGR_CODBAR_FAIL, RCTTag.SGR_CODBAR_2X_FAIL, RCTTag.SGR_CODBAR_3X_FAIL),
),
RCTOperation.CONTESTACAO: RCTPolicy(
success=_codes(RCTTag.CONTESTACAO_OK, RCTTag.CONTESTACAO_2X_OK, RCTTag.CONTESTACAO_3X_OK),
failure=_codes(RCTTag.CONTESTACAO_FAIL, RCTTag.CONTESTACAO_2X_FAIL, RCTTag.CONTESTACAO_3X_FAIL),
),
RCTOperation.DEF_VALOR_AJUSTE: RCTPolicy(
success=_codes(RCTTag.DEF_VALOR_AJUSTE_OK, RCTTag.DEF_VALOR_AJUSTE_2X_OK, RCTTag.DEF_VALOR_AJUSTE_3X_OK),
failure=_codes(RCTTag.DEF_VALOR_AJUSTE_FAIL, RCTTag.DEF_VALOR_AJUSTE_2X_FAIL, RCTTag.DEF_VALOR_AJUSTE_3X_FAIL),
),
RCTOperation.REG_ATEND_VAS_AVULSO: RCTPolicy(
success=_codes(RCTTag.REG_ATEND_VAS_AVULSO_OK, RCTTag.REG_ATEND_VAS_AVULSO_2X_OK, RCTTag.REG_ATEND_VAS_AVULSO_3X_OK),
failure=_codes(RCTTag.REG_ATEND_VAS_AVULSO_FAIL, RCTTag.REG_ATEND_VAS_AVULSO_2X_FAIL, RCTTag.REG_ATEND_VAS_AVULSO_3X_FAIL),
),
RCTOperation.REG_ATEND_VAS_ESTRAT: RCTPolicy(
success=_codes(RCTTag.REG_ATEND_VAS_ESTRAT_OK, RCTTag.REG_ATEND_VAS_ESTRAT_2X_OK, RCTTag.REG_ATEND_VAS_ESTRAT_3X_OK),
failure=_codes(RCTTag.REG_ATEND_VAS_ESTRAT_FAIL, RCTTag.REG_ATEND_VAS_ESTRAT_2X_FAIL, RCTTag.REG_ATEND_VAS_ESTRAT_3X_FAIL),
),
RCTOperation.PDF_FATURA: RCTPolicy(
success=_codes(RCTTag.PDF_FATURA_OK, RCTTag.PDF_FATURA_2X_OK, RCTTag.PDF_FATURA_3X_OK),
failure=_codes(RCTTag.PDF_FATURA_FAIL, RCTTag.PDF_FATURA_2X_FAIL, RCTTag.PDF_FATURA_3X_FAIL),
),
RCTOperation.BASE_CONHECIMENTO: RCTPolicy(
success=_codes(RCTTag.BASE_CONHECIMENTO_OK, RCTTag.BASE_CONHECIMENTO_2X_OK, RCTTag.BASE_CONHECIMENTO_3X_OK),
failure=_codes(RCTTag.BASE_CONHECIMENTO_FAIL, RCTTag.BASE_CONHECIMENTO_2X_FAIL, RCTTag.BASE_CONHECIMENTO_3X_FAIL),
),
RCTOperation.REG_CHAMADO_BO_RT15: RCTPolicy(
success=_codes(RCTTag.REG_CHAMADO_BO_RT15_OK, RCTTag.REG_CHAMADO_BO_RT15_2X_OK, RCTTag.REG_CHAMADO_BO_RT15_3X_OK),
failure=_codes(RCTTag.REG_CHAMADO_BO_RT15_FAIL, RCTTag.REG_CHAMADO_BO_RT15_2X_FAIL, RCTTag.REG_CHAMADO_BO_RT15_3X_FAIL),
),
}
def rct_tags_for_attempt(operation: RCTOperation | str | None, attempt: int, *, success: bool) -> tuple[str, ...]:
if operation is None or attempt < 1:
return ()
try:
op = operation if isinstance(operation, RCTOperation) else RCTOperation(str(operation))
except ValueError:
return ()
policy = RCT_POLICIES.get(op, RCTPolicy())
group = policy.success if success else policy.failure
idx = attempt - 1
return (group[idx],) if idx < len(group) and group[idx] else ()

View File

@@ -0,0 +1,264 @@
from __future__ import annotations
from typing import Any
from .client import TimApiClient
class ContasDomainService:
"""Business services for Contas.
Important: this is *not* an agent runtime or workflow engine. Conversation,
confirmation, clarification, checkpoints and branching remain in LangGraph/
agent_framework_oci. These methods implement only TIM business operations.
"""
def __init__(self, client: TimApiClient | None = None) -> None:
self.client = client or TimApiClient()
@staticmethod
def _products(payload: Any) -> list[dict[str, Any]]:
if not isinstance(payload, dict):
return []
value = payload.get("products") or payload.get("services") or []
return [x for x in value if isinstance(x, dict)]
@staticmethod
def _match_service(products: list[dict[str, Any]], subject: str) -> dict[str, Any] | None:
needle = (subject or "").strip().lower()
if not needle:
return None
exact = [p for p in products if str(p.get("name") or p.get("description") or "").strip().lower() == needle]
if exact:
return exact[0]
partial = [p for p in products if needle in str(p.get("name") or p.get("description") or "").lower() or str(p.get("name") or p.get("description") or "").lower() in needle]
return partial[0] if partial else None
def consultar_faturas(self, *, msisdn: str, **_: Any) -> Any:
return self.client.consultar_faturas(msisdn)
def invoice_explanation(self, *, msisdn: str, **args: Any) -> dict[str, Any]:
# Reuse framework-prefetched evidence when available; the domain never owns
# a second cache/session implementation. Explanation itself is produced by
# the framework LLM.
complete = args.get("complete_invoices_payload")
if not isinstance(complete, dict):
complete = self.client.consultar_faturas(msisdn)
billing = args.get("billing_analysis")
if not isinstance(billing, dict):
billing = self.client.billing_analysis(msisdn, invoice_id=args.get("invoice_id"), customer_id=args.get("customer_id"))
return {
"complete_invoices": complete,
"billing_analysis": billing,
"instruction": "Use estes dados como evidência para explicar composição/variação da fatura. Não invente cobranças ausentes.",
}
def consultar_vas(self, *, msisdn: str, **_: Any) -> Any:
return self.client.consultar_vas(msisdn)
def consultar_historico_vas(self, *, msisdn: str, **_: Any) -> Any:
return self.client.historico_vas(msisdn)
@staticmethod
def _history_products(payload: Any) -> list[dict[str, Any]]:
if not isinstance(payload, dict):
return []
services = payload.get("services") or payload.get("products") or []
out: list[dict[str, Any]] = []
for item in services if isinstance(services, list) else []:
if not isinstance(item, dict):
continue
row = dict(item)
row.setdefault("name", row.get("description") or row.get("billDescription") or "")
row.setdefault("cspId", row.get("csp_id"))
row.setdefault("appId", row.get("app_id"))
if "can" not in row:
row["can"] = {"cancel": bool(row.get("canCancel", False))}
out.append(row)
return out
@staticmethod
def _service_can_cancel(service: dict[str, Any]) -> bool:
can = service.get("can") if isinstance(service.get("can"), dict) else {}
details = service.get("details") if isinstance(service.get("details"), dict) else {}
details_can = details.get("can") if isinstance(details.get("can"), dict) else {}
values = [
can.get("cancel"),
details_can.get("cancel"),
service.get("canCancel"),
service.get("can_cancel"),
]
explicit = [value for value in values if value is not None]
return bool(explicit[0]) if explicit else True
def cancelar_vas_avulso(self, *, msisdn: str, subject: str, protocol: str = "", **_: Any) -> dict[str, Any]:
current = self.client.consultar_vas(msisdn)
active_products = self._products(current)
service = self._match_service(active_products, subject)
source = "active"
history = None
if not service:
history = self.client.historico_vas(msisdn)
history_products = self._history_products(history)
service = self._match_service(history_products, subject)
source = "history" if service else "none"
if not service:
return {
"success": False,
"error": f"Serviço '{subject}' não encontrado na consulta VAS nem no histórico",
"reason": "service_not_found",
"services": active_products,
"history": history,
"evidence_source": source,
}
if not self._service_can_cancel(service):
return {
"success": False,
"error": "Serviço já se encontra cancelado, não sendo possível realizar recancelamento.",
"reason": "already_inactive_or_blocked",
"service": service,
"evidence_source": source,
}
try:
block = self.client.bloquear_vas(msisdn, service)
except Exception as exc:
return {
"success": False,
"service": service,
"error": str(exc),
"reason": "block_vas_failed",
"evidence_source": source,
"eligible_for_contestation": True,
}
try:
cancel = self.client.cancelar_vas(msisdn, service, protocol=protocol)
except Exception as exc:
return {
"success": False,
"service": service,
"block": block,
"error": str(exc),
"reason": "cancel_vas_failed",
"evidence_source": source,
"eligible_for_contestation": True,
}
return {
"success": True,
"service": service,
"block": block,
"cancellation": cancel,
"evidence_source": source,
"eligible_for_contestation": True,
}
def tratar_vas_estrategico(self, *, msisdn: str, subject: str, accepted_explanation: bool | None = None, **_: Any) -> dict[str, Any]:
current = self.client.consultar_vas(msisdn)
service = self._match_service(self._products(current), subject)
return {
"success": bool(service),
"service": service,
"accepted_explanation": accepted_explanation,
"guidance": "VAS estratégico/bundle usa orientação conversacional do agente; não executar cancelamento automático sem tool transacional específica e confirmação do framework.",
}
def contestar_cobranca(self, *, msisdn: str, subject: str, valor: Any, motivo: str = "", **args: Any) -> dict[str, Any]:
# Domain orchestration only: all conversational confirmation has already happened in framework runtime.
invoices = self.client.consultar_faturas(msisdn)
contract = self.client.contrato(msisdn)
profile = self.client.profile_full(msisdn)
protocol_payload = {
"msisdn": msisdn,
"source": os_source(args),
"reason1": "CONTESTACAO",
"reason2": subject,
"reason3": motivo,
"status": "OPENED",
"requestStatus": "Aberto",
}
protocol = self.client.abrir_protocolo(protocol_payload)
protocol_number = str(protocol.get("interactionProtocol") or protocol.get("protocolNumber") or args.get("protocol") or "") if isinstance(protocol, dict) else ""
customer = (((invoices or {}).get("billingProfile") or {}).get("customer") or {}) if isinstance(invoices, dict) else {}
contest_payload = {
"msisdn": msisdn,
"sr": protocol_number,
"socialSecNo": customer.get("document") or args.get("social_sec_no") or "",
"customerId": customer.get("customerId") or customer.get("id") or args.get("customer_id") or "",
"invoiceNumber": args.get("invoice_id") or "",
"userId": "AIAGENTCR",
"items": [{"itemName": subject, "itemType": "VAS_AVULSO", "claimedAmount": str(valor), "validatedAmount": str(valor)}],
"description": motivo,
}
contestation = self.client.contestar(contest_payload)
tracking = self.client.tracking({"msisdn": msisdn, "protocolNumber": protocol_number, "activityType": "Contestação", "activityStatus": "Aberto"})
return {"success": True, "protocol": protocol, "contestation": contestation, "tracking": tracking, "invoices": invoices, "contract": contract, "profile": profile}
def consultar_status_solicitacao(self, *, msisdn: str = "", protocol: str = "", **_: Any) -> Any:
return self.client.status_sr({"msisdn": msisdn, "protocolNumber": protocol, "status": "CONSULTA", "channel": "AIAGENTCR"})
def enviar_sms(self, *, msisdn: str, message: str, **_: Any) -> Any:
return self.client.sms(msisdn, message)
def buscar_fatura_detalhada(
self,
*,
msisdn: str,
invoice_id: str,
customer_id: str = "",
include_danfe: bool = False,
output: str = "",
**_: Any,
) -> Any:
return self.client.bill_pdf(
msisdn, invoice_id, customer_id, include_danfe=include_danfe, output=output
)
def perfil_fatura(self, *, msisdn: str, **_: Any) -> Any:
return self.client.profile_bill(msisdn)
def info_linha(self, *, msisdn: str, **_: Any) -> Any:
return self.client.line_info(msisdn)
def recuperar_fatura_pdf(self, *, msisdn: str, invoice_id: str, customer_id: str = "", **_: Any) -> Any:
return self.client.secure_pdf(msisdn, invoice_id, customer_id)
@staticmethod
def _normalize_final_status(status: str) -> str:
valid = {
"resolvido", "nao_resolvido", "resolvido_outros_assuntos",
"outros_assuntos", "erro_falha_sistema", "erro_no_match", "erro_no_input",
}
aliases = {
"0": "resolvido", "1": "nao_resolvido",
"2": "resolvido_outros_assuntos", "3": "outros_assuntos",
"final": "resolvido",
}
value = str(status or "").strip().lower()
value = aliases.get(value, value)
return value if value in valid else "erro_falha_sistema"
@staticmethod
def _normalize_final_summary(summary: str) -> str:
value = str(summary or "").strip()
return "Encerramento realizado pelo agente." + (f" {value}" if value else "")
def finalizar_atendimento(self, *, status: str = "resolvido", summary: str = "", **args: Any) -> dict[str, Any]:
normalized_status = self._normalize_final_status(status)
normalized_summary = self._normalize_final_summary(summary)
protocol = args.get("protocol") or args.get("ura_call_id") or ""
response: dict[str, Any] = {
"success": True,
"status": normalized_status,
"summary": normalized_summary,
}
if protocol:
response["service_request_status"] = self.client.status_sr({
"protocolNumber": protocol,
"status": "Fechado",
"channel": "AIAGENTCR",
"notes": normalized_summary,
})
return response
def os_source(args: dict[str, Any]) -> str:
return str(args.get("channel") or "AIAGENTCR")

View File

@@ -0,0 +1,118 @@
"""Pure-Python string metrics used when the optional ``jellyfish`` wheel is absent.
The production project may still use jellyfish as an accelerator. These
implementations keep the business matcher deterministic and testable in
restricted/offline environments (including CPython 3.13 builders without
access to PyPI).
"""
from __future__ import annotations
import re
import unicodedata
def _norm(value: str) -> str:
value = unicodedata.normalize("NFKD", str(value).lower())
value = "".join(ch for ch in value if not unicodedata.combining(ch))
return re.sub(r"[^a-z0-9]+", "", value)
def levenshtein_distance(a: str, b: str) -> int:
a, b = str(a), str(b)
if a == b:
return 0
if not a:
return len(b)
if not b:
return len(a)
if len(a) > len(b):
a, b = b, a
previous = list(range(len(a) + 1))
for i, cb in enumerate(b, 1):
current = [i]
for j, ca in enumerate(a, 1):
current.append(min(
current[-1] + 1,
previous[j] + 1,
previous[j - 1] + (ca != cb),
))
previous = current
return previous[-1]
def jaro_similarity(a: str, b: str) -> float:
a, b = str(a), str(b)
if a == b:
return 1.0
if not a or not b:
return 0.0
match_distance = max(len(a), len(b)) // 2 - 1
match_distance = max(match_distance, 0)
a_match = [False] * len(a)
b_match = [False] * len(b)
matches = 0
for i, ca in enumerate(a):
lo = max(0, i - match_distance)
hi = min(i + match_distance + 1, len(b))
for j in range(lo, hi):
if b_match[j] or ca != b[j]:
continue
a_match[i] = True
b_match[j] = True
matches += 1
break
if matches == 0:
return 0.0
a_chars = [a[i] for i in range(len(a)) if a_match[i]]
b_chars = [b[j] for j in range(len(b)) if b_match[j]]
transpositions = sum(x != y for x, y in zip(a_chars, b_chars)) / 2.0
m = float(matches)
return (m / len(a) + m / len(b) + (m - transpositions) / m) / 3.0
def jaro_winkler_similarity(a: str, b: str, *, scaling: float = 0.1) -> float:
jaro = jaro_similarity(a, b)
prefix = 0
for ca, cb in zip(str(a), str(b)):
if ca != cb or prefix == 4:
break
prefix += 1
return jaro + prefix * scaling * (1.0 - jaro)
def metaphone(value: str) -> str:
"""Small deterministic phonetic key tuned for Portuguese ASR item names.
It intentionally mirrors the role (not the implementation) of Jellyfish's
Metaphone: collapse spelling variants so the matcher can rank transcript
errors. The key is conservative and is only one signal alongside
Jaro-Winkler/token similarity.
"""
s = _norm(value)
if not s:
return ""
replacements = (
("sch", "x"), ("sh", "x"), ("ch", "x"), ("ph", "f"),
("th", "t"), ("nh", "n"), ("lh", "l"), ("qu", "k"),
("gu", "g"), ("ck", "k"),
)
for old, new in replacements:
s = s.replace(old, new)
trans = str.maketrans({
"c": "k", "q": "k", "k": "k",
"g": "j", "j": "j",
"v": "f", "f": "f",
"z": "s", "s": "s", "x": "x",
"d": "t", "t": "t",
"b": "p", "p": "p",
"y": "i", "w": "u",
})
s = s.translate(trans)
first = s[0]
tail = "".join(ch for ch in s[1:] if ch not in "aeiou")
code = first + tail
code = re.sub(r"(.)\1+", r"\1", code)
return code.upper()
__all__ = ["jaro_winkler_similarity", "levenshtein_distance", "metaphone"]

View File

@@ -0,0 +1,371 @@
"""Composição determinística da resposta ao cliente após cancelamento de VAS avulso.
FONTE ÚNICA das regras da fala ao cliente neste fluxo. Antes, o texto era gerado por
uma chamada LLM (capability ``fluxo_vas_cancelamento_resposta_cliente``, hoje
REMOVIDA) que apenas renderizava prosa a partir do payload. Como cada ramo é função
pura de campos já conhecidos (``sms_sent``, ``contested_items``,
``contestation_error_description`` etc.), a composição vive aqui: mesma saída
canônica, sem prompt, sem LLM, sem não-determinismo. O runtime entrega o texto
verbatim ao cliente.
Regras (mantenha esta seção como o spec ao alterar comportamento):
- Ordem de montagem: (a) resultado [regras 1.1, 25] → (b) SMS [regra 6] →
(c) protocolo [regra 7].
- Regra 1.1: itens já contestados + itens contestados nesta solicitação (partes
independentes, concordância por lista); destino do valor por ``sms_sent``.
- Regra 2 (``sms_sent=false``): crédito na próxima fatura.
- Regra 3 (``sms_sent=true``): a confirmação canônica de cancelamento, retirada
do valor e envio do novo código por SMS prevalece mesmo sem itens novos.
- Regra 5: itens que não puderam ser contestados.
- Regra 6: SMS 6.1 (ok) / 6.2 (falha) mutuamente exclusivas por ``sms_not_send_error``.
- Regra 7: protocolo(s) em forma canônica, sempre ao final.
- Regra 8: erro terminal → copia ``contestation_error_description`` literal + protocolo;
para item já contestado, informa antes os cancelamentos efetivados.
- No-match de cancelamento (``nao_encontrados`` não vazio): serviço não localizado
na plataforma por divergência de nome → cancelamento não executado, mas o valor
foi contestado. Estrutura própria (``_compose_no_match``): (a) valor total
creditado/retirado [+ SMS] → (b) cancelamentos efetivados (``cancelados``, caso
misto) → (c) no-match + orientação App Meu TIM (opção Gerenciar Benefícios) → protocolo.
- Residual: sucesso sem itens detalhados → confirmação genérica neutra + protocolo.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Any
def _clean(value: Any) -> str:
return str(value or "").strip()
def _item_name(item: Any) -> str:
if isinstance(item, Mapping):
return _clean(
item.get("itemName")
or item.get("item_name")
or item.get("name")
or item.get("servico")
or item.get("service")
)
return _clean(item)
def _names(items: Any) -> list[str]:
if not isinstance(items, Sequence) or isinstance(items, (str, bytes)):
return []
return [name for name in (_item_name(item) for item in items) if name]
def _join_e(names: Sequence[str]) -> str:
"""Junção natural pt-BR: "A"; "A e B"; "A, B e C"."""
names = list(names)
if not names:
return ""
if len(names) == 1:
return names[0]
return f"{', '.join(names[:-1])} e {names[-1]}"
def _is_already_disputed_error(description: str) -> bool:
normalized = description.casefold()
return any(
marker in normalized
for marker in (
"já contestado",
"já foi contestado",
"já foram contestados",
)
)
def _cancellation_success_sentence(canceled: Sequence[str]) -> str:
if len(canceled) == 1:
return f"O cancelamento do item {canceled[0]} foi concluído com sucesso."
return (
f"O cancelamento dos itens {_join_e(canceled)} foi concluído com sucesso."
)
def _protocol_sentence(payload: Mapping[str, Any]) -> str:
"""Regra 7 — protocolo em forma canônica (ex.: ``PRT475D8F1C1F``)."""
protocols = [_clean(p) for p in (payload.get("contestacao_protocols") or [])]
protocols = [p for p in protocols if p]
single = _clean(payload.get("contestacao_protocol"))
if not protocols and single:
protocols = [single]
if not protocols:
return ""
if len(protocols) == 1:
return f"Seu número de protocolo é {protocols[0]}."
return f"Seus números de protocolo são {_join_e(protocols)}."
def _sms_sentence(payload: Mapping[str, Any]) -> str:
"""Regra 6 — mensagens 6.1/6.2 mutuamente exclusivas por ``sms_not_send_error``."""
if bool(payload.get("sms_not_send_error")):
return (
"Identificamos uma instabilidade no envio do SMS com o novo código "
"para pagamento. Você pode consultar o código atualizado e o prazo "
"de pagamento diretamente no app Meu TIM."
)
return (
"Enviamos um SMS com o novo código de barras e o valor atualizado. "
"O prazo para pagamento é de 4 dias."
)
def _compose_no_match(
payload: Mapping[str, Any],
*,
amount: str,
sms_sent: bool,
contested: list[str],
not_contested: list[str],
canceled: list[str],
no_match: list[str],
protocol: str,
) -> str:
"""No-match de cancelamento: serviço(s) não localizado(s) na plataforma por
divergência de nome → o cancelamento não foi executado pelo canal, mas o valor
foi contestado (ajuste em fatura). Estrutura: (a) valor [+ SMS] → (b) lista de
cancelamentos com sucesso (se houver) → (c) no-match + orientação App Meu TIM →
(d) protocolo. A frase de encerramento é anexada pelo runtime, não aqui.
"""
parts: list[str] = []
# (a) Destino do valor contestado — só quando houve contestação de fato.
if contested or amount not in ("", "0,00"):
if sms_sent:
parts.append(f"O valor total de R$ {amount} foi retirado da sua fatura.")
parts.append(_sms_sentence(payload))
else:
parts.append(
f"O valor total de R$ {amount} ficou registrado como crédito para "
"sua próxima fatura."
)
# (b) Cancelamentos efetivados (caso misto).
if canceled:
if len(canceled) == 1:
parts.append(
f"O cancelamento do serviço {canceled[0]} foi realizado com sucesso."
)
else:
parts.append(
f"O cancelamento dos serviços {_join_e(canceled)} foram realizados "
"com sucesso."
)
# (c) No-match + orientação para o App Meu TIM. A forma da frase difere entre
# o caso misto (há lista de sucesso antes) e o caso tudo-no-match.
if canceled:
if len(no_match) == 1:
parts.append(
f"Não consegui realizar por aqui o cancelamento do serviço "
f"{no_match[0]}. Você pode cancelá-lo pelo App Meu TIM, na opção "
"Gerenciar Benefícios."
)
else:
parts.append(
f"Não consegui realizar por aqui o cancelamento dos serviços "
f"{_join_e(no_match)}. Você pode cancelá-los pelo App Meu TIM, na "
"opção Gerenciar Benefícios."
)
else:
if len(no_match) == 1:
parts.append(
f"Sobre o cancelamento do serviço {no_match[0]}, não consegui "
"realizar por aqui. Você pode cancelar pelo App Meu TIM, na opção "
"Gerenciar Benefícios."
)
else:
parts.append(
f"Sobre o cancelamento dos serviços {_join_e(no_match)}, não "
"consegui realizar por aqui. Você pode cancelar pelo App Meu TIM, "
"na opção Gerenciar Benefícios."
)
# Regra 5 — itens que não puderam ser contestados (raro combinado com no-match).
if not_contested:
parts.append(
f"Não foi possível contestar o item {', '.join(not_contested)}."
)
if protocol:
parts.append(protocol)
return " ".join(part.strip() for part in parts if part.strip()).strip()
def compose_vas_cancellation_message(payload: Mapping[str, Any]) -> str:
"""Monta a resposta ao cliente a partir do payload estruturado.
Retorna ``""`` apenas quando não há sucesso nem nenhuma regra aplicável
(ex.: erro sem descrição). Nesse caso o backend aplica sua mensagem de erro
genérica — não há chamada LLM em nenhum caminho.
"""
if not isinstance(payload, Mapping):
return ""
error_desc = _clean(payload.get("contestation_error_description"))
protocol = _protocol_sentence(payload)
canceled = _names(payload.get("cancelados"))
# Regra 8 — contestação recusada pela API: preserva os cancelamentos que já
# ocorreram, mas deixa claro que uma nova contestação não pôde ser registrada.
if error_desc:
parts: list[str] = []
if canceled and _is_already_disputed_error(error_desc):
parts.append(_cancellation_success_sentence(canceled))
if not error_desc.endswith((".", "!", "?")) and protocol:
error_desc = f"{error_desc}."
parts.append(error_desc)
if protocol:
parts.append(protocol)
return " ".join(parts)
amount = _clean(payload.get("contested_invoice_amount_open"))
sms_sent = bool(payload.get("sms_sent"))
contested = _names(payload.get("contested_items"))
not_contested = _names(payload.get("not_contested_items"))
already = [_clean(x) for x in (payload.get("itens_ja_contestados") or [])]
already = [x for x in already if x]
no_match = _names(payload.get("nao_encontrados"))
# No-match de cancelamento (nome divergente na plataforma): o cancelamento não
# foi executado, mas o valor foi contestado. Assume a estrutura própria
# (lidera pelo valor + orienta App Meu TIM), distinta das regras 1.1/25.
if no_match:
return _compose_no_match(
payload,
amount=amount,
sms_sent=sms_sent,
contested=contested,
not_contested=not_contested,
canceled=canceled,
no_match=no_match,
protocol=protocol,
)
# Regra 3 — o envio do novo boleto por SMS é o sinal definitivo sobre o
# destino do ajuste. Ele prevalece sobre ``contested_items`` vazio (por
# exemplo, quando a API não devolve os itens detalhados) e nunca pode cair
# no texto de crédito para a próxima fatura.
if sms_sent and not bool(payload.get("sms_not_send_error")):
parts = [
"O cancelamento foi concluído com sucesso. O valor contestado foi "
"retirado da sua fatura.",
_sms_sentence(payload),
]
if not_contested:
parts.append(
f"Não foi possível contestar o item {', '.join(not_contested)}."
)
if protocol:
parts.append(protocol)
return " ".join(part.strip() for part in parts if part.strip()).strip()
# Sem nenhum item detalhado (contestado, não contestado ou já contestado):
# não há como aplicar as regras 1.1/25. Se o cancelamento teve sucesso,
# devolve uma confirmação genérica neutra (não promete crédito nem boleto,
# que dependem dos itens); o protocolo entra pela regra 7 (ou pelo append do
# backend, quando só há protocolo de cancelamento). Sem sucesso, devolve ""
# para o backend aplicar sua mensagem de erro genérica.
if not (contested or not_contested or already):
if not bool(payload.get("success")):
return ""
generico = "O cancelamento foi concluído com sucesso."
return f"{generico} {protocol}".strip() if protocol else generico
def _ja_contestados_notice() -> str:
# Parte 1 da regra 1.1 (concordância pela própria lista).
if len(already) == 1:
return (
f"Identifiquei que o item {already[0]} já havia sido contestado "
"anteriormente e, por esse motivo, não é possível registrar uma "
"nova contestação para ele."
)
return (
f"Identifiquei que os itens {_join_e(already)} já haviam sido "
"contestados anteriormente e, por esse motivo, não é possível "
"registrar uma nova contestação para eles."
)
parts: list[str] = []
emit_sms = False
if already and contested:
# Regra 1.1 — misto: parte por lista própria (concordância independente).
parts.append(_ja_contestados_notice())
if len(contested) == 1:
parts.append(
f"Já a contestação do item {contested[0]} no valor de R$ {amount} "
"foi concluída com sucesso."
)
else:
parts.append(
f"Já a contestação dos itens {_join_e(contested)} no valor total "
f"de R$ {amount} foi concluída com sucesso."
)
if sms_sent:
parts.append("O valor contestado já foi retirado da sua fatura.")
emit_sms = True
else:
parts.append(
"O valor contestado ficou registrado como crédito para sua "
"próxima fatura."
)
# Regras 2 e 3 NÃO se aplicam neste caso.
elif already and not contested:
# Gap fora das regras 1.1/2/3/4 (só itens já contestados, sem SMS): nada de
# novo foi contestado, então só o aviso "já contestado" — não afirma
# cancelamento/crédito que não ocorreu.
parts.append(_ja_contestados_notice())
elif sms_sent:
# Regra 3 — valor retirado da fatura (novo boleto por SMS).
if len(contested) == 1:
parts.append(
f"O cancelamento do item {contested[0]} no valor de R$ {amount} "
"foi concluído com sucesso. O valor contestado já foi retirado da "
"sua fatura."
)
else:
parts.append(
"O cancelamento dos itens foi concluído com sucesso. O valor total "
f"de R$ {amount} foi retirado da sua fatura."
)
emit_sms = True
else:
# Regra 2 — crédito na próxima fatura (sms_sent=false).
if len(contested) == 1:
parts.append(
f"O cancelamento do item {contested[0]} no valor de R$ {amount} "
"foi concluído com sucesso. O valor contestado ficou registrado "
"como crédito para sua próxima fatura."
)
else:
parts.append(
"O cancelamento dos itens foi feito com sucesso. O crédito no valor "
f"total de R$ {amount} ficou registrado como crédito para sua "
"próxima fatura."
)
if not parts:
return ""
# Regra 5 — itens que não puderam ser contestados (parte (a) do resultado).
if not_contested:
parts.append(
f"Não foi possível contestar o item {', '.join(not_contested)}."
)
# (b) Notificação de SMS.
if emit_sms:
parts.append(_sms_sentence(payload))
# (c) Protocolo, sempre.
if protocol:
parts.append(protocol)
return " ".join(part.strip() for part in parts if part.strip()).strip()

View File

@@ -0,0 +1,918 @@
"""vas_variation: quais VAS variaram entre a fatura passada e a atual.
Responde, deterministicamente: *que cobranças de VAS entraram (ou subiram de valor)
na fatura atual em relação à passada?* Dois consumidores dependem dessa resposta, e é
de propósito que ela viva num lugar só — duas implementações dariam respostas
diferentes para a mesma fatura. Eles pedem RECORTES DE CLASSE diferentes, porque a
pergunta de negócio de cada um é diferente:
- **Retenção de VAS no pedido de humano** (SPEC §9) → :func:`varied_avulso_items`: só
**avulso**, porque o agente promete *cancelar* o que ofereceu, e só avulso é
cancelável (SPEC §11). Estratégico/bundle nunca disparam retenção.
- **Ramo NÃO do ``invoice_explanation``** (SPEC §9 "Explicação da variação recusada")
→ :func:`varied_vas_charges`: **avulso ou estratégico**, lidos direto dos grupos da
análise. Aqui a pergunta não é "o que eu cancelo?", é "existe algo que eu resolva?"
e o estratégico o agente resolve pela tool ``vas_estrategico``. Sem nenhum dos dois
por trás da variação não há o que o bot resolva, e o atendimento finaliza
``nao_resolvido`` em vez de virar improviso do orquestrador.
**Só a retenção cruza com o PDF.** O ``type`` do grupo de análise já é a classe, então
o ramo NÃO não precisa do ``build_snapshot``: ele só conta o que variou nos dois grupos
de VAS. A retenção precisa, porque promete cancelar e tem de saber QUAL linha — e paga
por isso o preço de conciliar nome e valor entre dois pipelines.
``varied_current_charges`` responde só pela variação (o grupo de análise lido é
parâmetro); :func:`varied_avulso_items` cruza com a classificação da fatura.
**Por que ler o payload CRU e não o snapshot do resolver.**
``InvoiceResolver._iter_msisdn_buckets`` passa por ``_billing_analysis_sections``,
que FUNDE ``currentInvoice`` e ``invoiceVariation`` num único bucket por ``desc``.
Isso destrói a partição passada/atual — a informação de que este módulo depende — e
faz o snapshot conter itens que existiam SÓ na fatura passada. Por isso aqui as duas
listas são lidas cruas do ``invoice_detail``.
**A regra.** Dentro dos grupos de variação pedidos — ``servicos_contratados_de_parceiros``
é o VAS AVULSO e o bucket irmão ``streaming`` é o ESTRATÉGICO (SPEC §12.A) —, cada item
carrega o campo ``invoice`` = vencimento da fatura de onde veio. Particiona-se por esse
vencimento (o menor é a fatura passada, o maior a atual) e tira-se a diferença de
**multiset** por ``(nome normalizado, valor)`` — a multiplicidade importa, porque o mesmo
serviço cobrado 2× no ciclo são duas cobranças reais. Só a direção *entrou/subiu* é
devolvida: variação causada por REMOÇÃO não tem o que cancelar (decisão de produto).
Medida (no recorte avulso) contra 88 pares de fatura reais com as faturas normalizadas
como verdade: 88/88 exatas, precisão e recall 1.000. O desempate de data única (abaixo)
é essencial — sem ele a direção erra e a precisão cai para 0.377.
**Duas formas de payload.** O runtime entrega ``value`` como string (``'19.9'``) e
``invoice`` em ISO-8601 (``'2026-04-07T00:00:00.000Z'``); outra serialização do
mesmo pipeline usa float e ``dd/mm/yyyy``. As duas são aceitas.
**Falha fechada.** Qualquer ausência ou inconsistência que impeça estabelecer a
partição devolve tupla vazia — e sem cobrança variada a Policy não oferece retenção
(o pedido de humano volta ao single-strike). Nunca promete cancelar o que não sabe
que variou.
"""
from __future__ import annotations
import logging
import os
import re
from collections import Counter
from dataclasses import dataclass
from decimal import Decimal
from typing import Any, Iterable, Iterator, Mapping, Sequence
from .invoice_resolver import STRATEGIC_NAMES, InvoiceResolver
from .invoice_models import ResolvedInvoiceItem
logger = logging.getLogger(__name__)
# Segunda passada por PREFIXO DE TOKENS (``_match_qualified_names``, incidente
# e081812886a: variação "FIT ME App" × fatura "FIT ME App Premium Mensal").
# DESLIGADA por padrão: o corpus real prova que o mesmo prefixo tanto qualifica o
# mesmo produto quanto separa produtos DIFERENTES — ``Fluid Light`` × ``Fluid
# Premium`` na mesma fatura com preços distintos, e ``VOD + Canais Abertos`` ×
# ``VOD +Canais`` com valor IDÊNTICO (19.90). Como a forma é a mesma nos dois
# casos, nenhuma regra sintática os separa, e casar significaria cancelar o
# serviço errado sem confirmação (esta rota autoriza a fila direto). Religar só
# quando houver identificador de produto compartilhado entre as duas fontes — a
# interseção de campos hoje é apenas ``{desc, value}``.
# ``TIM_VAS_QUALIFIED_NAME_MATCH=true`` reativa (só para experimento/shadow).
_VAS_QUALIFIED_NAME_MATCH = (
os.getenv("TIM_VAS_QUALIFIED_NAME_MATCH", "false").lower() == "true"
)
# Buckets de VAS na análise de variação/fatura atual: ``servicos_contratados_de_parceiros``
# é o AVULSO e ``streaming`` o ESTRATÉGICO (SPEC §12.A). A retenção lê só o avulso (é o
# único cancelável); o ramo NÃO do invoice_explanation lê os dois.
_AVULSO_ANALYSIS_TYPE = "servicos_contratados_de_parceiros"
_STRATEGIC_ANALYSIS_TYPE = "streaming"
_VAS_ANALYSIS_TYPES = (_AVULSO_ANALYSIS_TYPE, _STRATEGIC_ANALYSIS_TYPE)
# Chaves de análise no ``invoice_detail``, como o backend as entrega (camelCase).
_VARIATION_KEY = "invoiceVariation"
_CURRENT_KEY = "currentInvoice"
_ISO_DATE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})")
_BR_DATE = re.compile(r"^(\d{2})/(\d{2})/(\d{4})")
# ----- data da COBRANÇA (≠ vencimento da fatura) ------------------------------
# ``_due_date_key``/``_BR_DATE`` acima leem o campo ``invoice`` (VENCIMENTO) e
# particionam fatura-passada × atual — são load-bearing e ficam intocados. O par
# abaixo resolve outra pergunta: QUAL cobrança, dentro da fatura, é esta. Precisa
# de duas formas que o ``_BR_DATE`` rejeita (ano de 2 dígitos), porque a
# explicação diz "no dia 29/06/26" e o PDF grava ``period`` como "01/11/25".
_CHARGE_DATE_ISO = re.compile(r"^(\d{4})-(\d{2})-(\d{2})")
_CHARGE_DATE_BR = re.compile(r"^(\d{2})/(\d{2})/(\d{2,4})\b")
# Sentinela que o backend emite quando não sabe a data. Parseia como data válida
# e viraria desempate FALSO se passasse adiante.
_CHARGE_DATE_SENTINEL = ("0000", "12", "31")
def _charge_date_key(raw: Any) -> tuple[str, str, str] | None:
"""Chave canônica ``(ano, mês, dia)`` de uma data de COBRANÇA.
Aceita ISO (``2025-11-01T00:00:00.000Z``), ``dd/mm/aa`` (``25/06/26``) e
``dd/mm/yyyy``. Ano de 2 dígitos vira ``20xx`` — as faturas em questão são
todas deste século e o campo não carrega o século.
Devolve ``None`` para ausente, ilegível, data impossível e para o SENTINELA
``0000-12-31``: aqui a igualdade de data é EVIDÊNCIA para desempatar
cancelamento, então "não sei" tem de ser indistinguível de "não tem" — nunca
tolerância por prefixo (isso é similaridade, e similaridade não autoriza
cancelar)."""
text = str(raw or "").strip()
if not text:
return None
iso = _CHARGE_DATE_ISO.match(text)
if iso:
key = (iso.group(1), iso.group(2), iso.group(3))
else:
br = _CHARGE_DATE_BR.match(text)
if not br:
return None
year = br.group(3)
key = (year if len(year) == 4 else f"20{year}", br.group(2), br.group(1))
if key == _CHARGE_DATE_SENTINEL:
return None
try:
if not (1 <= int(key[1]) <= 12 and 1 <= int(key[2]) <= 31):
return None
except ValueError:
return None
return key
@dataclass(frozen=True, slots=True)
class _ExplainedCharge:
"""Uma cobrança DATADA extraída do texto cru do ``invoiceExplanation``.
É a única fonte de data de cobrança que serve: o campo ``date`` estruturado
do ``invoiceVariation`` vem sentinela/ausente em produção."""
desc: str
value: Decimal
date_key: tuple[str, str, str]
# Só a forma COM data explícita. "Foram cobrados os seguintes serviços de
# terceiros: X" e "* X variou em R$ Y" não têm dia e, por desenho, não casam:
# sem data não há evidência para desempatar, e o fluxo falha FECHADO.
_EXPLAINED_CHARGE_RE = re.compile(
r"^(?:Cobran[çc]a\s+)?(?P<desc>.+?)\s+no\s+valor\s+de\s+R\$\s*"
r"(?P<value>[\d.,]+)\s+no\s+dia\s+(?P<date>\d{2}/\d{2}/\d{2,4})",
re.IGNORECASE,
)
def _explained_charges(text: Any) -> list[_ExplainedCharge]:
"""Extrai as cobranças datadas do ``invoiceExplanation`` CRU.
``text`` tem de ser o ``invoice_explanation_base`` (resposta original do
backend), NUNCA a reescrita do LLM: a reescrita mexe em pontuação e formato
monetário, e identidade determinística não pode depender disso.
Devolve LISTA, não conjunto — dois bullets podem ter o mesmo (valor, data), e
a multiplicidade é o que impede um hint de ser reusado por duas cobranças
independentes."""
out: list[_ExplainedCharge] = []
for line in str(text or "").splitlines():
stripped = line.strip().lstrip("*-•").strip()
if not stripped:
continue
match = _EXPLAINED_CHARGE_RE.match(stripped)
if match is None:
continue
date_key = _charge_date_key(match.group("date"))
if date_key is None:
continue
raw_value = match.group("value")
# "16,99" e "1.234,56": vírgula é decimal, ponto é milhar.
if "," in raw_value:
raw_value = raw_value.replace(".", "").replace(",", ".")
value = InvoiceResolver._parse_money(raw_value)
if value is None:
continue
out.append(
_ExplainedCharge(
desc=match.group("desc").strip(),
value=value,
date_key=date_key,
)
)
return out
@dataclass(frozen=True, slots=True)
class VariedCharge:
"""Uma cobrança de VAS avulso que entrou ou subiu na fatura atual.
``desc`` é o texto CRU da fatura (nunca normalizado) — serve a telemetria e a
qualquer fala futura. O casamento com itens do resolver é sempre por
:func:`charge_match_key`, nunca por ``desc`` direto."""
desc: str
value: Decimal
def charge_match_key(desc: Any, value: Any) -> tuple[str, Decimal] | None:
"""Chave de identidade de uma cobrança: ``(nome normalizado, valor)``.
Definição ÚNICA, usada dos dois lados do casamento (as cobranças variadas
daqui e os ``ResolvedInvoiceItem`` do snapshot). Reusa os helpers canônicos do
:class:`InvoiceResolver` de propósito: um normalizador próprio poderia divergir
do que casa ``canonical_name``, e aí a fila de cancelamento sairia errada.
``None`` quando falta nome ou o valor não é parseável — cobrança sem identidade
não entra no diff."""
name = str(desc or "").strip()
if not name:
return None
parsed = InvoiceResolver._parse_money(value)
if parsed is None:
return None
return (InvoiceResolver._normalize_match_text(name), parsed)
def varied_current_charges(
invoice_detail: Mapping[str, Any] | None,
*,
analysis_types: Sequence[str] = (_AVULSO_ANALYSIS_TYPE,),
) -> tuple[VariedCharge, ...]:
"""Cobranças de VAS que ENTRARAM ou SUBIRAM na fatura atual.
``analysis_types`` escolhe os grupos de análise lidos — só o avulso (default, o
recorte da retenção) ou ``_VAS_ANALYSIS_TYPES`` (avulso + estratégico). A partição
passada × atual é feita sobre a UNIÃO dos grupos pedidos: os itens dos dois carregam
o vencimento das MESMAS duas faturas, e olhar a união é o que salva o caso em que um
dos grupos existe só na fatura atual (sozinho ele cairia no desempate de data única).
Tupla vazia quando não há dados de variação utilizáveis (sem ``invoiceVariation``,
sem os grupos pedidos, vencimento ilegível, ou variação causada apenas por remoção)
— falha fechada, ver docstring do módulo."""
try:
items = list(_iter_vas_items(invoice_detail, _VARIATION_KEY, analysis_types))
except Exception: # pragma: no cover - defensivo, payload arbitrário
logger.debug("vas_variation.read_failed", exc_info=True)
return ()
if not items:
return ()
by_due_date: dict[tuple[str, str, str], list[Mapping[str, Any]]] = {}
for item in items:
due = _due_date_key(item.get("invoice"))
if due is None:
# Item sem vencimento legível não pode ser atribuído a nenhuma das duas
# faturas. Incluí-lo em qualquer um dos lados corromperia o diff — e um
# diff errado cancela o serviço errado. Aborta.
logger.debug("vas_variation.unattributable_item")
return ()
by_due_date.setdefault(due, []).append(item)
due_dates = sorted(by_due_date)
if len(due_dates) >= 2:
past_items = by_due_date[due_dates[0]]
current_items = by_due_date[due_dates[-1]]
else:
current_items, past_items = _split_single_due_date(
by_due_date[due_dates[0]], invoice_detail
)
current = _charge_counter(current_items)
past = _charge_counter(past_items)
added = current - past
if not added:
return ()
raw_descs = _first_raw_descs(current_items)
return tuple(
VariedCharge(desc=raw_descs.get(key, key[0]), value=key[1])
for key in added.elements()
)
def variation_source(
invoice_variation: Mapping[str, Any] | None,
invoice_detail: Mapping[str, Any] | None,
) -> Mapping[str, Any]:
"""Qual dos dois payloads carrega a ANÁLISE de variação.
Primária: ``invoice_variation`` — gravada pelo prefetch a partir de
``InvoiceExplanationOutput.detalhes`` (corpo do ``billingAnalysis``). NÃO vem do
``invoice_detail``, que é o PDF parseado e não carrega a partição fatura-passada ×
atual.
Fallback para ``invoice_detail``: em entradas onde ele JÁ é o payload de análise (o
resolver suporta esse formato — ver ``_billing_analysis_sections``), a variação
está lá. Definição ÚNICA para os dois consumidores (runtime e workflow), para os
dois lerem a mesma coisa."""
if isinstance(invoice_variation, Mapping) and (
invoice_variation.get(_VARIATION_KEY) or invoice_variation.get(_CURRENT_KEY)
):
return invoice_variation
return invoice_detail if isinstance(invoice_detail, Mapping) else {}
def varied_avulso_items(
*,
invoice_variation: Mapping[str, Any] | None,
invoice_detail: Mapping[str, Any] | None,
resolver: InvoiceResolver,
by_charge: bool,
explanation_text: str | None = None,
) -> list[ResolvedInvoiceItem] | None:
"""Itens ``avulso`` da fatura cujas cobranças VARIARAM na atual.
Responde a pergunta "a variação foi causada por VAS avulso?" e, quando sim, com
QUAIS cobranças — o escopo da retenção no pedido de humano (SPEC §9), que promete
cancelar o que ofereceu. Para o recorte mais largo (avulso **ou** estratégico), do
ramo NÃO do ``invoice_explanation``, ver :func:`varied_vas_charges`.
Cruza duas fontes, cada uma com o que só ela tem:
- ``invoice_variation`` → a ANÁLISE (``invoiceVariation``), única com a partição
fatura-passada × atual, logo a única que sabe o que VARIOU;
- ``invoice_detail`` → o PDF parseado, única com a classificação canônica
(``classe``/``estrategico`` carimbados pelo parser e honrados por ``_classify``),
logo a única que sabe o que é CANCELÁVEL.
O cruzamento é por :func:`charge_match_key` (nome normalizado + valor), a mesma
chave dos dois lados. Se os valores divergirem entre as fontes o casamento falha e
a lista sai vazia → falha fechada.
**Só AVULSO conta** (SPEC §9/§11): bundle/estratégico nunca entram. O ``item_type``
do resolver não basta como único filtro — na seção "Serviços de valor adicionado"
ele decide por ``contestable``, então um serviço que a API bucketou como avulso sai
``avulso`` mesmo sendo estratégico. Visto em fatura real: ``YouTube Premium Mensal``
no bucket avulso da variação vira ``avulso``/``cancelar_vas_avulso`` e, pelo grupo
``Streamings`` da mesma fatura, ``estrategico`` — duas classes para o MESMO serviço.
Por isso dois guards, e a NOSSA classificação vence a da API:
1. nome na lista fixa ``STRATEGIC_NAMES`` (SPEC §12.A) → nunca avulso;
2. o mesmo nome classificado ``estrategico``/``bundle`` em qualquer seção desta
fatura → o "avulso" é mis-bucket da API; descarta.
``None`` em falha de leitura/resolução; lista (possivelmente vazia) caso
contrário — o chamador decide o que "não sei" significa no fluxo dele."""
if not invoice_detail:
return None
try:
varied = varied_current_charges(
variation_source(invoice_variation, invoice_detail)
)
if not varied:
return []
snapshot = resolver.build_snapshot(invoice_detail, by_charge=by_charge)
except Exception:
logger.debug("vas_variation.avulso_lookup_failed", exc_info=True)
return None
wanted = Counter(
key
for key in (charge_match_key(c.desc, c.value) for c in varied)
if key is not None
)
if not wanted:
return []
# Guard 2: nomes que ESTA fatura classifica como não-cancelável em alguma seção.
# Normalizado pela mesma chave para casar grafias divergentes entre os dois campos
# de análise.
blocked_names = {
charge_match_key(it.canonical_name, 0)
for it in snapshot
if it.item_type in {"estrategico", "bundle"}
}
items: list[ResolvedInvoiceItem] = []
# Datas de cobrança do ``invoiceExplanation`` cru. Parse LAZY: sem explicação,
# ou sem nenhuma ambiguidade a desempatar, nada é parseado e o comportamento é
# IDÊNTICO ao de antes desta mudança.
hints = _DateHints(explanation_text)
# Iteração CHARGE-driven (não item-driven): para cada cobrança que variou,
# coleta TODOS os itens do snapshot que a atendem e só então decide. O loop
# item-driven anterior consumia a cobrança no PRIMEIRO item que casava, então
# o vencedor era quem ``build_snapshot`` emitisse antes — com o mesmo
# ``(nome, valor)`` em duas LINHAS, o cancelamento saía na linha decidida pela
# ordem de iteração do dict, não por evidência.
for key, missing in list(wanted.items()):
if missing <= 0:
continue
eligible = [
item
for item in snapshot
if item.item_type == "avulso"
and not is_strategic_name(item.canonical_name) # guard 1
and charge_match_key(item.canonical_name, 0) not in blocked_names # guard 2
and charge_match_key(item.canonical_name, item.value) == key
]
if not eligible:
continue
# ORDEM DOS GUARDS — IDENTIDADE ANTES DE EXECUTABILIDADE.
# Multiplicidade (SPEC §9): o mesmo serviço cobrado N× no ciclo são N
# cobranças e cada uma entra na fila — casar todas é correto quando a
# contagem BATE. Sobrando candidato, não se sabe QUAL variou: falha
# FECHADA, nunca "escolhe o primeiro".
#
# Este teste vem ANTES do filtro de ``msisdn`` de propósito. Invertido,
# um candidato descartado por falta de linha COROARIA o outro: com uma
# cobrança variada e dois itens de mesma identidade — um com linha, outro
# sem — a ambiguidade de IDENTIDADE continua de pé, e a inexecutabilidade
# de um deles não é evidência de que o outro seja o que variou.
if len(eligible) > missing:
# Antes de descartar, tenta ESTREITAR pela data da cobrança (a única
# evidência determinística que distingue linhas com o mesmo nome e
# valor — titular × dependente). Só reduz o conjunto: o resultado é
# sempre subconjunto de ``eligible``, e este caminho só é alcançado
# onde hoje se descarta tudo. Nenhum casamento existente muda.
narrowed, hint = _narrow_by_charge_date(eligible, missing, hints, key[1])
if narrowed is None:
logger.info(
"vas_variation.varied_charge_unmatched reason=multiple_candidates"
" date_hint=%s service=%s count=%s",
hint,
key[0],
len(eligible),
)
# Já reportada com a razão CERTA: consome para não reaparecer no
# resíduo de ``_log_unmatched_varied_charges`` como
# ``name_absent``/``value_mismatch``. Uma cobrança descartada emite
# exatamente um evento — telemetria dupla corrompe a taxa.
wanted[key] = 0
continue
logger.info(
"vas_variation.varied_charge_matched reason=charge_date"
" service=%s count=%s candidates=%s",
key[0],
len(narrowed),
len(eligible),
)
eligible = narrowed
# Guard 3: resolvida a identidade, sobra a executabilidade. Sem ``msisdn``
# não há linha de destino, e o campo vai CRU para os args de
# ``cancelar_vas_avulso`` (``action_queue._to_tool_args``). Razão própria —
# sem ela a cobrança sairia adiante como ``name_absent``/``value_mismatch``,
# diagnóstico errado (o nome estava lá; faltava a linha).
candidates = [item for item in eligible if item.msisdn]
if len(candidates) < len(eligible):
logger.info(
"vas_variation.varied_charge_unmatched reason=missing_msisdn"
" service=%s count=%s",
key[0],
len(eligible) - len(candidates),
)
# Consome as identidades RESOLVIDAS (não só as executáveis): a cobrança
# descartada por falta de linha já foi reportada com a razão certa e não
# pode reaparecer no resíduo como não-casada.
wanted[key] -= len(eligible)
items.extend(candidates)
# Ordem do snapshot preservada na saída: a decisão é por cobrança, mas a fila
# sai na ordem da fatura (determinismo de saída, independente da ordem em que
# as cobranças variadas foram iteradas).
order = {id(item): idx for idx, item in enumerate(snapshot)}
items.sort(key=lambda it: order.get(id(it), 0))
# Passe residual por (valor, data): resolve o caso em que os NOMES divergem
# entre as fontes ("FIT ME App" na análise × "FIT ME App Premium Mensal" no
# PDF). A ponte é determinística — valor ao centavo mais data da cobrança —,
# sem afirmar que os nomes são equivalentes. Só atua sobre chaves que o loop
# principal deixou por resolver.
items.extend(
_match_by_charge_date(wanted, snapshot, items, blocked_names, hints)
)
items.sort(key=lambda it: order.get(id(it), 0))
if _VAS_QUALIFIED_NAME_MATCH:
items.extend(_match_qualified_names(wanted, snapshot, items))
_log_unmatched_varied_charges(wanted, snapshot)
return items
def varied_vas_charges(
*,
invoice_variation: Mapping[str, Any] | None,
invoice_detail: Mapping[str, Any] | None,
) -> tuple[VariedCharge, ...]:
"""Cobranças de VAS que VARIARAM — a pergunta do ramo NÃO do
``invoice_explanation`` (SPEC §9 "Explicação da variação recusada").
"A variação foi causada por VAS que o agente resolve?" Recorte mais largo que
:func:`varied_avulso_items` porque o desfecho aqui não é cancelar, é *ter assunto*:
o estratégico o agente trata pela tool ``vas_estrategico``, então uma variação
causada por streaming volta ao orquestrador em vez de encerrar o atendimento.
**Não cruza com o PDF da fatura, de propósito.** O ``type`` do grupo de análise JÁ
É a classe: ``servicos_contratados_de_parceiros`` é o avulso e ``streaming`` o
estratégico (confirmado contra a API, 2026-08-10). O ``build_snapshot`` só
reafirmaria uma classificação que já veio carimbada, ao custo de casar nome e valor
entre dois pipelines que divergem em grafia e centavo — e cada divergência
ENCERRAVA a ligação de quem tinha o serviço na fatura (incidentes e081812886a,
"FIT ME App" × "FIT ME App Premium Mensal", e Neymar Jr.Experience, PDF ilegível).
Aqui nada é cancelado, então identidade não precisa ser provada: basta existir
assunto. ``bundle`` nunca chega nestes grupos, e cobrança de outra jornada
mis-bucketada pela API (roaming visto no grupo de parceiros) é risco ACEITO — o
preço é devolver o turno ao orquestrador, que é o comportamento histórico.
A retenção (:func:`varied_avulso_items`) segue cruzando com o PDF: lá se promete
cancelar, e é preciso saber QUAL linha.
Tupla vazia quando nada variou nos dois grupos — inclui análise ausente, vencimento
ilegível e variação causada apenas por REMOÇÃO (falha fechada de
:func:`varied_current_charges`), e o ramo NÃO finaliza ``nao_resolvido``."""
return varied_current_charges(
variation_source(invoice_variation, invoice_detail),
analysis_types=_VAS_ANALYSIS_TYPES,
)
class _DateHints:
"""Cobranças datadas da explicação, com CARDINALIDADE preservada.
Não é ``set[(valor, data)]`` de propósito: dois bullets reais podem ter o
mesmo valor e a mesma data, e um hint não pode ser reusado por duas cobranças
independentes sem evidência de multiplicidade. Parse lazy — só na primeira
consulta, que só acontece se algum caminho precisar desempatar."""
__slots__ = ("_text", "_charges", "_consumed")
def __init__(self, text: str | None) -> None:
self._text = text
self._charges: list[_ExplainedCharge] | None = None
self._consumed: set[int] = set()
def _all(self) -> list[_ExplainedCharge]:
if self._charges is None:
self._charges = _explained_charges(self._text) if self._text else []
return self._charges
def available(self, value: Decimal) -> list[int]:
"""Índices dos hints deste valor ainda NÃO consumidos."""
return [
idx
for idx, charge in enumerate(self._all())
if idx not in self._consumed and charge.value == value
]
def date_keys(self, idxs: Sequence[int]) -> set[tuple[str, str, str]]:
charges = self._all()
return {charges[idx].date_key for idx in idxs}
def take(
self, idxs: Sequence[int], items: Sequence[ResolvedInvoiceItem]
) -> bool:
"""Consome UM hint por item, casando data 1:1. False se não fechar."""
charges = self._all()
remaining = list(idxs)
used: list[int] = []
for item in items:
key = _charge_date_key(item.charge_date)
for idx in remaining:
if charges[idx].date_key == key:
used.append(idx)
remaining.remove(idx)
break
else:
return False
self._consumed.update(used)
return True
def _narrow_by_charge_date(
eligible: "list[ResolvedInvoiceItem]",
missing: int,
hints: "_DateHints",
value: Decimal,
) -> "tuple[list[ResolvedInvoiceItem] | None, str]":
"""Estreita candidatos ambíguos pela data da cobrança. Nunca amplia.
Devolve ``(None, motivo)`` quando a evidência não fecha — e aí o chamador
falha FECHADA, exatamente como antes desta mudança."""
idxs = hints.available(value)
if not idxs:
return None, "absent"
# Candidato SEM data legível não pode ser eliminado: ausência de data não é
# evidência negativa. Com um datado e um sem data, escolher o datado seria
# decidir por eliminação — proibido.
if any(_charge_date_key(item.charge_date) is None for item in eligible):
return None, "undated"
keys = hints.date_keys(idxs)
narrowed = [
item for item in eligible if _charge_date_key(item.charge_date) in keys
]
if len(narrowed) != missing:
return None, "ambiguous"
if not hints.take(idxs, narrowed):
return None, "ambiguous"
return narrowed, "matched"
def _match_by_charge_date(
wanted: "Counter[tuple[str, Decimal]]",
snapshot: "list[ResolvedInvoiceItem]",
already: "list[ResolvedInvoiceItem]",
blocked_names: "set[tuple[str, Decimal] | None]",
hints: "_DateHints",
) -> "list[ResolvedInvoiceItem]":
"""Passe residual: casa por (VALOR, DATA) quando o NOME diverge entre fontes.
A análise traz o nome curto e o PDF o comercial completo — a igualdade de
nome falha, mas valor ao centavo + data da cobrança identificam a mesma
cobrança sem nenhuma inferência sobre os nomes. Todos os guards do loop
principal valem aqui, na mesma ordem: identidade primeiro, executabilidade
(``msisdn``) depois, cardinalidade EXATA, e falha fechada em qualquer dúvida."""
consumed = {id(item) for item in already}
extra: list[ResolvedInvoiceItem] = []
for key, missing in list(wanted.items()):
if missing <= 0:
continue
idxs = hints.available(key[1])
if not idxs:
continue
keys = hints.date_keys(idxs)
eligible = [
item
for item in snapshot
if item.item_type == "avulso"
and id(item) not in consumed
and not is_strategic_name(item.canonical_name) # guard 1
and charge_match_key(item.canonical_name, 0) not in blocked_names # guard 2
and item.value == key[1]
and _charge_date_key(item.charge_date) in keys
]
if len(eligible) != missing:
continue
if not hints.take(idxs, eligible):
continue
candidates = [item for item in eligible if item.msisdn] # guard 3
if len(candidates) < len(eligible):
logger.info(
"vas_variation.varied_charge_unmatched reason=missing_msisdn"
" service=%s count=%s",
key[0],
len(eligible) - len(candidates),
)
wanted[key] = 0
for item in eligible:
consumed.add(id(item))
extra.extend(candidates)
if candidates:
logger.info(
"vas_variation.varied_charge_matched reason=charge_date"
" service=%s count=%s candidates=%s",
key[0],
len(candidates),
len(eligible),
)
return extra
def _match_qualified_names(
wanted: "Counter[tuple[str, Decimal]]",
snapshot: "list[ResolvedInvoiceItem]",
already: "list[ResolvedInvoiceItem]",
) -> "list[ResolvedInvoiceItem]":
"""Segunda passada: a análise de variação traz o nome CURTO do serviço e a
fatura traz o nome COMPLETO (incidente e081812886a: ``FIT ME App`` na variação
× ``FIT ME App Premium Mensal`` em ``Itens Eventuais``). A igualdade estrita de
nome descartava o item e o pedido de atendente encerrava sem oferecer retenção.
Casa apenas por **prefixo de TOKENS** — os tokens da variação têm de ser o
início exato dos tokens do item, nessa ordem. Não é substring solta ("me app"
não casa) nem fuzzy. E só consome a cobrança com todas as travas:
* mesmo valor, ao centavo (a chave já carrega o valor parseado);
* item ``avulso`` que passou pelos guards de estratégico/classe mista;
* **exatamente um** candidato — ambiguidade falha FECHADA (SPEC §retenção:
nunca prometer cancelamento do que não se sabe ter variado).
"""
if not wanted:
return []
consumed = {id(item) for item in already}
extra: list[ResolvedInvoiceItem] = []
for (wanted_name, wanted_value), missing in list(wanted.items()):
if missing <= 0:
continue
wanted_tokens = wanted_name.split()
if not wanted_tokens:
continue
candidates = [
item
for item in snapshot
if item.item_type == "avulso"
and item.msisdn # guard 3: mesma exigência da primeira passada
and id(item) not in consumed
and not is_strategic_name(item.canonical_name)
and _is_token_prefix(
wanted_tokens, charge_match_key(item.canonical_name, item.value)
)
and (charge_match_key(item.canonical_name, item.value) or (None, None))[1]
== wanted_value
]
if len(candidates) != 1:
if len(candidates) > 1:
logger.info(
"vas_variation.varied_charge_unmatched reason=multiple_candidates"
" service=%s count=%s",
wanted_name,
len(candidates),
)
continue
item = candidates[0]
consumed.add(id(item))
wanted[(wanted_name, wanted_value)] -= 1
extra.append(item)
logger.info(
"vas_variation.varied_charge_matched reason=qualified_name service=%s",
wanted_name,
)
return extra
def _is_token_prefix(
wanted_tokens: "list[str]", item_key: "tuple[str, Decimal] | None"
) -> bool:
"""Os tokens da variação são o PREFIXO exato dos tokens do item da fatura."""
if item_key is None:
return False
item_tokens = item_key[0].split()
if len(item_tokens) <= len(wanted_tokens):
return False
return item_tokens[: len(wanted_tokens)] == wanted_tokens
def is_strategic_name(name: str) -> bool:
"""True se o nome do serviço está na lista fixa de SVA Estratégico/Terceiros
(:data:`STRATEGIC_NAMES`, SPEC §12.A).
Guard 1 de :func:`varied_avulso_items`: estratégico NÃO é cancelável, e a
classificação por seção sozinha deixa passar estratégico que a API bucketou como
avulso. Substring sobre o nome em caixa baixa — a lista guarda a marca ("netflix",
"youtube"), não o nome comercial completo ("YouTube Premium Mensal")."""
lowered = str(name or "").casefold()
return any(brand in lowered for brand in STRATEGIC_NAMES)
def _log_unmatched_varied_charges(
wanted: Counter[tuple[str, Decimal]],
snapshot: Sequence[ResolvedInvoiceItem],
) -> None:
"""Registra as cobranças que a API diz ter variado mas NÃO casaram na fatura
atual. Elas ficam fora de propósito (só contamos o que casa nome E valor), mas o
descarte silencioso esconderia a taxa real.
As duas fontes são pipelines distintos — a variação vem do ``billingAnalysis`` e a
fatura atual do PDF parseado —, então grafia e arredondamento podem divergir.
Separar ``value_mismatch`` (o serviço está na fatura, o valor não bate) de
``name_absent`` (não achamos o serviço) é o que diria se vale afrouxar o casamento:
afrouxar por nome faria a cobrança variada casar a cobrança ERRADA do mesmo serviço
(ex.: 2 cobranças, só uma variou), que é a super-cancelação que este filtro existe
para evitar."""
unmatched = +wanted # descarta chaves já consumidas (contagem <= 0)
if not unmatched:
return
snapshot_names = {charge_match_key(it.canonical_name, 0) for it in snapshot}
for (name, _value), count in unmatched.items():
reason = (
"value_mismatch"
if charge_match_key(name, 0) in snapshot_names
else "name_absent"
)
logger.info(
"vas_variation.varied_charge_unmatched reason=%s service=%s count=%s",
reason,
name,
count,
)
def _split_single_due_date(
items: Sequence[Mapping[str, Any]],
invoice_detail: Mapping[str, Any] | None,
) -> tuple[list[Mapping[str, Any]], list[Mapping[str, Any]]]:
"""Bloco com um ÚNICO vencimento: não há partição para comparar.
Acontece quando uma das duas faturas não tinha nenhum VAS avulso (27 de 88 casos
na base medida) — então o bloco é inteiramente de um lado, e o que falta saber é
QUAL. Desempata contra ``currentInvoice``: se a maioria do bloco aparece lá, é o
lado ATUAL (tudo entrou); senão é o PASSADO (tudo saiu, nada a cancelar). Devolve
``(atuais, passadas)``.
Olha TODOS os grupos de ``currentInvoice``, não só o avulso: a pergunta aqui é
"esta cobrança está na fatura atual?", e a CLASSE é problema do resolver. Os dois
campos vêm de geradores distintos e discordam da classe do mesmo item (visto na
base: um serviço no bucket avulso da variação e em ``streaming`` da fatura atual)
— restringir ao bucket avulso perderia a cobrança e inverteria a direção."""
current_charges = _charge_counter(
_iter_all_items(invoice_detail, _CURRENT_KEY)
)
matched = 0
for item in items:
key = charge_match_key(item.get("desc"), item.get("value"))
if key is not None and current_charges[key] > 0:
current_charges[key] -= 1
matched += 1
if matched * 2 >= len(items):
return list(items), []
return [], list(items)
def _iter_vas_items(
invoice_detail: Mapping[str, Any] | None,
analysis_key: str,
analysis_types: Sequence[str],
) -> Iterator[Mapping[str, Any]]:
"""Itens dos grupos de VAS pedidos em ``analysis_key`` (``invoiceVariation`` ou
``currentInvoice``), lidos crus."""
yield from _iter_all_items(invoice_detail, analysis_key, only_types=analysis_types)
def _iter_all_items(
invoice_detail: Mapping[str, Any] | None,
analysis_key: str,
*,
only_types: Sequence[str] | None = None,
) -> Iterator[Mapping[str, Any]]:
"""Itens de ``analysis_key``, opcionalmente restritos a certos ``type`` de grupo.
Silencioso em qualquer forma inesperada."""
for groups in _iter_analysis_lists(invoice_detail, analysis_key):
for group in groups:
if not isinstance(group, Mapping):
continue
if (
only_types is not None
and str(group.get("type") or "").strip() not in only_types
):
continue
items = group.get("items")
if not isinstance(items, list):
continue
for item in items:
if isinstance(item, Mapping):
yield item
def _iter_analysis_lists(
invoice_detail: Mapping[str, Any] | None, analysis_key: str
) -> Iterator[list[Any]]:
"""Rende cada lista ``analysis_key`` do payload: no top-level e dentro de cada
bucket por msisdn — espelha os dois pontos de leitura de
``InvoiceResolver._iter_msisdn_buckets``."""
if not isinstance(invoice_detail, Mapping):
return
top = invoice_detail.get(analysis_key)
if isinstance(top, list):
yield top
for value in invoice_detail.values():
if not isinstance(value, Mapping):
continue
nested = value.get(analysis_key)
if isinstance(nested, list):
yield nested
def _charge_counter(
items: Iterable[Mapping[str, Any]],
) -> Counter[tuple[str, Decimal]]:
"""Multiset das cobranças por ``(nome normalizado, valor)``. Cobranças sem
identidade são descartadas."""
counter: Counter[tuple[str, Decimal]] = Counter()
for item in items:
key = charge_match_key(item.get("desc"), item.get("value"))
if key is not None:
counter[key] += 1
return counter
def _first_raw_descs(
items: Iterable[Mapping[str, Any]],
) -> dict[tuple[str, Decimal], str]:
"""Mapa chave → primeiro ``desc`` cru visto, para devolver o nome como está na
fatura em vez do normalizado."""
raw: dict[tuple[str, Decimal], str] = {}
for item in items:
key = charge_match_key(item.get("desc"), item.get("value"))
if key is not None and key not in raw:
raw[key] = str(item.get("desc") or "").strip()
return raw
def _due_date_key(raw: Any) -> tuple[str, str, str] | None:
"""Chave ordenável ``(ano, mês, dia)`` do vencimento (campo ``invoice`` do
item). Aceita ISO-8601 (payload do runtime) e ``dd/mm/yyyy`` (serialização
alternativa do mesmo pipeline). ``None`` quando não parseável."""
text = str(raw or "").strip()
if not text:
return None
iso = _ISO_DATE.match(text)
if iso is not None:
return (iso.group(1), iso.group(2), iso.group(3))
br = _BR_DATE.match(text)
if br is not None:
return (br.group(3), br.group(2), br.group(1))
return None
__all__ = [
"VariedCharge",
"charge_match_key",
"is_strategic_name",
"variation_source",
"varied_avulso_items",
"varied_current_charges",
"varied_vas_charges",
]

File diff suppressed because it is too large Load Diff

649
app/main.py Normal file
View File

@@ -0,0 +1,649 @@
from __future__ import annotations
import logging
from uuid import uuid4
import time
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from agent_framework.channels.base import ChannelResponse
from agent_framework.channels.gateway import ChannelGateway
from agent_framework.channels.interruption import classify_processing_interruption, evaluate_interruption
from agent_framework.channels.transcription import fix_whole_utterance_transcription
from agent_framework.config.agent_registry import AgentProfileRegistry
from agent_framework.config.settings import settings
from agent_framework.analytics.factory import create_analytics_publisher
from agent_framework.observer import configure as configure_global_observer
from agent_framework.llm.providers import create_llm
from agent_framework.memory.message_history import create_memory
from agent_framework.memory.summary_memory import create_conversation_summary_memory
from agent_framework.mcp.tool_router import create_mcp_tool_router
from agent_framework.models.identity import AgentIdentity
from agent_framework.identity import IdentityResolver, BusinessContext
from agent_framework.models.session import ChatMessage, SessionContext
from agent_framework.observability.telemetry import Telemetry
from agent_framework.observability.context import set_observability_context, clear_observability_context
from agent_framework.repositories.session_repository import create_session_repository
from agent_framework.checkpoints.checkpoint_repository import create_checkpoint_repository
from agent_framework.cache.cache import create_cache
from agent_framework.billing.usage_repository import create_usage_repository
from agent_framework.sse.events import SSEHub
from app.workflows.agent_graph import AgentWorkflow
from app.observability.telemetry_observer import TelemetryBackedAgentObserver
logging.basicConfig(level=settings.LOG_LEVEL)
logger = logging.getLogger("agent_template_backend")
app = FastAPI(title="TIM Contas - Agent Framework OCI")
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",")],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
telemetry = Telemetry(settings)
usage_repository = create_usage_repository(settings)
llm = create_llm(settings, telemetry=telemetry, usage_repository=usage_repository)
memory = create_memory(settings)
summary_memory = create_conversation_summary_memory(settings, message_history=memory, llm=llm, telemetry=telemetry)
sessions = create_session_repository(settings)
checkpoints = create_checkpoint_repository(settings)
cache = create_cache(settings, telemetry=telemetry)
gateway = ChannelGateway(input_mode=settings.FRAMEWORK_CHANNEL_INPUT_MODE)
analytics = create_analytics_publisher(settings)
observer = TelemetryBackedAgentObserver(telemetry=telemetry)
configure_global_observer({
"enabled": getattr(settings, "ENABLE_ANALYTICS", False),
"providers": getattr(settings, "ANALYTICS_PROVIDERS", "oci_streaming"),
"topic_path": getattr(settings, "GCP_PUBSUB_TOPIC_PATH", None) or getattr(settings, "AGENT_PUBSUB_TOPIC", None),
})
tool_router = create_mcp_tool_router(settings, telemetry=telemetry)
identity_resolver = IdentityResolver.from_yaml(settings.IDENTITY_CONFIG_PATH)
agent_profiles = AgentProfileRegistry(settings)
sse_hub = SSEHub(settings, telemetry=telemetry)
workflow = AgentWorkflow(llm, memory, telemetry, analytics, settings, observer=observer, tool_router=tool_router, summary_memory=summary_memory)
logger.info("LLM provider carregado: %s", llm.__class__.__name__)
logger.info("Langfuse habilitado: %s host=%s", telemetry.is_enabled(), settings.LANGFUSE_HOST)
logger.info("Analytics habilitado: %s providers=%s", getattr(settings, "ENABLE_ANALYTICS", False), getattr(settings, "ANALYTICS_PROVIDERS", ""))
logger.info("Agentes disponíveis: %s", [p.agent_id for p in agent_profiles.list_profiles()])
logger.info("Framework channel input mode: %s", gateway.input_mode)
@app.middleware("http")
async def observability_context_middleware(request: Request, call_next):
clear_observability_context()
request_id = request.headers.get("x-request-id") or str(uuid4())
set_observability_context(
request_id=request_id,
channel=request.headers.get("x-channel") or "http",
ura_call_id=request.headers.get("x-ura-call-id"),
)
started = time.time()
try:
response = await call_next(request)
response.headers["x-request-id"] = request_id
await telemetry.event("http.request.completed", {
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
"duration_ms": int((time.time() - started) * 1000),
}, kind="http")
return response
except Exception as exc:
await telemetry.event("http.request.failed", {
"method": request.method,
"path": request.url.path,
"error": str(exc),
"duration_ms": int((time.time() - started) * 1000),
}, kind="http")
raise
finally:
clear_observability_context()
class GatewayRequest(BaseModel):
channel: str = "web"
payload: dict
agent_id: str | None = None
tenant_id: str | None = None
def _metadata_value(payload: dict, key: str):
metadata = payload.get("metadata")
if isinstance(metadata, dict):
return metadata.get(key)
return None
def _extract_workflow_id(payload: dict) -> str | None:
return (
payload.get("workflow_id")
or payload.get("workflowId")
or _metadata_value(payload, "workflow_id")
or _metadata_value(payload, "workflowId")
)
def _format_root_span_name(template: str | None, values: dict) -> str:
template = template or "agent.gateway_message"
try:
return template.format(**{k: v or "unknown" for k, v in values.items()})
except Exception:
logger.warning("LANGFUSE_ROOT_SPAN_NAME inválido: %s", template)
return "agent.gateway_message"
def _resolve_identity(req: GatewayRequest, msg) -> tuple[AgentIdentity, dict, BusinessContext, list[str]]:
payload = req.payload or {}
context = dict(msg.context or {})
tenant_id = req.tenant_id or payload.get("tenant_id") or context.get("tenant_id") or "default"
agent_id = req.agent_id or payload.get("agent_id") or context.get("agent_id") or agent_profiles.default_agent_id
profile = agent_profiles.get(agent_id)
# 1) Identidade técnica do framework: isola tenant/agente/sessão.
context.update({"tenant_id": tenant_id, "agent_id": profile.agent_id, "agent_profile": profile.__dict__})
identity = AgentIdentity.from_context(context, session_id=msg.session_id)
# 2) Identidade de negócio: chaves canônicas vindas do front/canal.
# Estas chaves são estáveis na sessão e seguem até agentes e MCP Router.
previous_business_context = context.get("business_context") or context.get("identity") or {}
business_context = identity_resolver.resolve(
{**payload, **context},
session_id=identity.conversation_key(),
previous=previous_business_context,
)
missing_identity_keys = identity_resolver.validate(business_context)
context.update({
"business_context": business_context.model_dump(),
"business_keys": business_context.to_context_dict(),
"identity_missing": missing_identity_keys,
"conversation_key": identity.conversation_key(),
"original_session_id": msg.session_id,
})
return identity, context, business_context, missing_identity_keys
async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False) -> dict:
try:
msg = await gateway.normalize(req.channel, req.payload)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
payload = req.payload or {}
identity, normalized_context, business_context, missing_identity_keys = _resolve_identity(req, msg)
agent_session_id = identity.conversation_key()
message_id = payload.get("message_id") or str(uuid4())
workflow_id = _extract_workflow_id(payload)
set_observability_context(
session_id=agent_session_id,
user_id=msg.user_id,
tenant_id=identity.tenant_id,
agent_id=identity.agent_id,
channel=msg.channel,
message_id=message_id,
workflow_id=workflow_id,
ura_call_id=payload.get("ura_call_id") or normalized_context.get("ura_call_id") or business_context.interaction_key,
)
stream = sse_hub.stream_for(agent_session_id)
async with stream.lock:
await sse_hub.emit(agent_session_id, "flow.start", {"session_id": agent_session_id, "message_id": message_id, "agent_id": identity.agent_id}) if emit_sse else None
session = await sessions.get(agent_session_id)
if not session:
context_fields = {
k: v
for k, v in normalized_context.items()
if k in SessionContext.model_fields
and k not in {"tenant_id", "agent_id", "session_id", "user_id", "channel", "channel_id"}
}
session = SessionContext(
tenant_id=identity.tenant_id,
agent_id=identity.agent_id,
session_id=agent_session_id,
user_id=msg.user_id,
channel=msg.channel,
channel_id=msg.channel_id,
**context_fields,
)
session.tenant_id = identity.tenant_id
session.agent_id = identity.agent_id
session.channel = msg.channel
session.channel_id = msg.channel_id or session.channel_id
await sessions.upsert(session)
session.metadata = {
**(session.metadata or {}),
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
"original_context": normalized_context,
}
await sessions.upsert(session)
fixed_message_text = fix_whole_utterance_transcription(msg.text)
if fixed_message_text != msg.text:
await telemetry.event(
"channel.transcription.fixed",
{
"session_id": agent_session_id,
"original_text": msg.text,
"fixed_text": fixed_message_text,
},
)
interruption = evaluate_interruption(
payload=payload,
message_text=fixed_message_text,
session_metadata=session.metadata,
terminal_fallback_text=getattr(settings, "POST_FINALIZE_REPLAY_MESSAGE", ""),
)
if interruption.action == "classify":
prior_history = await memory.list(agent_session_id)
prior_user_text = ""
for prior in reversed(prior_history):
role = getattr(prior, "role", None)
content = getattr(prior, "content", "")
if str(role or "") == "user" and str(content or "").strip():
prior_user_text = str(content).strip()
break
regenerate = await classify_processing_interruption(
llm,
original_agent=interruption.replay_text,
original_client=prior_user_text,
supplement_client=interruption.text,
)
await telemetry.event(
"channel.processing_interruption.classified",
{
"session_id": agent_session_id,
"regenerate": regenerate,
"profile_name": "processing_interruption_classifier",
},
)
if regenerate:
interruption.action = "process"
interruption.reason = "classifier_result_1"
else:
interruption.action = "replay"
interruption.reason = "classifier_result_0"
if interruption.action == "replay":
response = ChannelResponse(
channel=msg.channel,
session_id=agent_session_id,
text=interruption.replay_text,
metadata={
"channel_id": msg.channel_id,
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"original_session_id": msg.session_id,
"conversation_key": agent_session_id,
"workflow_id": workflow_id,
"message_id": message_id,
"replay": True,
"replay_reason": interruption.reason,
"is_interruptible": interruption.is_interruptible,
"framework_short_circuit": True,
"terminal_status": interruption.terminal_status,
"llm_called": False,
"tools_called": False,
"guardrails_called": False,
},
)
rendered = await gateway.render(response)
await telemetry.event("gateway.message.replayed", {
"session_id": agent_session_id,
"reason": interruption.reason,
})
await sse_hub.emit(agent_session_id, "message.responded", rendered) if emit_sse else None
return rendered
effective_text = interruption.text
await sse_hub.emit(agent_session_id, "session.upserted", {"session_id": agent_session_id, "business_context": business_context.model_dump()}) if emit_sse else None
await memory.append(
agent_session_id,
ChatMessage(
role="user",
content=effective_text,
metadata={
**normalized_context,
"agent_id": identity.agent_id,
"tenant_id": identity.tenant_id,
"message_id": message_id,
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
},
),
)
await sse_hub.emit(agent_session_id, "message.received", {"session_id": agent_session_id, "role": "user"}) if emit_sse else None
history = [m.model_dump(mode="json") for m in await memory.list(agent_session_id)]
cms_input = {
"channel": req.channel,
"tenant_id": req.tenant_id,
"agent_id": req.agent_id,
"payload": payload,
}
trace_context = {
"text": effective_text,
"channel": msg.channel,
"channel_id": msg.channel_id,
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"conversation_key": agent_session_id,
"workflow_id": workflow_id,
"message_id": message_id,
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
}
root_span_name = _format_root_span_name(
getattr(settings, "LANGFUSE_ROOT_SPAN_NAME", "agent.gateway_message"),
{
"workflow_id": workflow_id,
"channel": msg.channel,
"agent_id": identity.agent_id,
"tenant_id": identity.tenant_id,
},
)
root_tags = ["agent-template", msg.channel, f"agent:{identity.agent_id}", f"tenant:{identity.tenant_id}"]
if workflow_id:
root_tags.append(f"workflow:{workflow_id}")
async with telemetry.span(
root_span_name,
session_id=agent_session_id,
user_id=session.user_id,
channel=msg.channel,
workflow_id=workflow_id,
input=cms_input,
tags=root_tags,
_root_span=True,
) as root_span:
await telemetry.event("gateway.message.received", trace_context)
await sse_hub.emit(agent_session_id, "workflow.started", trace_context) if emit_sse else None
result = await workflow.ainvoke(
{
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"session_id": agent_session_id,
"conversation_key": agent_session_id,
"workflow_id": workflow_id,
"agent_profile": normalized_context["agent_profile"],
# Chave estável de LTM. Nunca use session_id como identidade de longo prazo.
"long_term_memory_subject_key": business_context.customer_key or session.user_id,
"customer_key": business_context.customer_key,
"user_id": session.user_id,
"business_context": business_context.model_dump(),
"user_text": effective_text,
"history": history,
"context": {
**normalized_context,
"session": session.model_dump(mode="json"),
"original_session_id": msg.session_id,
"session_id": agent_session_id,
"conversation_key": agent_session_id,
"workflow_id": workflow_id,
"user_id": session.user_id,
"channel": msg.channel,
"message_id": message_id,
"business_context": business_context.model_dump(),
"business_keys": business_context.to_context_dict(),
"identity_missing": missing_identity_keys,
},
}
)
await checkpoints.put(agent_session_id, {"state": result, "message_id": message_id})
await sse_hub.emit(agent_session_id, "workflow.completed", {"session_id": agent_session_id, "route": result.get("route"), "intent": result.get("intent")}) if emit_sse else None
answer = result.get("final_answer") or result.get("answer") or ""
await memory.append(
agent_session_id,
ChatMessage(
role="assistant",
content=answer,
metadata={
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"message_id": f"assistant-{message_id}",
"route": result.get("route"),
"intent": result.get("intent"),
"route_decision": result.get("route_decision"),
"judges": result.get("judge_results"),
},
),
)
terminal_status = str(result.get("terminal_status") or "").strip()
session_ended = bool(result.get("session_ended")) or bool(terminal_status)
session.metadata = {
**(session.metadata or {}),
"last_assistant_text": answer,
"last_assistant_is_interruptible": bool(result.get("is_interruptible", True)),
"last_route": result.get("route"),
"last_intent": result.get("intent"),
"conversation_closed": session_ended,
"terminal_status": terminal_status or ("resolvido" if session_ended else ""),
"terminal_replay_text": answer if session_ended else "",
}
await sessions.upsert(session)
await telemetry.event(
"gateway.message.responded",
{
"session_id": agent_session_id,
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"route": result.get("route"),
"intent": result.get("intent"),
"answer_chars": len(answer),
},
)
response = ChannelResponse(
channel=msg.channel,
session_id=agent_session_id,
text=answer,
metadata={
"channel_id": msg.channel_id,
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"original_session_id": msg.session_id,
"conversation_key": agent_session_id,
"workflow_id": workflow_id,
"message_id": message_id,
"route": result.get("route"),
"intent": result.get("intent"),
"route_decision": result.get("route_decision"),
"domain": result.get("domain"),
"mcp_tools": result.get("mcp_tools"),
"mcp_results": result.get("mcp_results"),
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
"judges": result.get("judge_results"),
"guardrails": result.get("guardrail_decisions"),
"long_term_memory": {
"subject_key": business_context.customer_key or session.user_id,
"loaded": result.get("long_term_memories", []),
"context": result.get("long_term_memory_context", ""),
"load_error": result.get("long_term_memory_load_error"),
"write_result": result.get("long_term_memory_write_result", {}),
},
},
)
rendered = await gateway.render(response)
root_span.set_output(rendered)
await sse_hub.emit(agent_session_id, "message.responded", rendered) if emit_sse else None
await sse_hub.emit(agent_session_id, "flow.end", {"session_id": agent_session_id, "message_id": message_id}) if emit_sse else None
return rendered
@app.get("/health")
async def health():
return {
"status": "ok",
"llm_provider": settings.LLM_PROVIDER,
"llm_class": llm.__class__.__name__,
"langfuse_enabled": telemetry.is_enabled(),
"agents": [p.agent_id for p in agent_profiles.list_profiles()],
"default_agent_id": agent_profiles.default_agent_id,
"routing_mode": settings.ROUTING_MODE,
"sse_enabled": settings.ENABLE_SSE,
"session_repository": settings.SESSION_REPOSITORY_PROVIDER,
"memory_repository": settings.MEMORY_REPOSITORY_PROVIDER,
"long_term_memory": {
"enabled": getattr(settings, "ENABLE_LONG_TERM_MEMORY", False),
"provider": getattr(settings, "LONG_TERM_MEMORY_PROVIDER", None),
"sqlite_path": getattr(settings, "LONG_TERM_MEMORY_SQLITE_PATH", None),
"table": getattr(settings, "LONG_TERM_MEMORY_TABLE", None),
"auto_extract": getattr(settings, "LONG_TERM_MEMORY_AUTO_EXTRACT", None),
"inject_context": getattr(settings, "LONG_TERM_MEMORY_INJECT_CONTEXT", None),
},
"checkpoint_repository": settings.CHECKPOINT_REPOSITORY_PROVIDER,
"usage_repository": settings.USAGE_REPOSITORY_PROVIDER,
"identity_config_path": settings.IDENTITY_CONFIG_PATH,
"mcp_parameter_mapping_path": settings.MCP_PARAMETER_MAPPING_PATH,
"framework_channel_input_mode": settings.FRAMEWORK_CHANNEL_INPUT_MODE,
"legacy_channel_gateway_mode": settings.CHANNEL_GATEWAY_MODE,
}
@app.get("/agents")
async def list_agents():
return {"default_agent_id": agent_profiles.default_agent_id, "agents": [p.__dict__ for p in agent_profiles.list_profiles()]}
@app.get("/debug/env")
async def debug_env():
return {
"APP_ENV": settings.APP_ENV,
"LLM_PROVIDER": settings.LLM_PROVIDER,
"ENABLE_LANGFUSE": settings.ENABLE_LANGFUSE,
"LANGFUSE_HOST": settings.LANGFUSE_HOST,
"TELEMETRY_ENABLED": telemetry.is_enabled(),
"SQLITE_DB_PATH": settings.SQLITE_DB_PATH,
"SESSION_REPOSITORY_PROVIDER": settings.SESSION_REPOSITORY_PROVIDER,
"MEMORY_REPOSITORY_PROVIDER": settings.MEMORY_REPOSITORY_PROVIDER,
"CHECKPOINT_REPOSITORY_PROVIDER": settings.CHECKPOINT_REPOSITORY_PROVIDER,
"AGENTS_CONFIG_PATH": settings.AGENTS_CONFIG_PATH,
"ROUTING_CONFIG_PATH": settings.ROUTING_CONFIG_PATH,
"ROUTING_MODE": settings.ROUTING_MODE,
"FRAMEWORK_CHANNEL_INPUT_MODE": settings.FRAMEWORK_CHANNEL_INPUT_MODE,
"CHANNEL_GATEWAY_MODE": settings.CHANNEL_GATEWAY_MODE,
}
@app.get("/test-llm")
async def test_llm():
async with telemetry.span("debug.test_llm", input={"message": "Diga apenas OK"}):
answer = await llm.ainvoke([
{"role": "system", "content": "Responda de forma curta."},
{"role": "user", "content": "Diga apenas OK"},
])
telemetry.flush()
return {"provider": llm.__class__.__name__, "answer": answer}
@app.post("/debug/route")
async def debug_route(req: GatewayRequest):
msg = await gateway.normalize(req.channel, req.payload)
identity, context, business_context, missing_identity_keys = _resolve_identity(req, msg)
state = {
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"session_id": msg.session_id or "debug-session",
"conversation_key": identity.conversation_key(),
"agent_profile": context["agent_profile"],
"user_text": msg.text,
"sanitized_input": msg.text,
"history": [],
"context": {**context, "session": context.get("session", {}), "channel": msg.channel, "business_context": business_context.model_dump()},
}
if settings.ROUTING_MODE == "supervisor":
plan = await workflow.supervisor.route_plan(state)
return {"mode": "supervisor", "route": "supervisor_agent", "agents": plan.agents, "intent": plan.intent, "confidence": plan.confidence, "reason": plan.reason, "metadata": plan.metadata}
decision = await workflow.router.route(state)
data = decision.model_dump(mode="json")
data["mode"] = "router"
return data
@app.post("/debug/identity")
async def debug_identity(req: GatewayRequest):
msg = await gateway.normalize(req.channel, req.payload)
identity, context, business_context, missing_identity_keys = _resolve_identity(req, msg)
return {
"technical_identity": {
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"conversation_key": identity.conversation_key(),
"original_session_id": msg.session_id,
},
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
"context_keys": sorted(context.keys()),
}
@app.get("/debug/usage")
async def debug_usage(tenant_id: str | None = None, session_id: str | None = None):
return await usage_repository.summarize(tenant_id=tenant_id, session_id=session_id)
@app.get("/debug/mcp/tools")
async def debug_mcp_tools():
return {"enabled": tool_router.enabled, "tools": tool_router.describe_tools()}
@app.post("/debug/mcp/call/{tool_name}")
async def debug_mcp_call(tool_name: str, arguments: dict | None = None):
arguments = arguments or {}
ctx = arguments.get("business_context") or arguments.get("identity") or {}
result = await tool_router.call(
tool_name,
arguments,
business_context=ctx,
original_context=arguments,
)
return result.model_dump(mode="json")
@app.post("/gateway/message")
async def gateway_message(req: GatewayRequest):
return await _process_gateway_message(req, emit_sse=False)
@app.post("/gateway/message/sse")
async def gateway_message_sse(req: GatewayRequest):
return await _process_gateway_message(req, emit_sse=True)
@app.get("/gateway/events/{session_id}")
async def gateway_events(session_id: str, request: Request):
last = request.headers.get("last-event-id") or request.query_params.get("last_event_id") or "0"
return StreamingResponse(
sse_hub.subscribe(session_id, int(last)),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
)
@app.get("/sessions/{session_id}/messages")
async def get_session_messages(session_id: str, limit: int = 50):
return {"session_id": session_id, "messages": [m.model_dump(mode="json") for m in await memory.list(session_id, limit)]}
@app.get("/sessions/{session_id}/checkpoint")
async def get_session_checkpoint(session_id: str):
return {"session_id": session_id, "checkpoint": await checkpoints.get_latest(session_id)}
@app.on_event("shutdown")
async def shutdown():
telemetry.shutdown()

View File

@@ -0,0 +1,16 @@
from __future__ import annotations
import os
from agent_framework.gateways import MCPGatewayClient
def build_mcp_gateway_client() -> MCPGatewayClient | None:
if os.getenv("MCP_GATEWAY_ENABLED", "true").lower() != "true":
return None
return MCPGatewayClient(
base_url=os.getenv("MCP_GATEWAY_URL", "http://localhost:8300"),
token=os.getenv("MCP_GATEWAY_TOKEN") or None,
timeout_seconds=int(os.getenv("MCP_GATEWAY_TIMEOUT_SECONDS", "60")),
)

View File

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
"""Observer adapter that emits IC/NOC/GRL through framework Telemetry only.
This avoids a second Langfuse root trace created by AgentObserver ->
AnalyticsPublisher while preserving the events inside the active request span.
"""
from datetime import datetime, timezone
from typing import Any
def _normalize_ic_code(code: str) -> str:
code = str(code or "UNKNOWN").strip()
return code if code.startswith(("IC.", "AGA.", "NOC.", "GRL.")) else f"IC.{code}"
def _normalize_noc_code(code: str) -> str:
code = str(code or "UNKNOWN").strip()
return code if code.startswith("NOC.") else f"NOC.{code}"
def _normalize_grl_code(code: str) -> str:
code = str(code or "UNKNOWN").strip()
return code if code.startswith("GRL.") else f"GRL.{code}"
def _kind_for(event_type: str) -> str:
if event_type.startswith(("IC.", "AGA.")):
return "ic"
if event_type.startswith("NOC."):
return "noc"
if event_type.startswith("GRL."):
return "grl"
return "event"
class TelemetryBackedAgentObserver:
"""Drop-in subset of AgentObserver backed by Telemetry.event.
Do not publish through AnalyticsPublisher here. Analytics publishing may be
configured with a Langfuse provider, and that path creates an extra root
trace for business events such as IC.AGENT_COMPLETED/NOC.006. Telemetry.event
uses the active span/trace context, so these events appear inside the single
request trace.
"""
def __init__(self, telemetry: Any, *, source: str = "agent_framework") -> None:
self.telemetry = telemetry
self.source = source
async def emit(
self,
event_type: str,
payload: dict[str, Any] | None = None,
*,
metadata: dict[str, Any] | None = None,
source: str | None = None,
) -> dict[str, Any]:
body = dict(payload or {})
meta = dict(metadata or {})
body.setdefault("tag", event_type)
event = {
"eventType": event_type,
"source": source or self.source,
"eventDate": datetime.now(timezone.utc).isoformat(),
"body": body,
"metadata": meta,
}
try:
await self.telemetry.event(event_type, event, kind=_kind_for(event_type))
except TypeError:
# Compatibility with older Telemetry.event signatures.
await self.telemetry.event(event_type, event)
return event
async def emit_ic(self, code: str, payload: dict[str, Any] | None = None, **metadata: Any) -> dict[str, Any]:
return await self.emit(_normalize_ic_code(code), payload, metadata={**metadata, "ic": True})
async def emit_noc(self, code: str, payload: dict[str, Any] | None = None, **metadata: Any) -> dict[str, Any]:
return await self.emit(_normalize_noc_code(code), payload, metadata={**metadata, "noc": True})
async def emit_grl(self, code: str, payload: dict[str, Any] | None = None, **metadata: Any) -> dict[str, Any]:
return await self.emit(_normalize_grl_code(code), payload, metadata={**metadata, "grl": True})

53
app/state.py Normal file
View File

@@ -0,0 +1,53 @@
from typing import Any, TypedDict
class AgentState(TypedDict, total=False):
tenant_id: str
agent_id: str
session_id: str
conversation_key: str
workflow_id: str
agent_profile: dict[str, Any]
user_text: str
sanitized_input: str
route: str
intent: str
route_decision: dict[str, Any]
answer: str
final_answer: str
history: list[dict[str, Any]]
context: dict[str, Any]
guardrail_decisions: list[dict[str, Any]]
judge_results: list[dict[str, Any]]
next_state: str
domain: str
mcp_tools: list[str]
mcp_results: list[dict[str, Any]]
available_mcp_tools: list[str]
selected_tool_call: dict[str, Any]
pending_tool_call: dict[str, Any]
transaction_status: str
confirmation_required: bool
confirmation_received: bool
tool_policy_result: dict[str, Any]
missing_parameters: list[str]
supervisor_plan: dict[str, Any]
supervisor_results: list[dict[str, Any]]
active_agent: str
route_bypassed: bool
continuity_signal: dict[str, Any]
session_control: str
session_ended: bool
human_handoff_requested: bool
blocked: bool
supervisor_action: str
supervisor_guidance: str
supervisor_attempt: int
supervisor_handover_reason: str
output_supervisor_results: list[dict[str, Any]]
output_guardrails_already_applied: bool
long_term_memories: list[dict[str, Any]]
long_term_memory_context: str
long_term_memory_write_result: dict[str, Any]
long_term_memory_subject_key: str
long_term_memory_load_error: str

View File

@@ -0,0 +1,963 @@
from agent_framework.checkpoints.langgraph_saver import create_langgraph_checkpointer
from agent_framework.workflows import END, START, FrameworkStateGraph
from agent_framework.guardrails.pipeline import GuardrailPipeline
from agent_framework.guardrails.output_supervisor import OutputSupervisor
from agent_framework.guardrails.rail_action import RailAction
from agent_framework.guardrails.rail_result import RailResult
from agent_framework.judges.judge import JudgePipeline
from agent_framework.routing.enterprise_router import EnterpriseRouter
from agent_framework.supervisor.supervisor import Supervisor
from agent_framework.observability.workflow_events import WorkflowTelemetry
from agent_framework.observability.guardrail_events import GuardrailTelemetry
from agent_framework.observability.judge_events import JudgeTelemetry
from agent_framework.observability.langgraph_telemetry import LangGraphDeepTelemetry
from agent_framework.observability.observer import AgentObserver
from app.agents.faturas_agent import FaturasAgent
from app.agents.vas_agent import VasAgent
from app.agents.contestacao_agent import ContestacaoAgent
from app.agents.suporte_contas_agent import SuporteContasAgent
from app.state import AgentState
from agent_framework.rag.rag_service import RagService
from agent_framework.rag.embedding_provider import create_embedding_provider
from agent_framework.cache.cache import create_cache
from agent_framework.memory.long_term_memory import create_long_term_memory_manager
class FrameworkOutputGuardrailRail:
"""Adapter: reutiliza GuardrailPipeline.run_output dentro do OutputSupervisor novo.
O framework antigo retornava decisões allowed=True/False. O OutputSupervisor
corporativo trabalha com RailAction (allow/sanitize/retry/block/handover).
Este adapter evita reescrever todos os rails agora e mantém compatibilidade.
"""
code = "LEGACY_OUTPUT_GUARDRAILS"
def __init__(self, pipeline: GuardrailPipeline):
self.pipeline = pipeline
async def evaluate(self, candidate: str, context: dict):
final, decisions = await self.pipeline.run_output(candidate, context)
serialized = [d.model_dump() for d in decisions]
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
if blocked:
first = blocked[0]
code = (getattr(first, "code", "") or "").upper()
action = RailAction.RETRY if code in {"REVPREC", "CMP", "SCO", "GND"} else RailAction.BLOCK
return RailResult(
code=code or self.code,
action=action,
reason=getattr(first, "reason", "Resposta bloqueada por guardrail de saída"),
guidance=getattr(first, "reason", "Regerar resposta seguindo as políticas de saída."),
sanitized_text=final,
metadata={"framework_decisions": serialized},
)
if final != candidate:
return RailResult(
code=self.code,
action=RailAction.SANITIZE,
reason="Resposta sanitizada por guardrail de saída do framework.",
sanitized_text=final,
metadata={"framework_decisions": serialized},
)
return RailResult(
code=self.code,
action=RailAction.ALLOW,
reason="Resposta aprovada pelos guardrails de saída do framework.",
sanitized_text=final,
metadata={"framework_decisions": serialized},
)
class AgentWorkflow:
"""Workflow principal com dois modos de roteamento.
Modos suportados por configuração:
ROUTING_MODE=router
input_guardrails -> routing_decision/EnterpriseRouter -> 1 agente -> output_guardrails
ROUTING_MODE=supervisor
input_guardrails -> routing_decision/Supervisor -> supervisor_agent -> N agentes -> consolidação
Em ambos os modos, memória/checkpoint/session usam tenant_id:agent_id:session_id.
"""
def __init__(self, llm, memory, telemetry, analytics, settings, observer: AgentObserver | None = None, tool_router=None, summary_memory=None):
self.llm = llm
self.memory = memory
self.telemetry = telemetry
self.analytics = analytics
self.observer = observer or AgentObserver(analytics=analytics)
self.settings = settings
self.tool_router = tool_router
self.summary_memory = summary_memory
self.long_term_memory_manager = create_long_term_memory_manager(settings, telemetry=telemetry)
self.guardrails = GuardrailPipeline(
observer=self.observer,
llm=llm,
enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
)
self.output_supervisor_engine = OutputSupervisor(
rails=[FrameworkOutputGuardrailRail(self.guardrails)],
observer=self.observer,
max_retries=int(getattr(settings, "OUTPUT_SUPERVISOR_MAX_RETRIES", 3)),
enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
)
self.judges = JudgePipeline()
self.supervisor = Supervisor()
self.workflow_telemetry = WorkflowTelemetry(telemetry)
self.guardrail_telemetry = GuardrailTelemetry(telemetry)
self.judge_telemetry = JudgeTelemetry(telemetry)
self.langgraph_telemetry = LangGraphDeepTelemetry(telemetry)
self.cache = create_cache(settings)
self.embedding_provider = create_embedding_provider(settings)
self.rag_service = RagService(settings, embedding_provider=self.embedding_provider, telemetry=telemetry)
self.router = EnterpriseRouter(settings, llm=llm, telemetry=telemetry)
agent_kwargs = {
"telemetry": telemetry,
"tool_router": getattr(self, "tool_router", None),
"rag_service": self.rag_service,
"cache": self.cache,
"settings": settings,
"observer": self.observer,
"memory": memory,
"summary_memory": summary_memory,
"guardrail_pipeline": self.guardrails,
}
self.faturas = FaturasAgent(llm, **agent_kwargs)
self.vas = VasAgent(llm, **agent_kwargs)
self.contestacao = ContestacaoAgent(llm, **agent_kwargs)
self.suporte_contas = SuporteContasAgent(llm, **agent_kwargs)
# Long-term memory is injected as a runtime capability after creation.
for agent in (self.faturas, self.vas, self.contestacao, self.suporte_contas):
agent.long_term_memory_manager = self.long_term_memory_manager
self.graph = self._build_graph()
@staticmethod
def _output_guardrail_context(state: dict) -> dict:
"""Enriquece contexto de guardrail com evidência operacional real.
CMP/ANATEL precisa dos protocolos produzidos pelas tools; GND/ALUC precisam
enxergar evidências MCP. O domínio não implementa rails, apenas devolve dados.
"""
ctx = dict(state.get("context", {}) or {})
mcp_results = state.get("mcp_results") or []
ctx["evidence"] = mcp_results or ctx.get("evidence")
ctx["tool_result"] = mcp_results or ctx.get("tool_result")
ctx["tool_executed"] = any(isinstance(r, dict) and r.get("ok") for r in mcp_results)
protocols: list[str] = []
seen: set[str] = set()
protocol_keys = {"protocol_number", "protocolo_id", "interactionProtocol", "protocolNumber", "finalizacao_protocol"}
def walk(value):
if isinstance(value, dict):
for key, item in value.items():
if key in protocol_keys and item not in (None, ""):
text = str(item).strip()
if text and text not in seen:
seen.add(text)
protocols.append(text)
elif isinstance(item, (dict, list, tuple)):
walk(item)
elif isinstance(value, (list, tuple)):
for item in value:
walk(item)
walk(mcp_results)
if protocols:
ctx["expected_protocols"] = protocols
ctx["requer_protocolo"] = True
ctx.setdefault("tipo_fluxo", "ajuste")
return ctx
def _node(self, name, fn):
async def _wrapped(state):
async with self.langgraph_telemetry.node(name, state):
return await fn(state)
return _wrapped
def _build_graph(self):
builder = FrameworkStateGraph(AgentState)
builder.add_node("input_guardrails", self._node("input_guardrails", self.input_guardrails))
builder.add_node("load_long_term_memory", self._node("load_long_term_memory", self.load_long_term_memory))
builder.add_node("routing_decision", self._node("routing_decision", self.routing_decision))
builder.add_node("faturas_agent", self._node("faturas_agent", self.faturas_agent))
builder.add_node("vas_agent", self._node("vas_agent", self.vas_agent))
builder.add_node("contestacao_agent", self._node("contestacao_agent", self.contestacao_agent))
builder.add_node("suporte_contas_agent", self._node("suporte_contas_agent", self.suporte_contas_agent))
builder.add_node("handoff", self._node("handoff", self.handoff))
builder.add_node("human_handoff", self._node("human_handoff", self.human_handoff))
builder.add_node("end_session", self._node("end_session", self.end_session))
builder.add_node("supervisor_agent", self._node("supervisor_agent", self.supervisor_agent))
builder.add_node("output_supervisor", self._node("output_supervisor", self.output_supervisor))
builder.add_node("output_guardrails", self._node("output_guardrails", self.output_guardrails))
builder.add_node("judge", self._node("judge", self.judge))
builder.add_node("supervisor_review", self._node("supervisor_review", self.supervisor_review))
builder.add_node("persist_long_term_memory", self._node("persist_long_term_memory", self.persist_long_term_memory))
builder.add_node("persist", self._node("persist", self.persist))
builder.add_edge(START, "input_guardrails")
builder.add_conditional_edges(
"input_guardrails",
self._after_input_guardrails,
{"blocked": "persist", "continue": "load_long_term_memory"},
)
builder.add_edge("load_long_term_memory", "routing_decision")
builder.add_conditional_edges(
"routing_decision",
lambda s: s.get("route", "faturas_agent"),
{
"faturas_agent": "faturas_agent",
"vas_agent": "vas_agent",
"contestacao_agent": "contestacao_agent",
"suporte_contas_agent": "suporte_contas_agent",
"handoff": "handoff",
"human_handoff": "human_handoff",
"end_session": "end_session",
"supervisor_agent": "supervisor_agent",
},
)
builder.add_edge("faturas_agent", "output_supervisor")
builder.add_edge("vas_agent", "output_supervisor")
builder.add_edge("contestacao_agent", "output_supervisor")
builder.add_edge("suporte_contas_agent", "output_supervisor")
builder.add_edge("handoff", "output_supervisor")
builder.add_edge("human_handoff", "output_supervisor")
builder.add_edge("end_session", "output_supervisor")
builder.add_edge("supervisor_agent", "output_supervisor")
builder.add_edge("output_supervisor", "output_guardrails")
builder.add_edge("output_guardrails", "judge")
builder.add_edge("judge", "supervisor_review")
builder.add_edge("supervisor_review", "persist_long_term_memory")
builder.add_edge("persist_long_term_memory", "persist")
builder.add_edge("persist", END)
return builder.compile(checkpointer=create_langgraph_checkpointer(self.settings))
def _after_input_guardrails(self, state):
return "blocked" if state.get("blocked") else "continue"
async def input_guardrails(self, state):
if state.get("session_ended") is True:
answer = str(getattr(
self.settings,
"SESSION_ALREADY_ENDED_MESSAGE",
"Este atendimento já foi encerrado. Inicie uma nova sessão para continuar.",
))
await self.telemetry.event(
"session.message.rejected_after_end",
{"session_id": state.get("conversation_key") or state.get("session_id")},
)
return {
"answer": answer,
"final_answer": answer,
"blocked": True,
"session_control": "END_SESSION",
"session_ended": True,
"next_state": "SESSION_ENDED",
}
async with self.telemetry.span(
"workflow.input_guardrails",
session_id=state.get("conversation_key") or state.get("session_id"),
input=state.get("user_text"),
):
history_texts = [m.get("content", "") for m in state.get("history", [])]
await self.observer.emit_grl(
"001",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "input",
},
component="workflow.input_guardrails.start",
)
sanitized, decisions = await self.guardrails.run_input(
state["user_text"],
{
**(state.get("context") or {}),
"history_texts": history_texts,
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"agent_profile": state.get("agent_profile") or {},
},
)
for _decision in decisions:
await self.guardrail_telemetry.evaluated("input", _decision)
await self.observer.emit_grl(
"002" if _decision.allowed else "004",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "input",
"rail_code": getattr(_decision, "code", None),
"allowed": bool(_decision.allowed),
"reason": getattr(_decision, "reason", None),
},
component="workflow.input_guardrails.decision",
)
if not _decision.allowed:
await self.guardrail_telemetry.blocked("input", _decision)
await self.telemetry.event(
"guardrails.input.completed",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"decisions": [d.model_dump() for d in decisions],
},
)
await self.observer.emit_grl(
"009",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "input",
"blocked": any(not d.allowed for d in decisions),
"decision_count": len(decisions),
},
component="workflow.input_guardrails.final",
)
if any(not d.allowed for d in decisions):
return {
"sanitized_input": sanitized,
"answer": "Não consegui seguir com essa mensagem por regra de segurança.",
"final_answer": "Não consegui seguir com essa mensagem por regra de segurança.",
"guardrail_decisions": [d.model_dump() for d in decisions],
"route": "blocked",
"blocked": True,
}
return {
"sanitized_input": sanitized,
"guardrail_decisions": [d.model_dump() for d in decisions],
"blocked": False,
}
async def routing_decision(self, state):
mode = getattr(self.settings, "ROUTING_MODE", "router")
async with self.telemetry.span(
"workflow.routing_decision",
session_id=state.get("conversation_key") or state.get("session_id"),
input={
"mode": mode,
"text": state.get("sanitized_input") or state.get("user_text"),
"previous_state": state.get("next_state"),
},
):
if mode == "supervisor":
plan = await self.supervisor.route_plan(state)
await self.langgraph_telemetry.edge("routing_decision", "supervisor_agent", state, {"method": "supervisor", "intent": plan.intent, "confidence": plan.confidence})
return {
"route": "supervisor_agent",
"intent": plan.intent,
"supervisor_plan": {
"agents": plan.agents,
"intent": plan.intent,
"confidence": plan.confidence,
"reason": plan.reason,
"metadata": plan.metadata,
},
"route_decision": {
"route": "supervisor_agent",
"agent": "supervisor",
"intent": plan.intent,
"confidence": plan.confidence,
"reason": plan.reason,
"method": "supervisor",
"metadata": plan.metadata,
},
}
decision = await self.router.route(state)
await self.langgraph_telemetry.edge("routing_decision", decision.route, state, {"method": getattr(decision, "method", None), "intent": decision.intent, "confidence": decision.confidence})
await self.observer.emit_ic(
"ROUTE_SELECTED",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": decision.route,
"intent": decision.intent,
"confidence": decision.confidence,
"method": getattr(decision, "method", None),
},
component="workflow.routing_decision",
)
return {
"route": decision.route,
"intent": decision.intent,
"route_decision": decision.model_dump(mode="json"),
"domain": decision.domain,
"mcp_tools": decision.mcp_tools,
"next_state": decision.next_state,
"active_agent": decision.agent,
"route_bypassed": decision.method == "continuity",
"session_control": (decision.metadata or {}).get("session_control", ""),
"human_handoff_requested": (decision.metadata or {}).get("session_control") == "HUMAN_HANDOFF",
"session_ended": (decision.metadata or {}).get("session_control") == "END_SESSION",
"continuity_signal": {
"decision": (decision.metadata or {}).get("continuity_decision"),
"confidence": decision.confidence if decision.method == "continuity" else None,
"reason": decision.reason if decision.method == "continuity" else None,
"profile": (decision.metadata or {}).get("continuity_profile"),
} if decision.method == "continuity" else {},
}
async def faturas_agent(self, state):
async with self.telemetry.span(
"workflow.agent.billing",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"intent": state.get("intent")},
):
return await self.faturas.run(state)
async def vas_agent(self, state):
async with self.telemetry.span(
"workflow.agent.product",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"intent": state.get("intent")},
):
return await self.vas.run(state)
async def contestacao_agent(self, state):
async with self.telemetry.span(
"workflow.agent.orders",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"intent": state.get("intent")},
):
return await self.contestacao.run(state)
async def suporte_contas_agent(self, state):
async with self.telemetry.span(
"workflow.agent.support",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"intent": state.get("intent")},
):
return await self.suporte_contas.run(state)
async def supervisor_agent(self, state):
"""Executa um ou mais agentes no modo supervisor e consolida a resposta.
Este nó mantém o desenho de supervisor sem obrigar o restante do workflow
a conhecer quantos agentes foram acionados. Cada execução especializada
recebe o mesmo estado, mas com route/active_agent atualizados.
"""
plan = state.get("supervisor_plan") or {}
agents = plan.get("agents") or ["faturas_agent"]
handlers = {
"faturas_agent": self.faturas.run,
"vas_agent": self.vas.run,
"contestacao_agent": self.contestacao.run,
"suporte_contas_agent": self.suporte_contas.run,
}
partials = []
mcp_results = []
async with self.telemetry.span(
"workflow.supervisor_agent",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"agents": agents, "intent": state.get("intent")},
):
for agent_name in agents:
handler = handlers.get(agent_name)
if handler is None:
continue
child_state = {**state, "route": agent_name, "active_agent": agent_name}
result = await handler(child_state)
partials.append({"agent": agent_name, "answer": result.get("answer", "")})
mcp_results.extend(result.get("mcp_results") or [])
if len(partials) == 1:
answer = partials[0]["answer"]
else:
joined = "\n\n".join(f"{p['agent']}: {p['answer']}" for p in partials)
answer = (
"[Supervisor] Consolidação de múltiplos agentes acionados.\n"
f"{joined}"
)
return {
"answer": answer,
"supervisor_results": partials,
"mcp_results": mcp_results,
"next_state": "SUPERVISOR_ACTIVE",
}
async def handoff(self, state):
async with self.telemetry.span("workflow.handoff", session_id=state.get("session_id")):
target = (state.get("route_decision") or {}).get("metadata", {}).get("target_agent")
answer = (
"Vou redirecionar sua solicitação para o especialista correto. "
f"Destino sugerido: {target or 'agente especializado'}."
)
return {"answer": answer}
async def human_handoff(self, state):
session_id = state.get("conversation_key") or state.get("session_id")
async with self.telemetry.span("workflow.human_handoff", session_id=session_id):
try:
if self.tool_router:
runtime_context = (state.get("context") or {}).get("business_context") or {}
await self.tool_router.call(
"finalizar_atendimento",
{"status": "nao_resolvido", "summary": "Handoff humano solicitado pelo agent_framework_oci", "confirmed": True},
business_context=runtime_context,
original_context=state.get("context") or {},
)
except Exception:
pass
answer = str(getattr(self.settings, "HUMAN_HANDOFF_MESSAGE", "Vou encaminhar seu atendimento para uma pessoa."))
await self.telemetry.event(
"session.human_handoff.requested",
{
"session_id": session_id,
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"reason": (state.get("route_decision") or {}).get("reason"),
},
)
return {
"answer": answer,
"session_control": "HUMAN_HANDOFF",
"human_handoff_requested": True,
"session_ended": True,
"terminal_status": "nao_resolvido",
"next_state": "HUMAN_HANDOFF_REQUESTED",
}
async def end_session(self, state):
session_id = state.get("conversation_key") or state.get("session_id")
async with self.telemetry.span("workflow.end_session", session_id=session_id):
# Preserva efeitos colaterais de negócio do Contas (protocolos/ICs)
# sem devolver a orquestração ao runtime do framework. Falha aqui não impede
# o encerramento controlado pelo framework.
try:
if self.tool_router:
runtime_context = (state.get("context") or {}).get("business_context") or {}
await self.tool_router.call(
"finalizar_atendimento",
{"status": "resolvido", "summary": "Encerramento solicitado pelo agent_framework_oci", "confirmed": True},
business_context=runtime_context,
original_context=state.get("context") or {},
)
except Exception:
pass
answer = str(getattr(self.settings, "END_SESSION_MESSAGE", "Atendimento encerrado. Obrigado pelo contato."))
await self.telemetry.event(
"session.end.requested",
{
"session_id": session_id,
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"reason": (state.get("route_decision") or {}).get("reason"),
},
)
return {
"answer": answer,
"session_control": "END_SESSION",
"session_ended": True,
"terminal_status": "resolvido",
"human_handoff_requested": False,
"next_state": "SESSION_ENDED",
}
async def output_supervisor(self, state):
"""Valida a resposta candidata com o OutputSupervisor corporativo.
Este nó não substitui o roteador/supervisor multiagente. Ele roda após o
agente gerar `answer` e antes dos judges/persistência, produzindo campos
supervisor_* no state e eventos GRL.001..GRL.009 via AgentObserver.
"""
if not bool(getattr(self.settings, "ENABLE_OUTPUT_SUPERVISOR", True)):
return {
"output_guardrails_already_applied": False,
"supervisor_action": "disabled",
"supervisor_attempt": int(state.get("supervisor_attempt", 0)),
}
candidate = state.get("answer") or ""
context = {
**self._output_guardrail_context(state),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"session_id": state.get("conversation_key") or state.get("session_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"supervisor_attempt": int(state.get("supervisor_attempt", 0)),
}
async with self.telemetry.span(
"workflow.output_supervisor",
session_id=state.get("conversation_key") or state.get("session_id"),
input=candidate,
):
decision = await self.output_supervisor_engine.evaluate(candidate, context)
action = decision.action.value
await self.telemetry.event(
"output_supervisor.completed",
{
"session_id": context["session_id"],
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"action": action,
"approved": decision.approved,
"guidance": decision.guidance,
},
)
await self.observer.emit_ic(
"IC.OUTPUT_SUPERVISOR_COMPLETED",
{
"session_id": context["session_id"],
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"action": action,
"approved": decision.approved,
"result_count": len(decision.results),
},
component="workflow.output_supervisor",
)
if decision.action in {RailAction.ALLOW, RailAction.SANITIZE, RailAction.OBSERVE}:
final_answer = decision.candidate
elif decision.action == RailAction.HANDOVER:
final_answer = "Vou encaminhar seu atendimento para continuidade com um especialista."
else:
final_answer = decision.fallback_message
return {
"answer": final_answer,
"final_answer": final_answer,
"supervisor_action": action,
"supervisor_guidance": decision.guidance,
"supervisor_attempt": int(state.get("supervisor_attempt", 0)) + (1 if decision.action == RailAction.RETRY else 0),
"supervisor_handover_reason": decision.handover_reason,
"output_supervisor_results": [
{
"code": r.code,
"action": r.action.value,
"reason": r.reason,
"guidance": r.guidance,
"metadata": r.metadata,
}
for r in decision.results
],
"output_guardrails_already_applied": True,
"guardrail_decisions": state.get("guardrail_decisions", [])
+ [item for r in decision.results for item in (r.metadata or {}).get("framework_decisions", [])],
}
async def output_guardrails(self, state):
if state.get("output_guardrails_already_applied"):
return {"final_answer": state.get("final_answer") or state.get("answer") or ""}
async with self.telemetry.span(
"workflow.output_guardrails",
session_id=state.get("conversation_key") or state.get("session_id"),
input=state.get("answer"),
):
await self.observer.emit_grl(
"001",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "output",
"route": state.get("route"),
"intent": state.get("intent"),
},
component="workflow.output_guardrails.start",
)
guardrail_context = self._output_guardrail_context(state)
final, decisions = await self.guardrails.run_output(
state["answer"], guardrail_context
)
for _decision in decisions:
await self.guardrail_telemetry.evaluated("output", _decision)
await self.observer.emit_grl(
"002" if _decision.allowed else "004",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "output",
"rail_code": getattr(_decision, "code", None),
"allowed": bool(_decision.allowed),
"reason": getattr(_decision, "reason", None),
},
component="workflow.output_guardrails.decision",
)
if not _decision.allowed:
await self.guardrail_telemetry.blocked("output", _decision)
await self.telemetry.event(
"guardrails.output.completed",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"decisions": [d.model_dump() for d in decisions],
},
)
await self.observer.emit_grl(
"009",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "output",
"blocked": any(not d.allowed for d in decisions),
"decision_count": len(decisions),
},
component="workflow.output_guardrails.final",
)
return {
"final_answer": final,
"guardrail_decisions": state.get("guardrail_decisions", [])
+ [d.model_dump() for d in decisions],
}
async def judge(self, state):
async with self.telemetry.span(
"workflow.judge",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"question": state.get("user_text"), "answer": state.get("final_answer")},
):
judge_context = dict(state.get("context", {}) or {})
judge_context["mcp_results"] = state.get("mcp_results", [])
judge_context["evidence"] = state.get("mcp_results", []) or judge_context.get("evidence")
judge_context["route"] = state.get("route")
judge_context["intent"] = state.get("intent")
# Judge sampling must see the finalized transaction state. These
# fields are populated by the agent/tool runtime before this node.
for key in (
"transaction_status",
"confirmation_required",
"confirmation_received",
"tool_policy_result",
"selected_tool_call",
"pending_tool_call",
):
judge_context[key] = state.get(key)
judge_context["transactional_tools"] = [
result.get("tool_name")
for result in state.get("mcp_results", [])
if isinstance(result, dict)
and (
(result.get("metadata") or {}).get("operation_type") == "transactional"
or result.get("awaiting_confirmation")
or result.get("transaction_status")
)
]
results = await self.judges.evaluate_all(
state["user_text"], state["final_answer"], judge_context
)
for _result in results:
await self.judge_telemetry.evaluated(_result)
await self.telemetry.event(
"judges.completed",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"results": [r.model_dump() for r in results],
},
)
return {"judge_results": [r.model_dump() for r in results]}
async def supervisor_review(self, state):
async with self.telemetry.span(
"workflow.supervisor_review",
session_id=state.get("conversation_key") or state.get("session_id"),
input=state.get("final_answer"),
):
ok, answer = await self.supervisor.review(
state["final_answer"], state.get("context", {})
)
await self.telemetry.event(
"supervisor.review.completed",
{"session_id": state.get("session_id"), "approved": ok},
)
return {"final_answer": answer if ok else answer}
async def load_long_term_memory(self, state):
"""Carrega LTM antes do roteamento e mantém o resultado no estado.
A carga explícita evita depender apenas do agente selecionado para realizar
a recuperação e facilita o diagnóstico de identidade/namespace.
"""
try:
memories = await self.long_term_memory_manager.load(state)
serialized = []
context_lines = []
for item in memories or []:
if hasattr(item, "model_dump"):
data = item.model_dump(mode="json")
elif hasattr(item, "__dict__"):
data = dict(item.__dict__)
elif isinstance(item, dict):
data = dict(item)
else:
data = {"value": str(item)}
serialized.append(data)
key = data.get("key") or data.get("memory_key") or data.get("category") or "memory"
value = data.get("value") or data.get("memory_value")
if value not in (None, ""):
context_lines.append(f"- {key}: {value}")
return {
"long_term_memories": serialized,
"long_term_memory_context": "\n".join(context_lines),
}
except Exception as exc:
await self.telemetry.event(
"long_term_memory.load.failed",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"subject_key": state.get("long_term_memory_subject_key"),
"error": str(exc),
},
)
return {
"long_term_memories": [],
"long_term_memory_context": "",
"long_term_memory_load_error": str(exc),
}
async def persist_long_term_memory(self, state):
try:
result = await self.long_term_memory_manager.persist_turn(state)
await self.telemetry.event(
"long_term_memory.persist.completed",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"subject_key": state.get("long_term_memory_subject_key"),
"result": result,
},
)
return {"long_term_memory_write_result": result}
except Exception as exc:
await self.telemetry.event(
"long_term_memory.persist.failed",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"subject_key": state.get("long_term_memory_subject_key"),
"error": str(exc),
},
)
return {"long_term_memory_write_result": {"saved": 0, "error": str(exc)}}
async def persist(self, state):
async with self.telemetry.span(
"workflow.persist",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"route": state.get("route"), "intent": state.get("intent")},
):
await self.observer.emit_ic(
"AGENT_COMPLETED",
{
"session_id": state.get("conversation_key") or state["session_id"],
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"route_decision": state.get("route_decision"),
"judges": state.get("judge_results", []),
"mcp_tools": state.get("mcp_tools", []),
"mcp_results": state.get("mcp_results", []),
},
)
await self.observer.emit_noc(
"006",
{
"session_id": state.get("conversation_key") or state["session_id"],
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"answer_chars": len(state.get("final_answer") or ""),
},
component="workflow.persist",
)
await self.telemetry.event(
"agent.completed",
{
"session_id": state.get("conversation_key") or state["session_id"],
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"answer_chars": len(state.get("final_answer") or ""),
},
)
return state
async def ainvoke(self, state):
thread_id = state.get("conversation_key") or state["session_id"]
config = {"configurable": {"thread_id": thread_id}}
async with self.telemetry.span(
"workflow.langgraph.ainvoke",
session_id=state.get("conversation_key") or state.get("session_id"),
user_id=state.get("context", {}).get("user_id"),
input={"user_text": state.get("user_text")},
tags=["langgraph", "agent-workflow", f"routing-mode:{getattr(self.settings, 'ROUTING_MODE', 'router')}",],
):
await self.workflow_telemetry.started("agent_workflow", state)
await self.observer.emit_noc(
"001",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"channel_id": (state.get("context") or {}).get("channel"),
"message_id": (state.get("context") or {}).get("message_id"),
"ura_call_id": (state.get("context") or {}).get("ura_call_id"),
},
component="workflow.ainvoke",
)
await self.observer.emit_ic(
"AGENT_STARTED",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"channel_id": (state.get("context") or {}).get("channel"),
"message_id": (state.get("context") or {}).get("message_id"),
"user_text_chars": len(state.get("user_text") or ""),
},
component="workflow.ainvoke",
)
try:
result = await self.graph.ainvoke(state, config=config)
await self.workflow_telemetry.completed("agent_workflow", result)
return result
except Exception as exc:
await self.workflow_telemetry.failed("agent_workflow", exc)
await self.observer.emit_noc(
"005",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"error": str(exc),
"exception_type": exc.__class__.__name__,
},
component="workflow.ainvoke",
)
raise