Ajustes no agente de contas

This commit is contained in:
2026-08-31 14:13:51 -03:00
parent 00ac7f0c83
commit 87a0b77dae
137 changed files with 715 additions and 8 deletions

Binary file not shown.

View File

@@ -0,0 +1,277 @@
from __future__ import annotations
import os
import re
from dataclasses import dataclass
from typing import Any, Iterable
from .invoice_resolver import InvoiceResolver
from .vas_variation import varied_avulso_items
_REGULATORY_RE = re.compile(r"\b(anatel|procon|justi[cç]a|judicial|advogad[oa]|meus direitos|processar)\b", re.I)
_HUMAN_RE = re.compile(r"\b(atendente|humano|pessoa|operador|supervisor)\b", re.I)
_YES_RE = re.compile(r"^\s*(sim|s|pode|pode sim|isso|isso mesmo|quero|claro|ok|confirmo|vamos)\b", re.I)
_NO_RE = re.compile(r"^\s*(n[aã]o|nao|n|negativo|deixa|deixe|prefiro n[aã]o|quero atendente|atendente)\b", re.I)
_CLOSE_RE = re.compile(r"\b(era s[oó] isso|s[oó] isso mesmo|obrigad[oa].*era isso|entendi.*obrigad[oa]|pode encerrar|pode finalizar)\b", re.I)
_PLURAL_CONTINUE_RE = re.compile(r"\b(as duas|os dois|ambas|ambos)\b.*\b(pode|seguir|isso|sim|mesmo)\b", re.I)
@dataclass(frozen=True)
class PolicyDecision:
route: str | None = None
intent: str | None = None
answer: str | None = None
reason: str | None = None
patch: dict[str, Any] | None = None
def _walk_dicts(value: Any) -> Iterable[dict[str, Any]]:
if isinstance(value, dict):
yield value
for child in value.values():
yield from _walk_dicts(child)
elif isinstance(value, list):
for child in value:
yield from _walk_dicts(child)
def _first_dict_value(state: dict[str, Any], keys: tuple[str, ...]) -> dict[str, Any] | None:
for obj in _walk_dicts(state):
for key in keys:
value = obj.get(key)
if isinstance(value, dict) and value:
return value
return None
def _explicit_answer(text: str) -> str | None:
raw = str(text or "").strip()
if _YES_RE.search(raw):
return "YES"
if _NO_RE.search(raw):
return "NO"
return None
def _previous_assistant_intent(state: dict[str, Any]) -> str:
for item in reversed(state.get("history") or []):
if not isinstance(item, dict) or str(item.get("role") or "") != "assistant":
continue
metadata = item.get("metadata") if isinstance(item.get("metadata"), dict) else {}
direct = str(metadata.get("intent") or "").strip()
if direct:
return direct
decision = metadata.get("route_decision")
if isinstance(decision, dict):
return str(decision.get("intent") or "").strip()
return ""
def _has_live_transaction(state: dict[str, Any]) -> bool:
status = str(state.get("transaction_status") or "").upper()
if status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION", "WORKFLOW_PAUSED", "TOOL_RESULT_CLARIFICATION"}:
return True
return bool(state.get("active_transaction") or state.get("pending_tool_call") or state.get("pending_domain_workflow"))
def is_regulatory_threat(text: str) -> bool:
return bool(_REGULATORY_RE.search(str(text or "")))
def _focused_subjects(state: dict[str, Any]) -> list[str]:
out: list[str] = []
candidates = [
state.get("active_transaction"), state.get("last_transaction"),
state.get("selected_tool_call"), state.get("pending_tool_call"),
(state.get("context") or {}).get("focused_items") if isinstance(state.get("context"), dict) else None,
]
for candidate in candidates:
if isinstance(candidate, list):
for item in candidate:
if isinstance(item, str) and item.strip():
out.append(item.strip())
continue
if not isinstance(candidate, dict):
continue
args = candidate.get("arguments") if isinstance(candidate.get("arguments"), dict) else candidate
for key in ("subject", "item", "service", "name"):
value = args.get(key) if isinstance(args, dict) else None
if isinstance(value, str) and value.strip():
out.append(value.strip())
values = args.get("items") if isinstance(args, dict) else None
if isinstance(values, list):
for item in values:
if isinstance(item, dict):
value = item.get("name") or item.get("desc") or item.get("subject")
if value:
out.append(str(value).strip())
return list(dict.fromkeys(x for x in out if x))
def _retention_items(state: dict[str, Any]) -> list[dict[str, Any]]:
invoice_detail = _first_dict_value(state, ("invoice_detail", "invoiceDetail"))
if not invoice_detail:
return []
invoice_variation = _first_dict_value(state, ("invoice_variation", "invoiceVariation", "billing_analysis"))
try:
items = varied_avulso_items(
invoice_variation=invoice_variation,
invoice_detail=invoice_detail,
resolver=InvoiceResolver(),
by_charge=True,
)
except Exception:
return []
if not items:
return []
return [
{
"name": item.canonical_name,
"msisdn": item.msisdn,
"value": str(item.value) if item.value is not None else None,
"charge_date": item.charge_date,
}
for item in items if item.msisdn and item.canonical_name
]
def evaluate(state: dict[str, Any]) -> PolicyDecision | None:
"""Contas-only conversational policy executed after generic routing.
It never performs a side effect. It may only (a) answer/reprompt, (b) enrich
routing, or (c) prepare explicit domain context consumed by normal framework
transaction machinery.
"""
text = str(state.get("sanitized_input") or state.get("user_text") or "").strip()
route_decision = state.get("route_decision") if isinstance(state.get("route_decision"), dict) else {}
method = str(route_decision.get("method") or "")
intent = str(state.get("intent") or route_decision.get("intent") or "")
# 1) Consecutive incomprehensible utterances. A router fallback is the generic,
# architecture-native signal that no intent was understood. Any understood turn
# resets the counter. Default requirement is three consecutive failures.
previous_count = int(state.get("no_match_count") or 0)
if intent == "contas_no_match":
count = previous_count + 1
limit = max(1, int(os.getenv("CONTAS_NO_MATCH_MAX_CONSECUTIVE", "3")))
if count >= limit:
return PolicyDecision(
route="end_session", intent="contas_no_match_terminal",
reason="no_match_limit_reached",
patch={"no_match_count": count, "terminal_status": "erro_no_match",
"conversation_terminal_message": "Não consegui compreender sua solicitação após três tentativas. Vou encerrar este atendimento para evitar uma ação incorreta."},
)
return PolicyDecision(
route="conversation_policy_response", intent="contas_no_match_retry",
answer="Desculpe, não entendi. Poderia repetir de outra forma?",
reason="no_match_retry", patch={"no_match_count": count},
)
reset_patch: dict[str, Any] = {"no_match_count": 0} if previous_count else {}
# Explicit conversational closure is a lifecycle signal, not a new business
# intent. Never consume it while a transaction/workflow is still live.
if _CLOSE_RE.search(text) and not _has_live_transaction(state):
return PolicyDecision(
route="end_session", intent="contas_conversation_close",
reason="explicit_customer_closure",
patch={**reset_patch, "terminal_status": "resolvido",
"conversation_terminal_message": "Atendimento encerrado. Obrigado pelo contato."},
)
# A short plural continuation after an invoice explanation belongs to that
# explanation; it must not be mistaken for generic finalization merely because
# it contains an affirmative such as "pode seguir". The invoice agent keeps
# the prior evidence/context and can explain the two referenced charges.
previous_intent = _previous_assistant_intent(state)
if (
previous_intent == "contas_invoice_explanation"
and _PLURAL_CONTINUE_RE.search(text)
and not _has_live_transaction(state)
):
return PolicyDecision(
route="faturas_agent", intent="contas_invoice_explanation",
reason="invoice_plural_context_continuation",
patch={**reset_patch, "mcp_tools": ["invoice_explanation"],
"_route_metadata": {"contextual_reentry": True, "original_input": text}},
)
# 2) Regulatory/legal wording is not itself an action. Only preserve action
# semantics when an entity is already concretely in focus in transaction state.
if is_regulatory_threat(text):
focused = _focused_subjects(state)
if focused:
subject = focused[0]
return PolicyDecision(
route="contestacao_agent", intent="contas_vas_cancel",
reason="regulatory_threat_with_focused_entity",
patch={**reset_patch, "regulatory_context": {"threat": True, "focused_items": focused},
"mcp_tools": ["consultar_vas", "cancelar_vas_avulso"],
"context": {**(state.get("context") or {}), "focused_items": focused, "regulatory_threat": True}},
)
# Broad complaint: never invent an item/action from the invoice.
return PolicyDecision(
route="suporte_contas_agent", intent="contas_regulatory_complaint",
reason="regulatory_threat_without_focused_entity",
patch={**reset_patch, "regulatory_context": {"threat": True, "focused_items": []}, "mcp_tools": []},
)
# 3) Two-step human-retention policy. It is only eligible when the router would
# hand off AND authoritative invoice evidence proves a varied cancellable VAS.
retention = state.get("contas_retention") if isinstance(state.get("contas_retention"), dict) else {}
pending = str(retention.get("pending_stage") or "")
offered = list(retention.get("stages_offered") or [])
retained_items = list(retention.get("items") or [])
if pending:
answer_kind = _explicit_answer(text)
if pending == "explain":
if answer_kind == "YES":
return PolicyDecision(
route="faturas_agent", intent="contas_invoice_explanation",
reason="human_retention_explanation_accepted",
patch={**reset_patch, "mcp_tools": ["invoice_explanation"],
"contas_retention": {"pending_stage": None, "stages_offered": offered, "items": retained_items}},
)
if answer_kind == "NO" or _HUMAN_RE.search(text):
names = ", ".join(str(x.get("name")) for x in retained_items if isinstance(x, dict) and x.get("name"))
return PolicyDecision(
route="conversation_policy_response", intent="contas_retention_continue",
answer=(f"Antes de transferir, posso tratar o cancelamento dos serviços que participaram da variação da conta: {names}. Deseja que eu prossiga?" if names else "Antes de transferir, posso tratar os serviços que participaram da variação da conta. Deseja que eu prossiga?"),
reason="human_retention_offer_continue",
patch={**reset_patch, "contas_retention": {"pending_stage": "continue", "stages_offered": list(dict.fromkeys([*offered, "continue"])), "items": retained_items}},
)
elif pending == "continue":
if answer_kind == "YES":
names = [str(x.get("name")) for x in retained_items if isinstance(x, dict) and x.get("name")]
context = {**(state.get("context") or {}), "retention_items": retained_items, "focused_items": names}
# Route through the normal transactional agent. No side effect here.
semantic_request = "cancelar " + " e ".join(names) if names else text
return PolicyDecision(
route="contestacao_agent", intent="contas_vas_cancel",
reason="human_retention_cancel_accepted",
patch={**reset_patch, "mcp_tools": ["consultar_vas", "cancelar_vas_avulso"], "context": context,
"_route_metadata": {"contextual_reentry": True, "original_input": semantic_request,
"relevant_conversation_context": "Serviços avulsos comprovados na variação: " + ", ".join(names)},
"contas_retention": {"pending_stage": None, "stages_offered": offered, "items": retained_items}},
)
if answer_kind == "NO" or _HUMAN_RE.search(text):
return PolicyDecision(
route="human_handoff", intent="human_handoff", reason="human_retention_declined",
patch={**reset_patch, "contas_retention": {"pending_stage": None, "stages_offered": offered, "items": retained_items}},
)
if state.get("route") == "human_handoff" and "explain" not in offered:
items = _retention_items(state)
if items:
return PolicyDecision(
route="conversation_policy_response", intent="contas_retention_explain",
answer="Antes de transferir, identifiquei serviços avulsos que participaram da variação da sua conta. Posso primeiro explicar essa variação para você?",
reason="human_retention_offer_explain",
patch={**reset_patch, "human_handoff_requested": False, "session_control": "",
"contas_retention": {"pending_stage": "explain", "stages_offered": ["explain"], "items": items}},
)
if reset_patch:
return PolicyDecision(patch=reset_patch)
return None

View File

@@ -62,3 +62,9 @@ class AgentState(TypedDict, total=False):
long_term_memory_load_error: str
operational_context_boundary_pending: bool
operational_context_reset: bool
no_match_count: int
contas_retention: dict[str, Any]
regulatory_context: dict[str, Any]
terminal_status: str
conversation_terminal_message: str

View File

@@ -7,6 +7,7 @@ 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 app.domain.contas.conversation_policy import evaluate as evaluate_contas_conversation_policy
from agent_framework.supervisor.supervisor import Supervisor
from agent_framework.observability.workflow_events import WorkflowTelemetry
from agent_framework.observability.guardrail_events import GuardrailTelemetry
@@ -292,6 +293,8 @@ class AgentWorkflow:
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("conversation_policy", self._node("conversation_policy", self.conversation_policy))
builder.add_node("conversation_policy_response", self._node("conversation_policy_response", self.conversation_policy_response))
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))
@@ -316,8 +319,9 @@ class AgentWorkflow:
{"blocked": "output_guardrails", "continue": "load_long_term_memory"},
)
builder.add_edge("load_long_term_memory", "routing_decision")
builder.add_edge("routing_decision", "conversation_policy")
builder.add_conditional_edges(
"routing_decision",
"conversation_policy",
lambda s: s.get("route", "faturas_agent"),
{
"faturas_agent": "faturas_agent",
@@ -328,6 +332,7 @@ class AgentWorkflow:
"human_handoff": "human_handoff",
"end_session": "end_session",
"supervisor_agent": "supervisor_agent",
"conversation_policy_response": "conversation_policy_response",
},
)
builder.add_edge("faturas_agent", "output_supervisor")
@@ -338,6 +343,7 @@ class AgentWorkflow:
builder.add_edge("human_handoff", "output_supervisor")
builder.add_edge("end_session", "output_supervisor")
builder.add_edge("supervisor_agent", "output_supervisor")
builder.add_edge("conversation_policy_response", "output_supervisor")
builder.add_edge("output_supervisor", "output_guardrails")
builder.add_conditional_edges(
"output_guardrails",
@@ -676,6 +682,26 @@ class AgentWorkflow:
} if decision.method == "continuity" else {},
}
async def conversation_policy(self, state):
decision = evaluate_contas_conversation_policy(state)
if decision is None:
return {}
patch = dict(decision.patch or {})
route_metadata_patch = patch.pop("_route_metadata", {}) if isinstance(patch.get("_route_metadata", {}), dict) else {}
if decision.route:
patch["route"] = decision.route
if decision.intent:
patch["intent"] = decision.intent
if decision.answer is not None:
patch["answer"] = decision.answer
if decision.reason:
current = state.get("route_decision") if isinstance(state.get("route_decision"), dict) else {}
patch["route_decision"] = {**current, "route": patch.get("route", state.get("route")), "intent": patch.get("intent", state.get("intent")), "reason": decision.reason, "metadata": {**(current.get("metadata") or {}), **route_metadata_patch, "contas_conversation_policy": decision.reason}}
return patch
async def conversation_policy_response(self, state):
return {"answer": str(state.get("answer") or ""), "next_state": state.get("next_state") or "CONVERSATION_POLICY"}
async def faturas_agent(self, state):
async with self.telemetry.span(
"workflow.agent.billing",
@@ -820,13 +846,13 @@ class AgentWorkflow:
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},
{"status": str(state.get("terminal_status") or "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."))
answer = str(state.get("conversation_terminal_message") or getattr(self.settings, "END_SESSION_MESSAGE", "Atendimento encerrado. Obrigado pelo contato."))
await self.telemetry.event(
"session.end.requested",
{
@@ -840,7 +866,7 @@ class AgentWorkflow:
"answer": answer,
"session_control": "END_SESSION",
"session_ended": True,
"terminal_status": "resolvido",
"terminal_status": str(state.get("terminal_status") or "resolvido"),
"human_handoff_requested": False,
"next_state": "SESSION_ENDED",
}

View File

@@ -50,6 +50,17 @@ state_policies:
- state: COLLECTING_SUPORTE_CONTAS_PARAMETERS
agent: suporte_contas_agent
intents:
- name: contas_no_match
domain: telecom_contas
agent: suporte_contas_agent
priority: 200
description: Fala realmente incompreensível, sem conteúdo semântico recuperável ou transcrição corrompida. Use somente quando não for possível entender nenhuma solicitação. Não use para assunto fora de escopo, reclamação, pedido de humano, jurídico/Anatel/Procon ou frase compreensível que apenas não corresponda a outra intent.
mcp_tools: []
keywords: []
examples:
- "asdkj qweoi zxcm"
- "hã trrr blá qx"
- "[fala sem conteúdo inteligível]"
- name: contas_plan_information
domain: telecom_contas
agent: faturas_agent

View File

@@ -111,9 +111,10 @@ tools:
args_schema:
subject:
type: string
description: Referência a um serviço, produto ou benefício concreto e identificável nas evidências VAS/fatura do cliente.
Categorias ou referências genéricas não identificam uma entidade por si só; se o texto não permitir resolver univocamente
para um item real, mantenha subject ausente/null.
description: Referência a um ou mais serviços, produtos ou benefícios concretos e identificáveis nas evidências VAS/fatura do cliente.
Preserve referências múltiplas e anafóricas quando o contexto imediato as resolver (por exemplo, "os dois", "ambos",
"Netflix e HBO", "todos os VAS avulsos"). Categorias genéricas sem foco conversacional não identificam entidade; se o texto
e o contexto não permitirem resolução segura para um item real nas evidências autorizadas, mantenha subject ausente/null.
user_prompt: Qual serviço você deseja cancelar?
selection_keywords:
- cancelar serviço

View File

@@ -244,6 +244,19 @@ def _preflight_subject(name: str, args: dict[str, Any]) -> dict[str, Any] | None
return None
if bool(args.get("clarification_resolved")):
return None
# A pre-validation transaction boundary may already have resolved a multi-item
# cancellation into authoritative ``items[]``. At execution time that list is
# the source of truth; re-parsing the presentation ``subject`` (for example
# ``"Tamboro Mensal, Paramount+"``) would incorrectly turn two canonical
# entities back into one ambiguous free-text reference.
if name == "cancelar_vas_avulso":
resolved_items = [
item for item in (args.get("items") or [])
if isinstance(item, dict) and str(item.get("name") or item.get("subject") or "").strip()
]
if len(resolved_items) >= 2:
return None
msisdn = str(args.get("msisdn") or "").strip()
subject = str(args.get("subject") or "").strip()
if not msisdn or not subject:
@@ -986,6 +999,49 @@ async def _validate_contestation(args: dict[str, Any]) -> dict[str, Any]:
"entity_resolution": "invoice_evidence",
},
}
# A claimed amount larger/different from the authoritative billed amount is
# recoverable input, not a reason to emit a generic safety failure. Keep the
# subject frozen and recollect only ``valor``, while telling the customer the
# concrete amount found in the invoice evidence.
amount_mismatch = next((
entry for entry in (validation_log or [])
if isinstance(entry, dict) and str(entry.get("erro") or "") in {
"valor_ajuste_maior_que_item", "valor_ajuste_divergente_item"
}
), None)
if amount_mismatch is not None:
billed = amount_mismatch.get("valor_item_fatura")
canonical_subject = str(
amount_mismatch.get("item_fatura_resolvido")
or args.get("resolved_subject") or args.get("subject") or "item"
).strip()
billed_ptbr = _money_ptbr(billed) if billed not in (None, "") else ""
message = (
f"O valor encontrado na fatura para {canonical_subject} é R$ {billed_ptbr}. "
f"O valor informado (R$ {_money_ptbr(requested_value)}) não corresponde a essa cobrança. "
"Informe o valor da cobrança que deseja contestar."
if billed_ptbr else
"O valor informado não corresponde à cobrança encontrada na fatura. Informe o valor correto da cobrança que deseja contestar."
)
return {
"eligible": False,
"status": "NEEDS_PARAMETER",
"parameter": "valor",
"reason": "CVAL",
"recoverable_reason": "amount_not_supported_by_invoice",
"subject": canonical_subject,
"resolved_value": billed,
"parameter_message": message,
"validation_log": validation_log,
"items": validated,
"metadata": {
"side_effect_free": True,
"target_tool": args.get("target_tool") or "contestar_cobranca",
"guardrail_code": "CVAL",
"entity_resolution": "invoice_evidence",
},
}
return {
"eligible": False,
"status": "BLOCKED",
@@ -1145,14 +1201,79 @@ def _vas_domain_policy_from_invoice_detail(canonical: str, args: dict[str, Any])
return "", item_type
def _resolve_multiple_vas_subjects(subject: str, catalog: list[dict[str, Any]], args: dict[str, Any]) -> list[dict[str, Any]]:
"""Resolve explicit multi-entity cancellation references against authorized evidence.
Named references are split into conversational segments and each segment must
resolve uniquely through the same catalog resolver used for single entities.
Explicit mass expansion is restricted to "todos os VAS/serviços avulsos";
generic "todos" is deliberately not expanded.
"""
raw = str(subject or "").strip()
norm = _norm_entity_reference(raw)
if not norm:
return []
explicit_all_avulso = any(token in norm for token in (
"todos os vas avulsos", "todos vas avulsos", "todos os servicos avulsos",
"todos servicos avulsos", "todas as assinaturas avulsas",
))
selected_names: list[str] = []
if explicit_all_avulso:
selected_names = [str(row.get("name") or "").strip() for row in catalog if row.get("name")]
else:
cleaned = re.sub(r"(?i)\b(cancelar|cancela|cancele|retirar|retire|tirar|tire|desativar|desative)\b", " ", raw)
segments = [seg.strip(" .:-") for seg in re.split(r"\s*(?:,|;|/|\be\b|\bmais\b)\s*", cleaned, flags=re.I) if seg.strip(" .:-")]
if len(segments) < 2:
return []
for segment in segments:
canonical, _ = _resolve_catalog_entity(segment, catalog)
if canonical:
selected_names.append(canonical)
selected: list[dict[str, Any]] = []
for name in list(dict.fromkeys(selected_names)):
resolved_class, resolved_type = _vas_domain_policy_from_invoice_detail(name, args)
if explicit_all_avulso and resolved_class != "cancelar_vas_avulso":
continue
selected.append({
"name": name,
"msisdn": _invoice_detail_msisdn(args.get("invoice_detail"), name) or str(args.get("msisdn") or ""),
"tool_category": resolved_class or None,
"item_type": resolved_type or None,
})
return selected
async def _validate_vas_subject(args: dict[str, Any]) -> dict[str, Any]:
"""Side-effect-free entity resolution used before confirmation/execution."""
await _enrich_invoice_context("validar_vas_subject", args)
subject = str(args.get("subject") or "").strip()
catalog = _vas_entity_catalog(args)
canonical, matches = _resolve_catalog_entity(subject, catalog)
requested_tool = str(args.get("target_tool") or "").strip()
multi = _resolve_multiple_vas_subjects(subject, catalog, args) if requested_tool == "cancelar_vas_avulso" else []
if len(multi) >= 2 or (multi and any(token in _norm_entity_reference(subject) for token in ("todos os vas avulsos", "todos os servicos avulsos"))):
names = [str(item.get("name") or "").strip() for item in multi if item.get("name")]
return {
"eligible": True,
"status": "ELIGIBLE",
"subject": subject,
"resolved_subject": names[0] if len(names) == 1 else ", ".join(names),
"resolved_subjects": names,
"entity_resolution": "vas_evidence_multiple",
"transaction_decision": {
"resolved_arguments": {"subject": ", ".join(names), "items": multi},
"target_tool": requested_tool,
"action_changed": False,
"requires_reconfirmation": False,
"confirmation_message": (
f"Você confirma o cancelamento dos serviços {', '.join(names)}? "
"Responda 'sim' para executar ou 'não' para cancelar."
),
"domain_policy": {"class": "cancelar_vas_avulso", "item_type": "multiple"},
},
"metadata": {"side_effect_free": True, "target_tool": requested_tool, "catalog_size": len(catalog), "resolved_count": len(names)},
}
if canonical:
requested_tool = str(args.get("target_tool") or "").strip()
effective_tool = requested_tool
resolved_class = ""
resolved_type = ""

View File

@@ -0,0 +1,27 @@
# Correções após regressão de 31/08/2026
Esta rodada corrige quatro comportamentos observados no relatório de regressão sem reintroduzir o runtime conversacional legado.
## 1. Cancelamento múltiplo após confirmação
O snapshot transacional do framework já preservava corretamente os argumentos confirmados. O defeito estava no preflight de execução do MCP do Contas, que tentava resolver novamente o `subject` de apresentação (por exemplo, `Tamboro Mensal, Paramount+`) mesmo quando `items[]` já continha múltiplas entidades canônicas pré-validadas. Agora `items[]` é a fonte de verdade nessa condição e não há segunda resolução textual.
## 2. Contestação com valor incompatível
Uma divergência de valor comprovada pelo CVAL passa a ser recuperável: o item permanece preservado, apenas `valor` volta para coleta e a resposta informa o valor autoritativo encontrado na fatura. O contrato de auditoria mantém `reason=CVAL` e acrescenta `recoverable_reason=amount_not_supported_by_invoice`.
O runtime genérico ganhou suporte opcional a `parameter_message` emitido por um pre-validator de domínio. O framework apenas apresenta essa mensagem enquanto permanece em `COLLECTING_PARAMETERS`; ele não interpreta a regra de negócio.
## 3. Encerramento explícito
Expressões inequívocas como `entendi, obrigado, era só isso` encerram a sessão como `resolvido`, desde que não exista transação ou workflow ativo. Isso evita que uma despedida caia em fallback/guardrail sem consumir confirmações pendentes.
## 4. Continuação plural após explicação de fatura
Frases como `as duas mesmo, pode seguir` imediatamente após `contas_invoice_explanation` permanecem no contexto de explicação e não são confundidas com finalização genérica.
## Regressão
- Testes novos e direcionados: 58/58 no Contas e 30/30 no runtime transacional do framework.
- `tests/migration`: 818 PASS / 2 FAIL.
- Os 2 FAIL restantes são preexistentes nesta base: caso histórico de `validar_contestacao` com TIM Fashion/R$50 e fraseologia de `termino_desconto`.

View File

@@ -0,0 +1,63 @@
# Correção de paridade conversacional residual do Contas — 2026-08-31
## Escopo
Correções pontuais extraídas do comportamento útil do Contas anterior sem restaurar o runtime legado:
- retenção antes de handoff humano;
- jurídico/Anatel/Procon com dependência de entidade previamente em foco;
- três falas consecutivas realmente incompreensíveis;
- preservação do pós-finalização no framework, com status terminal de erro respeitado;
- cancelamento múltiplo por entidades nomeadas/contextuais e por "todos os VAS avulsos".
## Arquitetura
Foi adicionada `app/domain/contas/conversation_policy.py`, executada depois do `EnterpriseRouter` e antes do agente de domínio. A policy não executa side effects: apenas reprompta, enriquece contexto ou altera o roteamento. Toda operação transacional continua passando pelo `AgentRuntimeMixin`, pré-validação MCP, confirmação explícita e workflow do Contas.
## Retenção
Quando o router solicita handoff humano, a policy procura evidência autoritativa de VAS avulso que participou da variação da conta usando `varied_avulso_items`. Sem evidência, o handoff segue normalmente. Com evidência, a policy oferece dois degraus: explicação da variação e, em seguida, tratamento dos VAS identificados. Recusa do segundo degrau leva ao handoff humano normal.
O aceite do tratamento cria reentrada contextual com os nomes já comprovados; o cliente não precisa repeti-los e a transação continua sujeita à pré-validação e confirmação.
## Jurídico / Anatel / Procon
A mera ameaça regulatória não cria uma transação. Sem entidade concreta em foco, a fala é encaminhada como reclamação ampla ao suporte, sem MCP transacional. Quando já existe `subject/items` em estado transacional, o foco é preservado e a fala pode continuar no fluxo correspondente. A fatura inteira nunca é usada para inventar o alvo.
## Três falas incompreensíveis
Foi criada a intent semântica `contas_no_match`, exclusiva para fala sem conteúdo recuperável. `fallback` genérico não conta como incompreensão. O contador é consecutivo e reinicia em qualquer turno compreendido.
- 1ª: pede reformulação;
- 2ª: pede reformulação;
- 3ª: encerra pelo nó global `end_session`, chamando `finalizar_atendimento` com `status=erro_no_match`.
O limite pode ser configurado por `CONTAS_NO_MATCH_MAX_CONSECUTIVE`, default 3.
## Cancelamento múltiplo
`validar_vas_subject` agora resolve múltiplas entidades exclusivamente contra catálogo autorizado VAS/fatura. Exemplos suportados após extração semântica contextual:
- `cancela Netflix e HBO`;
- `os dois` / `ambos`, quando o extrator LLM consegue resolver os nomes pelo contexto imediato;
- `todos os VAS avulsos`.
`todos` genérico não expande em massa. Para "todos os VAS avulsos", itens estratégicos/bundle são filtrados pela política de domínio. Os itens resolvidos são enviados como `items[]` para o workflow batch existente, com uma única confirmação explícita antes da execução.
## Pós-finalização
Não foi criado runtime duplicado. O lifecycle continua no framework. O nó `end_session` passou apenas a respeitar um `terminal_status` já definido pela policy (por exemplo `erro_no_match`) e uma mensagem terminal específica, mantendo o mecanismo atual de replay/soft reset.
## Testes
Novos testes:
- `tests/migration/test_contas_conversation_policy_residuals.py`;
- `tests/migration/test_multiple_vas_subject_resolution.py`.
Regressão direcionada: **61 PASS**.
Regressão completa `tests/migration`: **810 PASS / 2 FAIL**. Os mesmos dois FAIL foram reproduzidos no ZIP original sem estas alterações, portanto são falhas preexistentes e fora do escopo desta correção:
1. `test_validar_contestacao_aprova_quando_item_e_valor_sao_comprovados`;
2. `test_termino_desconto_e_valor_divergente_preservam_semantica_do_original`.

Some files were not shown because too many files have changed in this diff Show More