Ajustes no agente de contas
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
277
app/domain/contas/conversation_policy.py
Normal file
277
app/domain/contas/conversation_policy.py
Normal 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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
if canonical:
|
||||
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:
|
||||
effective_tool = requested_tool
|
||||
resolved_class = ""
|
||||
resolved_type = ""
|
||||
|
||||
27
docs/FIX_CONTAS_REPORT_20260831_SECOND_PASS.md
Normal file
27
docs/FIX_CONTAS_REPORT_20260831_SECOND_PASS.md
Normal 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`.
|
||||
63
docs/FIX_CONTAS_RESIDUAL_CONVERSATION_PARITY_20260831.md
Normal file
63
docs/FIX_CONTAS_RESIDUAL_CONVERSATION_PARITY_20260831.md
Normal 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`.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user