diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc index 562654f4..53182555 100644 Binary files a/app/__pycache__/__init__.cpython-313.pyc and b/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc index a672e90d..36960016 100644 Binary files a/app/__pycache__/main.cpython-313.pyc and b/app/__pycache__/main.cpython-313.pyc differ diff --git a/app/__pycache__/state.cpython-313.pyc b/app/__pycache__/state.cpython-313.pyc index d717a3c0..402794e2 100644 Binary files a/app/__pycache__/state.cpython-313.pyc and b/app/__pycache__/state.cpython-313.pyc differ diff --git a/app/agents/__pycache__/contas_prompting.cpython-313.pyc b/app/agents/__pycache__/contas_prompting.cpython-313.pyc index 636a7811..f9af64fe 100644 Binary files a/app/agents/__pycache__/contas_prompting.cpython-313.pyc and b/app/agents/__pycache__/contas_prompting.cpython-313.pyc differ diff --git a/app/agents/__pycache__/contestacao_agent.cpython-313.pyc b/app/agents/__pycache__/contestacao_agent.cpython-313.pyc index 2e941367..ab4790f8 100644 Binary files a/app/agents/__pycache__/contestacao_agent.cpython-313.pyc and b/app/agents/__pycache__/contestacao_agent.cpython-313.pyc differ diff --git a/app/agents/__pycache__/faturas_agent.cpython-313.pyc b/app/agents/__pycache__/faturas_agent.cpython-313.pyc index e3329ed6..80d6da8d 100644 Binary files a/app/agents/__pycache__/faturas_agent.cpython-313.pyc and b/app/agents/__pycache__/faturas_agent.cpython-313.pyc differ diff --git a/app/agents/__pycache__/prompting.cpython-313.pyc b/app/agents/__pycache__/prompting.cpython-313.pyc index d5f57c0d..86f64985 100644 Binary files a/app/agents/__pycache__/prompting.cpython-313.pyc and b/app/agents/__pycache__/prompting.cpython-313.pyc differ diff --git a/app/agents/__pycache__/runtime.cpython-313.pyc b/app/agents/__pycache__/runtime.cpython-313.pyc index ba22c1b4..7a6d2768 100644 Binary files a/app/agents/__pycache__/runtime.cpython-313.pyc and b/app/agents/__pycache__/runtime.cpython-313.pyc differ diff --git a/app/agents/__pycache__/suporte_contas_agent.cpython-313.pyc b/app/agents/__pycache__/suporte_contas_agent.cpython-313.pyc index 7bad7664..c641fb9f 100644 Binary files a/app/agents/__pycache__/suporte_contas_agent.cpython-313.pyc and b/app/agents/__pycache__/suporte_contas_agent.cpython-313.pyc differ diff --git a/app/agents/__pycache__/vas_agent.cpython-313.pyc b/app/agents/__pycache__/vas_agent.cpython-313.pyc index 01344c2a..cbe1f48d 100644 Binary files a/app/agents/__pycache__/vas_agent.cpython-313.pyc and b/app/agents/__pycache__/vas_agent.cpython-313.pyc differ diff --git a/app/domain/__pycache__/__init__.cpython-313.pyc b/app/domain/__pycache__/__init__.cpython-313.pyc index abfb0bf6..bfcaf3cc 100644 Binary files a/app/domain/__pycache__/__init__.cpython-313.pyc and b/app/domain/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/__init__.cpython-313.pyc b/app/domain/contas/__pycache__/__init__.cpython-313.pyc index e32cc5bc..d82e8666 100644 Binary files a/app/domain/contas/__pycache__/__init__.cpython-313.pyc and b/app/domain/contas/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/client.cpython-313.pyc b/app/domain/contas/__pycache__/client.cpython-313.pyc index 66f82efa..5c1a0106 100644 Binary files a/app/domain/contas/__pycache__/client.cpython-313.pyc and b/app/domain/contas/__pycache__/client.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/contestation_rules.cpython-313.pyc b/app/domain/contas/__pycache__/contestation_rules.cpython-313.pyc index d187f0fe..562cded2 100644 Binary files a/app/domain/contas/__pycache__/contestation_rules.cpython-313.pyc and b/app/domain/contas/__pycache__/contestation_rules.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/contestation_validation.cpython-313.pyc b/app/domain/contas/__pycache__/contestation_validation.cpython-313.pyc index 7e25ba78..7adf0faa 100644 Binary files a/app/domain/contas/__pycache__/contestation_validation.cpython-313.pyc and b/app/domain/contas/__pycache__/contestation_validation.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/conversation_policy.cpython-313.pyc b/app/domain/contas/__pycache__/conversation_policy.cpython-313.pyc new file mode 100644 index 00000000..671d2434 Binary files /dev/null and b/app/domain/contas/__pycache__/conversation_policy.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/ic_tags.cpython-313.pyc b/app/domain/contas/__pycache__/ic_tags.cpython-313.pyc index 1ef731d6..3e338ee3 100644 Binary files a/app/domain/contas/__pycache__/ic_tags.cpython-313.pyc and b/app/domain/contas/__pycache__/ic_tags.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/informational_context.cpython-313.pyc b/app/domain/contas/__pycache__/informational_context.cpython-313.pyc index 6d0e3e3f..dc5cf8f7 100644 Binary files a/app/domain/contas/__pycache__/informational_context.cpython-313.pyc and b/app/domain/contas/__pycache__/informational_context.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/invoice_context.cpython-313.pyc b/app/domain/contas/__pycache__/invoice_context.cpython-313.pyc index 746a7d7c..bbe6ae3a 100644 Binary files a/app/domain/contas/__pycache__/invoice_context.cpython-313.pyc and b/app/domain/contas/__pycache__/invoice_context.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/invoice_models.cpython-313.pyc b/app/domain/contas/__pycache__/invoice_models.cpython-313.pyc index 87a0191f..fe4fdd9f 100644 Binary files a/app/domain/contas/__pycache__/invoice_models.cpython-313.pyc and b/app/domain/contas/__pycache__/invoice_models.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/invoice_resolver.cpython-313.pyc b/app/domain/contas/__pycache__/invoice_resolver.cpython-313.pyc index a6d6def0..dc0ba9a4 100644 Binary files a/app/domain/contas/__pycache__/invoice_resolver.cpython-313.pyc and b/app/domain/contas/__pycache__/invoice_resolver.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/item_matcher.cpython-313.pyc b/app/domain/contas/__pycache__/item_matcher.cpython-313.pyc index 58d7bad3..68010945 100644 Binary files a/app/domain/contas/__pycache__/item_matcher.cpython-313.pyc and b/app/domain/contas/__pycache__/item_matcher.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/line_reference.cpython-313.pyc b/app/domain/contas/__pycache__/line_reference.cpython-313.pyc index 049e820d..db4d1421 100644 Binary files a/app/domain/contas/__pycache__/line_reference.cpython-313.pyc and b/app/domain/contas/__pycache__/line_reference.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/pro_rata_rules.cpython-313.pyc b/app/domain/contas/__pycache__/pro_rata_rules.cpython-313.pyc index 04ee7885..f579a96a 100644 Binary files a/app/domain/contas/__pycache__/pro_rata_rules.cpython-313.pyc and b/app/domain/contas/__pycache__/pro_rata_rules.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/protocol_triplets.cpython-313.pyc b/app/domain/contas/__pycache__/protocol_triplets.cpython-313.pyc index cd0c697c..18f1c80d 100644 Binary files a/app/domain/contas/__pycache__/protocol_triplets.cpython-313.pyc and b/app/domain/contas/__pycache__/protocol_triplets.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/rct_policy.cpython-313.pyc b/app/domain/contas/__pycache__/rct_policy.cpython-313.pyc index 24d7f699..4fe21bc7 100644 Binary files a/app/domain/contas/__pycache__/rct_policy.cpython-313.pyc and b/app/domain/contas/__pycache__/rct_policy.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/service.cpython-313.pyc b/app/domain/contas/__pycache__/service.cpython-313.pyc index 44803c0f..ab27cd03 100644 Binary files a/app/domain/contas/__pycache__/service.cpython-313.pyc and b/app/domain/contas/__pycache__/service.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/string_metrics.cpython-313.pyc b/app/domain/contas/__pycache__/string_metrics.cpython-313.pyc index a21b7080..d1f8b7d4 100644 Binary files a/app/domain/contas/__pycache__/string_metrics.cpython-313.pyc and b/app/domain/contas/__pycache__/string_metrics.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/vas_cancellation_message.cpython-313.pyc b/app/domain/contas/__pycache__/vas_cancellation_message.cpython-313.pyc index 3d0b6a0c..5be3a9e8 100644 Binary files a/app/domain/contas/__pycache__/vas_cancellation_message.cpython-313.pyc and b/app/domain/contas/__pycache__/vas_cancellation_message.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/vas_variation.cpython-313.pyc b/app/domain/contas/__pycache__/vas_variation.cpython-313.pyc index ada21a1f..8193567f 100644 Binary files a/app/domain/contas/__pycache__/vas_variation.cpython-313.pyc and b/app/domain/contas/__pycache__/vas_variation.cpython-313.pyc differ diff --git a/app/domain/contas/__pycache__/workflow_actions.cpython-313.pyc b/app/domain/contas/__pycache__/workflow_actions.cpython-313.pyc index 338c89a2..40cd839d 100644 Binary files a/app/domain/contas/__pycache__/workflow_actions.cpython-313.pyc and b/app/domain/contas/__pycache__/workflow_actions.cpython-313.pyc differ diff --git a/app/domain/contas/conversation_policy.py b/app/domain/contas/conversation_policy.py new file mode 100644 index 00000000..c34784d2 --- /dev/null +++ b/app/domain/contas/conversation_policy.py @@ -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 diff --git a/app/domain/contas/integrations/__pycache__/secure_pdf_crypto.cpython-313.pyc b/app/domain/contas/integrations/__pycache__/secure_pdf_crypto.cpython-313.pyc index 420fc86e..a76bd2ba 100644 Binary files a/app/domain/contas/integrations/__pycache__/secure_pdf_crypto.cpython-313.pyc and b/app/domain/contas/integrations/__pycache__/secure_pdf_crypto.cpython-313.pyc differ diff --git a/app/domain/contas/parsers/__pycache__/__init__.cpython-313.pyc b/app/domain/contas/parsers/__pycache__/__init__.cpython-313.pyc index 4d216e86..37aa58eb 100644 Binary files a/app/domain/contas/parsers/__pycache__/__init__.cpython-313.pyc and b/app/domain/contas/parsers/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/domain/contas/parsers/__pycache__/bill_parser.cpython-313.pyc b/app/domain/contas/parsers/__pycache__/bill_parser.cpython-313.pyc index 4e7f464f..c8981b2f 100644 Binary files a/app/domain/contas/parsers/__pycache__/bill_parser.cpython-313.pyc and b/app/domain/contas/parsers/__pycache__/bill_parser.cpython-313.pyc differ diff --git a/app/domain/contas/parsers/__pycache__/bill_processor.cpython-313.pyc b/app/domain/contas/parsers/__pycache__/bill_processor.cpython-313.pyc index e5e0ce88..6209bb8d 100644 Binary files a/app/domain/contas/parsers/__pycache__/bill_processor.cpython-313.pyc and b/app/domain/contas/parsers/__pycache__/bill_processor.cpython-313.pyc differ diff --git a/app/extensions/__pycache__/__init__.cpython-313.pyc b/app/extensions/__pycache__/__init__.cpython-313.pyc index 1d3b65be..21b2bbf2 100644 Binary files a/app/extensions/__pycache__/__init__.cpython-313.pyc and b/app/extensions/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/extensions/__pycache__/tim_guardrails.cpython-313.pyc b/app/extensions/__pycache__/tim_guardrails.cpython-313.pyc index b4fbe734..cac4ac2f 100644 Binary files a/app/extensions/__pycache__/tim_guardrails.cpython-313.pyc and b/app/extensions/__pycache__/tim_guardrails.cpython-313.pyc differ diff --git a/app/extensions/__pycache__/tim_judges.cpython-313.pyc b/app/extensions/__pycache__/tim_judges.cpython-313.pyc index 017cdc75..17ccdc3a 100644 Binary files a/app/extensions/__pycache__/tim_judges.cpython-313.pyc and b/app/extensions/__pycache__/tim_judges.cpython-313.pyc differ diff --git a/app/extensions/tim_prompts/__pycache__/__init__.cpython-313.pyc b/app/extensions/tim_prompts/__pycache__/__init__.cpython-313.pyc index dcb08545..14464454 100644 Binary files a/app/extensions/tim_prompts/__pycache__/__init__.cpython-313.pyc and b/app/extensions/tim_prompts/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/extensions/tim_prompts/__pycache__/aluc.cpython-313.pyc b/app/extensions/tim_prompts/__pycache__/aluc.cpython-313.pyc index c2d759ea..fffd3204 100644 Binary files a/app/extensions/tim_prompts/__pycache__/aluc.cpython-313.pyc and b/app/extensions/tim_prompts/__pycache__/aluc.cpython-313.pyc differ diff --git a/app/extensions/tim_prompts/__pycache__/ausencia_oferta_proativa.cpython-313.pyc b/app/extensions/tim_prompts/__pycache__/ausencia_oferta_proativa.cpython-313.pyc index 7183c953..67283817 100644 Binary files a/app/extensions/tim_prompts/__pycache__/ausencia_oferta_proativa.cpython-313.pyc and b/app/extensions/tim_prompts/__pycache__/ausencia_oferta_proativa.cpython-313.pyc differ diff --git a/app/extensions/tim_prompts/__pycache__/fraseologia.cpython-313.pyc b/app/extensions/tim_prompts/__pycache__/fraseologia.cpython-313.pyc index b8ff33a5..51aeaaae 100644 Binary files a/app/extensions/tim_prompts/__pycache__/fraseologia.cpython-313.pyc and b/app/extensions/tim_prompts/__pycache__/fraseologia.cpython-313.pyc differ diff --git a/app/extensions/tim_prompts/__pycache__/out_of_scope.cpython-313.pyc b/app/extensions/tim_prompts/__pycache__/out_of_scope.cpython-313.pyc index 0a7201a9..a2da2611 100644 Binary files a/app/extensions/tim_prompts/__pycache__/out_of_scope.cpython-313.pyc and b/app/extensions/tim_prompts/__pycache__/out_of_scope.cpython-313.pyc differ diff --git a/app/extensions/tim_prompts/__pycache__/revprec.cpython-313.pyc b/app/extensions/tim_prompts/__pycache__/revprec.cpython-313.pyc index 0478eca3..713221a4 100644 Binary files a/app/extensions/tim_prompts/__pycache__/revprec.cpython-313.pyc and b/app/extensions/tim_prompts/__pycache__/revprec.cpython-313.pyc differ diff --git a/app/extensions/tim_prompts/__pycache__/rqlt.cpython-313.pyc b/app/extensions/tim_prompts/__pycache__/rqlt.cpython-313.pyc index 54e04f28..4fda7afd 100644 Binary files a/app/extensions/tim_prompts/__pycache__/rqlt.cpython-313.pyc and b/app/extensions/tim_prompts/__pycache__/rqlt.cpython-313.pyc differ diff --git a/app/observability/__pycache__/__init__.cpython-313.pyc b/app/observability/__pycache__/__init__.cpython-313.pyc index 2c185334..4fe4eea7 100644 Binary files a/app/observability/__pycache__/__init__.cpython-313.pyc and b/app/observability/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/observability/__pycache__/telemetry_observer.cpython-313.pyc b/app/observability/__pycache__/telemetry_observer.cpython-313.pyc index def8ec79..55420615 100644 Binary files a/app/observability/__pycache__/telemetry_observer.cpython-313.pyc and b/app/observability/__pycache__/telemetry_observer.cpython-313.pyc differ diff --git a/app/presentation/__pycache__/__init__.cpython-313.pyc b/app/presentation/__pycache__/__init__.cpython-313.pyc index 2c9878a8..2ee6a3bb 100644 Binary files a/app/presentation/__pycache__/__init__.cpython-313.pyc and b/app/presentation/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/presentation/__pycache__/tool_renderers.cpython-313.pyc b/app/presentation/__pycache__/tool_renderers.cpython-313.pyc index cf7326b8..c4c0123f 100644 Binary files a/app/presentation/__pycache__/tool_renderers.cpython-313.pyc and b/app/presentation/__pycache__/tool_renderers.cpython-313.pyc differ diff --git a/app/state.py b/app/state.py index 7122992e..1bed096f 100644 --- a/app/state.py +++ b/app/state.py @@ -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 + diff --git a/app/workflows/__pycache__/agent_graph.cpython-313.pyc b/app/workflows/__pycache__/agent_graph.cpython-313.pyc index f2ff4500..7a62a87a 100644 Binary files a/app/workflows/__pycache__/agent_graph.cpython-313.pyc and b/app/workflows/__pycache__/agent_graph.cpython-313.pyc differ diff --git a/app/workflows/agent_graph.py b/app/workflows/agent_graph.py index 209bcc01..77ee2724 100644 --- a/app/workflows/agent_graph.py +++ b/app/workflows/agent_graph.py @@ -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", } diff --git a/config/routing.yaml b/config/routing.yaml index 6ff01cd8..696ff778 100644 --- a/config/routing.yaml +++ b/config/routing.yaml @@ -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 diff --git a/config/tools.yaml b/config/tools.yaml index 0df1bd2e..8f388451 100644 --- a/config/tools.yaml +++ b/config/tools.yaml @@ -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 diff --git a/contas_mcp/__pycache__/__init__.cpython-313.pyc b/contas_mcp/__pycache__/__init__.cpython-313.pyc index 76871f4f..6eaf3bb5 100644 Binary files a/contas_mcp/__pycache__/__init__.cpython-313.pyc and b/contas_mcp/__pycache__/__init__.cpython-313.pyc differ diff --git a/contas_mcp/servers/__pycache__/__init__.cpython-313.pyc b/contas_mcp/servers/__pycache__/__init__.cpython-313.pyc index 635bb830..403be7b4 100644 Binary files a/contas_mcp/servers/__pycache__/__init__.cpython-313.pyc and b/contas_mcp/servers/__pycache__/__init__.cpython-313.pyc differ diff --git a/contas_mcp/servers/contas_mcp_server/__pycache__/__init__.cpython-313.pyc b/contas_mcp/servers/contas_mcp_server/__pycache__/__init__.cpython-313.pyc index cdf344ee..bf41d42f 100644 Binary files a/contas_mcp/servers/contas_mcp_server/__pycache__/__init__.cpython-313.pyc and b/contas_mcp/servers/contas_mcp_server/__pycache__/__init__.cpython-313.pyc differ diff --git a/contas_mcp/servers/contas_mcp_server/__pycache__/authorized_lines_service.cpython-313.pyc b/contas_mcp/servers/contas_mcp_server/__pycache__/authorized_lines_service.cpython-313.pyc index f66c7781..28a0d79d 100644 Binary files a/contas_mcp/servers/contas_mcp_server/__pycache__/authorized_lines_service.cpython-313.pyc and b/contas_mcp/servers/contas_mcp_server/__pycache__/authorized_lines_service.cpython-313.pyc differ diff --git a/contas_mcp/servers/contas_mcp_server/__pycache__/discount_history_service.cpython-313.pyc b/contas_mcp/servers/contas_mcp_server/__pycache__/discount_history_service.cpython-313.pyc index 63d5cc18..e9abe151 100644 Binary files a/contas_mcp/servers/contas_mcp_server/__pycache__/discount_history_service.cpython-313.pyc and b/contas_mcp/servers/contas_mcp_server/__pycache__/discount_history_service.cpython-313.pyc differ diff --git a/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy.cpython-313.pyc b/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy.cpython-313.pyc index 8cde6fae..73f0215b 100644 Binary files a/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy.cpython-313.pyc and b/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy.cpython-313.pyc differ diff --git a/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy_alt1.cpython-313.pyc b/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy_alt1.cpython-313.pyc index 0087c75c..3ab582d4 100644 Binary files a/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy_alt1.cpython-313.pyc and b/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy_alt1.cpython-313.pyc differ diff --git a/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy_alt2.cpython-313.pyc b/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy_alt2.cpython-313.pyc index 8afa03c3..e8416eab 100644 Binary files a/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy_alt2.cpython-313.pyc and b/contas_mcp/servers/contas_mcp_server/__pycache__/line_policy_alt2.cpython-313.pyc differ diff --git a/contas_mcp/servers/contas_mcp_server/__pycache__/main.cpython-313.pyc b/contas_mcp/servers/contas_mcp_server/__pycache__/main.cpython-313.pyc index be3fe135..8f4e7646 100644 Binary files a/contas_mcp/servers/contas_mcp_server/__pycache__/main.cpython-313.pyc and b/contas_mcp/servers/contas_mcp_server/__pycache__/main.cpython-313.pyc differ diff --git a/contas_mcp/servers/contas_mcp_server/main.py b/contas_mcp/servers/contas_mcp_server/main.py index 6dda7f52..b6f93570 100644 --- a/contas_mcp/servers/contas_mcp_server/main.py +++ b/contas_mcp/servers/contas_mcp_server/main.py @@ -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 = "" diff --git a/docs/FIX_CONTAS_REPORT_20260831_SECOND_PASS.md b/docs/FIX_CONTAS_REPORT_20260831_SECOND_PASS.md new file mode 100644 index 00000000..58be3917 --- /dev/null +++ b/docs/FIX_CONTAS_REPORT_20260831_SECOND_PASS.md @@ -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`. diff --git a/docs/FIX_CONTAS_RESIDUAL_CONVERSATION_PARITY_20260831.md b/docs/FIX_CONTAS_RESIDUAL_CONVERSATION_PARITY_20260831.md new file mode 100644 index 00000000..c86dc158 --- /dev/null +++ b/docs/FIX_CONTAS_RESIDUAL_CONVERSATION_PARITY_20260831.md @@ -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`. diff --git a/tests/migration/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc index 18dcdb9e..6307e161 100644 Binary files a/tests/migration/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_adversarial_parity.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_adversarial_parity.cpython-313-pytest-9.0.2.pyc index 06a02ace..df8ed54a 100644 Binary files a/tests/migration/__pycache__/test_adversarial_parity.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_adversarial_parity.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_backend_wrapper_contract_parity.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_backend_wrapper_contract_parity.cpython-313-pytest-9.0.2.pyc index 54e16353..9ba7c9a2 100644 Binary files a/tests/migration/__pycache__/test_backend_wrapper_contract_parity.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_backend_wrapper_contract_parity.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_business_events_and_guardrails.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_business_events_and_guardrails.cpython-313-pytest-9.0.2.pyc index aa5cfaab..f100c2e5 100644 Binary files a/tests/migration/__pycache__/test_business_events_and_guardrails.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_business_events_and_guardrails.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_cancel_backend_wrapper_parity.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_cancel_backend_wrapper_parity.cpython-313-pytest-9.0.2.pyc index bfb982c3..2fe5e19a 100644 Binary files a/tests/migration/__pycache__/test_cancel_backend_wrapper_parity.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_cancel_backend_wrapper_parity.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_cancel_composite_workflow.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_cancel_composite_workflow.cpython-313-pytest-9.0.2.pyc index 935d8b3f..7251d60d 100644 Binary files a/tests/migration/__pycache__/test_cancel_composite_workflow.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_cancel_composite_workflow.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_cancel_wrapper_remaining_parity.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_cancel_wrapper_remaining_parity.cpython-313-pytest-9.0.2.pyc index 95c5d4ac..e506ba17 100644 Binary files a/tests/migration/__pycache__/test_cancel_wrapper_remaining_parity.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_cancel_wrapper_remaining_parity.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_contas_conversation_policy_residuals.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_contas_conversation_policy_residuals.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 00000000..c18630d4 Binary files /dev/null and b/tests/migration/__pycache__/test_contas_conversation_policy_residuals.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_contas_domain_mock.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_contas_domain_mock.cpython-313-pytest-9.0.2.pyc index 33878efb..de79f936 100644 Binary files a/tests/migration/__pycache__/test_contas_domain_mock.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_contas_domain_mock.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_contas_invoice_explanation_routing.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_contas_invoice_explanation_routing.cpython-313-pytest-9.0.2.pyc index 666add57..89727621 100644 Binary files a/tests/migration/__pycache__/test_contas_invoice_explanation_routing.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_contas_invoice_explanation_routing.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_contestation_business_rules_full.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_contestation_business_rules_full.cpython-313-pytest-9.0.2.pyc index ed16df81..987391d2 100644 Binary files a/tests/migration/__pycache__/test_contestation_business_rules_full.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_contestation_business_rules_full.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_contestation_failure_mapping.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_contestation_failure_mapping.cpython-313-pytest-9.0.2.pyc index 31fdbe26..53a78147 100644 Binary files a/tests/migration/__pycache__/test_contestation_failure_mapping.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_contestation_failure_mapping.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_contestation_prompt_terminal_out_of_scope.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_contestation_prompt_terminal_out_of_scope.cpython-313-pytest-9.0.2.pyc index dfed7068..72822fb0 100644 Binary files a/tests/migration/__pycache__/test_contestation_prompt_terminal_out_of_scope.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_contestation_prompt_terminal_out_of_scope.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_contestation_single_item_mock_regression.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_contestation_single_item_mock_regression.cpython-313-pytest-9.0.2.pyc index 02a9dc78..928aa92e 100644 Binary files a/tests/migration/__pycache__/test_contestation_single_item_mock_regression.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_contestation_single_item_mock_regression.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_contestation_subject_entity_resolution.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_contestation_subject_entity_resolution.cpython-313-pytest-9.0.2.pyc index 3f25021b..cc9af691 100644 Binary files a/tests/migration/__pycache__/test_contestation_subject_entity_resolution.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_contestation_subject_entity_resolution.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_discount_history_mcp_service.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_discount_history_mcp_service.cpython-313-pytest-9.0.2.pyc index 0622a874..5e5dee2d 100644 Binary files a/tests/migration/__pycache__/test_discount_history_mcp_service.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_discount_history_mcp_service.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_dispute_actions_remaining_parity.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_dispute_actions_remaining_parity.cpython-313-pytest-9.0.2.pyc index 13721774..542dcc5a 100644 Binary files a/tests/migration/__pycache__/test_dispute_actions_remaining_parity.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_dispute_actions_remaining_parity.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_external_guardrails_judges_spi.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_external_guardrails_judges_spi.cpython-313-pytest-9.0.2.pyc index 1dd140d7..de91aa04 100644 Binary files a/tests/migration/__pycache__/test_external_guardrails_judges_spi.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_external_guardrails_judges_spi.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_finalization_parity_extended.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_finalization_parity_extended.cpython-313-pytest-9.0.2.pyc index d5bd9c74..581b193d 100644 Binary files a/tests/migration/__pycache__/test_finalization_parity_extended.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_finalization_parity_extended.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_framework_agent_gap_fixes.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_framework_agent_gap_fixes.cpython-313-pytest-9.0.2.pyc index 956fe746..8a8dd2d7 100644 Binary files a/tests/migration/__pycache__/test_framework_agent_gap_fixes.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_framework_agent_gap_fixes.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_framework_llm_composition_directive.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_framework_llm_composition_directive.cpython-313-pytest-9.0.2.pyc index 6512c757..5f8eb5df 100644 Binary files a/tests/migration/__pycache__/test_framework_llm_composition_directive.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_framework_llm_composition_directive.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_framework_native_structure.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_framework_native_structure.cpython-313-pytest-9.0.2.pyc index ccecc89b..19b341b6 100644 Binary files a/tests/migration/__pycache__/test_framework_native_structure.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_framework_native_structure.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_framework_rag_directive.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_framework_rag_directive.cpython-313-pytest-9.0.2.pyc index e6a2eac9..d4fd0b42 100644 Binary files a/tests/migration/__pycache__/test_framework_rag_directive.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_framework_rag_directive.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_framework_zero_legacy.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_framework_zero_legacy.cpython-313-pytest-9.0.2.pyc index b5e5f7ba..b324c3d1 100644 Binary files a/tests/migration/__pycache__/test_framework_zero_legacy.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_framework_zero_legacy.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_guardrail_binary_parity.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_guardrail_binary_parity.cpython-313-pytest-9.0.2.pyc index ac2e807e..6453b1de 100644 Binary files a/tests/migration/__pycache__/test_guardrail_binary_parity.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_guardrail_binary_parity.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_guardrail_context_dict_history.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_guardrail_context_dict_history.cpython-313-pytest-9.0.2.pyc index 6b333bb8..1cc5a37a 100644 Binary files a/tests/migration/__pycache__/test_guardrail_context_dict_history.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_guardrail_context_dict_history.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_guardrail_original_defaults_parity.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_guardrail_original_defaults_parity.cpython-313-pytest-9.0.2.pyc index 4d470495..5bacca23 100644 Binary files a/tests/migration/__pycache__/test_guardrail_original_defaults_parity.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_guardrail_original_defaults_parity.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_human_handoff_guardrail_context.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_human_handoff_guardrail_context.cpython-313-pytest-9.0.2.pyc index 61c1fef9..c2cf288f 100644 Binary files a/tests/migration/__pycache__/test_human_handoff_guardrail_context.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_human_handoff_guardrail_context.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_input_guardrail_user_feedback.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_input_guardrail_user_feedback.cpython-313-pytest-9.0.2.pyc index a72d4ae3..a5533e92 100644 Binary files a/tests/migration/__pycache__/test_input_guardrail_user_feedback.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_input_guardrail_user_feedback.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_invoice_context_framework_native.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_invoice_context_framework_native.cpython-313-pytest-9.0.2.pyc index c8d9f21b..d1277bca 100644 Binary files a/tests/migration/__pycache__/test_invoice_context_framework_native.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_invoice_context_framework_native.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_invoice_explanation_final_response.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_invoice_explanation_final_response.cpython-313-pytest-9.0.2.pyc index 30ec2dcd..661455f7 100644 Binary files a/tests/migration/__pycache__/test_invoice_explanation_final_response.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_invoice_explanation_final_response.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_invoice_explanation_handoff_policy.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_invoice_explanation_handoff_policy.cpython-313-pytest-9.0.2.pyc index 9c0fec17..b3784d69 100644 Binary files a/tests/migration/__pycache__/test_invoice_explanation_handoff_policy.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_invoice_explanation_handoff_policy.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_invoice_explanation_unmatched_meaningful.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_invoice_explanation_unmatched_meaningful.cpython-313-pytest-9.0.2.pyc index 556bf1ba..8560694d 100644 Binary files a/tests/migration/__pycache__/test_invoice_explanation_unmatched_meaningful.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_invoice_explanation_unmatched_meaningful.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_line_policy_alternatives.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_line_policy_alternatives.cpython-313-pytest-9.0.2.pyc index 36bda081..5d75edf9 100644 Binary files a/tests/migration/__pycache__/test_line_policy_alternatives.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_line_policy_alternatives.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_mcp_contestation_subject_guard.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_mcp_contestation_subject_guard.cpython-313-pytest-9.0.2.pyc index 43095bdc..80e4baec 100644 Binary files a/tests/migration/__pycache__/test_mcp_contestation_subject_guard.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_mcp_contestation_subject_guard.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_multiple_vas_subject_resolution.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_multiple_vas_subject_resolution.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 00000000..3b1c5c19 Binary files /dev/null and b/tests/migration/__pycache__/test_multiple_vas_subject_resolution.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_no_legacy_dependency.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_no_legacy_dependency.cpython-313-pytest-9.0.2.pyc index cf4def87..a04404df 100644 Binary files a/tests/migration/__pycache__/test_no_legacy_dependency.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_no_legacy_dependency.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_observability_code_mapping.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_observability_code_mapping.cpython-313-pytest-9.0.2.pyc index cbec7139..3d217c5d 100644 Binary files a/tests/migration/__pycache__/test_observability_code_mapping.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_observability_code_mapping.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_observability_default_overlay_compatibility.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_observability_default_overlay_compatibility.cpython-313-pytest-9.0.2.pyc index 4ccd187a..aebe5842 100644 Binary files a/tests/migration/__pycache__/test_observability_default_overlay_compatibility.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_observability_default_overlay_compatibility.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_observability_mapping_provider_boundary.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_observability_mapping_provider_boundary.cpython-313-pytest-9.0.2.pyc index ed4eb4b7..0bed412e 100644 Binary files a/tests/migration/__pycache__/test_observability_mapping_provider_boundary.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_observability_mapping_provider_boundary.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_observability_mapping_registry_actions.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_observability_mapping_registry_actions.cpython-313-pytest-9.0.2.pyc index 6173e6c8..9f882acb 100644 Binary files a/tests/migration/__pycache__/test_observability_mapping_registry_actions.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_observability_mapping_registry_actions.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_original_compliance_anatel.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_original_compliance_anatel.cpython-313-pytest-9.0.2.pyc index e08f1194..ae7a9a36 100644 Binary files a/tests/migration/__pycache__/test_original_compliance_anatel.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_original_compliance_anatel.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_original_contestation_validation.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_original_contestation_validation.cpython-313-pytest-9.0.2.pyc index 8a37edaf..f3310dc1 100644 Binary files a/tests/migration/__pycache__/test_original_contestation_validation.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_original_contestation_validation.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_original_item_matcher_transcription.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_original_item_matcher_transcription.cpython-313-pytest-9.0.2.pyc index 07b724c4..5bda56ca 100644 Binary files a/tests/migration/__pycache__/test_original_item_matcher_transcription.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_original_item_matcher_transcription.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_original_vas_cancellation_message.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_original_vas_cancellation_message.cpython-313-pytest-9.0.2.pyc index 7bbfe54f..498c10f8 100644 Binary files a/tests/migration/__pycache__/test_original_vas_cancellation_message.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_original_vas_cancellation_message.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_original_vas_variation.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_original_vas_variation.cpython-313-pytest-9.0.2.pyc index f2a82911..e4098cb3 100644 Binary files a/tests/migration/__pycache__/test_original_vas_variation.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_original_vas_variation.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_original_vas_variation_adversarial.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_original_vas_variation_adversarial.cpython-313-pytest-9.0.2.pyc index 16d771bd..c6a6bcc6 100644 Binary files a/tests/migration/__pycache__/test_original_vas_variation_adversarial.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_original_vas_variation_adversarial.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_original_workflow_cases.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_original_workflow_cases.cpython-313-pytest-9.0.2.pyc index fd43505f..1290e622 100644 Binary files a/tests/migration/__pycache__/test_original_workflow_cases.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_original_workflow_cases.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_output_supervisor_no_contract_hardcodes.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_output_supervisor_no_contract_hardcodes.cpython-313-pytest-9.0.2.pyc index 3506cd2b..dae0dabc 100644 Binary files a/tests/migration/__pycache__/test_output_supervisor_no_contract_hardcodes.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_output_supervisor_no_contract_hardcodes.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_pro_rata_full_parity.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_pro_rata_full_parity.cpython-313-pytest-9.0.2.pyc index f2257f69..c5a01b4d 100644 Binary files a/tests/migration/__pycache__/test_pro_rata_full_parity.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_pro_rata_full_parity.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_rag_resilience_and_informational_context.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_rag_resilience_and_informational_context.cpython-313-pytest-9.0.2.pyc index ef9a5244..545539dc 100644 Binary files a/tests/migration/__pycache__/test_rag_resilience_and_informational_context.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_rag_resilience_and_informational_context.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_real_legacy_integration_compat.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_real_legacy_integration_compat.cpython-313-pytest-9.0.2.pyc index 49e042e1..5566fb01 100644 Binary files a/tests/migration/__pycache__/test_real_legacy_integration_compat.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_real_legacy_integration_compat.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_remaining_command_contracts.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_remaining_command_contracts.cpython-313-pytest-9.0.2.pyc index 49101647..0e69a81f 100644 Binary files a/tests/migration/__pycache__/test_remaining_command_contracts.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_remaining_command_contracts.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_requested_tools_parity_pente_fino.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_requested_tools_parity_pente_fino.cpython-313-pytest-9.0.2.pyc index 0d646323..59b49306 100644 Binary files a/tests/migration/__pycache__/test_requested_tools_parity_pente_fino.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_requested_tools_parity_pente_fino.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_revprec_epistemic_uncertainty.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_revprec_epistemic_uncertainty.cpython-313-pytest-9.0.2.pyc index a9c5d913..afaf712a 100644 Binary files a/tests/migration/__pycache__/test_revprec_epistemic_uncertainty.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_revprec_epistemic_uncertainty.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_subject_entity_resolution_all_domains.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_subject_entity_resolution_all_domains.cpython-313-pytest-9.0.2.pyc index 495e054a..52bc7629 100644 Binary files a/tests/migration/__pycache__/test_subject_entity_resolution_all_domains.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_subject_entity_resolution_all_domains.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_termino_desconto_grounding.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_termino_desconto_grounding.cpython-313-pytest-9.0.2.pyc index 46263d86..7d7a3054 100644 Binary files a/tests/migration/__pycache__/test_termino_desconto_grounding.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_termino_desconto_grounding.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_tim_aoferta_transaction_continuation.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_tim_aoferta_transaction_continuation.cpython-313-pytest-9.0.2.pyc index ffceec58..9d5b6582 100644 Binary files a/tests/migration/__pycache__/test_tim_aoferta_transaction_continuation.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_tim_aoferta_transaction_continuation.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_tim_contract_parity.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_tim_contract_parity.cpython-313-pytest-9.0.2.pyc index 11e034fb..8850bd13 100644 Binary files a/tests/migration/__pycache__/test_tim_contract_parity.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_tim_contract_parity.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_tim_contracts_and_idempotency.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_tim_contracts_and_idempotency.cpython-313-pytest-9.0.2.pyc index cfddac43..63c35f11 100644 Binary files a/tests/migration/__pycache__/test_tim_contracts_and_idempotency.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_tim_contracts_and_idempotency.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_tim_event_metadata_parity.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_tim_event_metadata_parity.cpython-313-pytest-9.0.2.pyc index bbef2c67..2fdc85bf 100644 Binary files a/tests/migration/__pycache__/test_tim_event_metadata_parity.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_tim_event_metadata_parity.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_tim_oos_handoff_bypass.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_tim_oos_handoff_bypass.cpython-313-pytest-9.0.2.pyc index ec77c5d9..1050d471 100644 Binary files a/tests/migration/__pycache__/test_tim_oos_handoff_bypass.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_tim_oos_handoff_bypass.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_tool_policy_operation_types.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_tool_policy_operation_types.cpython-313-pytest-9.0.2.pyc index e2fbd168..864f7968 100644 Binary files a/tests/migration/__pycache__/test_tool_policy_operation_types.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_tool_policy_operation_types.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_vaa_dispute_parity.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_vaa_dispute_parity.cpython-313-pytest-9.0.2.pyc index cf78d23e..8b01f6e4 100644 Binary files a/tests/migration/__pycache__/test_vaa_dispute_parity.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_vaa_dispute_parity.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_vas_response_renderer.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_vas_response_renderer.cpython-313-pytest-9.0.2.pyc index c6252bef..8cf0c511 100644 Binary files a/tests/migration/__pycache__/test_vas_response_renderer.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_vas_response_renderer.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_workflow_execution_latch.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_workflow_execution_latch.cpython-313-pytest-9.0.2.pyc index 91ae166d..a9f98e89 100644 Binary files a/tests/migration/__pycache__/test_workflow_execution_latch.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_workflow_execution_latch.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/__pycache__/test_wrapper_last_contracts.cpython-313-pytest-9.0.2.pyc b/tests/migration/__pycache__/test_wrapper_last_contracts.cpython-313-pytest-9.0.2.pyc index 726269c9..f43c5dab 100644 Binary files a/tests/migration/__pycache__/test_wrapper_last_contracts.cpython-313-pytest-9.0.2.pyc and b/tests/migration/__pycache__/test_wrapper_last_contracts.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/migration/test_contas_conversation_policy_residuals.py b/tests/migration/test_contas_conversation_policy_residuals.py new file mode 100644 index 00000000..aaa04e3f --- /dev/null +++ b/tests/migration/test_contas_conversation_policy_residuals.py @@ -0,0 +1,107 @@ +from app.domain.contas.conversation_policy import evaluate, is_regulatory_threat + + +def test_no_match_three_consecutive_then_terminal(monkeypatch): + monkeypatch.setenv("CONTAS_NO_MATCH_MAX_CONSECUTIVE", "3") + base = {"user_text": "???", "intent": "contas_no_match", "route": "suporte_contas_agent", "route_decision": {"method": "llm", "intent": "contas_no_match"}} + d1 = evaluate(base) + assert d1 and d1.patch["no_match_count"] == 1 and d1.route == "conversation_policy_response" + d2 = evaluate({**base, "no_match_count": 1}) + assert d2 and d2.patch["no_match_count"] == 2 and not d2.patch.get("session_ended") + d3 = evaluate({**base, "no_match_count": 2}) + assert d3 and d3.patch["no_match_count"] == 3 + assert d3.route == "end_session" + assert d3.patch["terminal_status"] == "erro_no_match" + + +def test_understood_turn_resets_no_match_counter(): + state = {"user_text": "quero minha fatura", "intent": "contas_invoice_query", "route": "faturas_agent", "route_decision": {"method": "keyword", "intent": "contas_invoice_query"}, "no_match_count": 2} + d = evaluate(state) + assert d and d.patch == {"no_match_count": 0} + + +def test_regulatory_threat_without_focus_does_not_invent_transaction(): + state = {"user_text": "vou procurar a Anatel e o Procon", "intent": "fallback", "route": "faturas_agent", "route_decision": {"method": "llm", "intent": "fallback"}} + d = evaluate(state) + assert d and d.route == "suporte_contas_agent" + assert d.intent == "contas_regulatory_complaint" + assert d.patch["mcp_tools"] == [] + + +def test_regulatory_threat_with_focused_subject_preserves_action_context(): + state = { + "user_text": "se não resolver vou para a Anatel", + "intent": "fallback", "route": "faturas_agent", "route_decision": {"method": "llm", "intent": "fallback"}, + "last_transaction": {"arguments": {"subject": "HBO Max Standard"}}, + } + d = evaluate(state) + assert d and d.route == "contestacao_agent" + assert d.intent == "contas_vas_cancel" + assert d.patch["regulatory_context"]["focused_items"] == ["HBO Max Standard"] + + +def test_regulatory_detector_terms(): + assert is_regulatory_threat("vou procurar meus direitos no Procon") + assert is_regulatory_threat("vou à justiça") + + +def test_retention_offer_and_two_step_acceptance(monkeypatch): + import app.domain.contas.conversation_policy as policy + items = [{"name": "VAS A", "msisdn": "11999999999"}, {"name": "VAS B", "msisdn": "11999999999"}] + monkeypatch.setattr(policy, "_retention_items", lambda state: items) + first = policy.evaluate({"user_text": "quero um atendente", "route": "human_handoff", "intent": "human_handoff", "route_decision": {"method": "continuity", "intent": "human_handoff"}}) + assert first and first.route == "conversation_policy_response" + assert first.patch["contas_retention"]["pending_stage"] == "explain" + + second = policy.evaluate({"user_text": "não, quero atendente", "route": "human_handoff", "intent": "human_handoff", "route_decision": {"method": "continuity", "intent": "human_handoff"}, "contas_retention": first.patch["contas_retention"]}) + assert second and second.patch["contas_retention"]["pending_stage"] == "continue" + + third = policy.evaluate({"user_text": "sim", "route": "human_handoff", "intent": "human_handoff", "route_decision": {"method": "continuity", "intent": "human_handoff"}, "contas_retention": second.patch["contas_retention"]}) + assert third and third.route == "contestacao_agent" + assert third.patch["context"]["focused_items"] == ["VAS A", "VAS B"] + assert third.patch["_route_metadata"]["original_input"] == "cancelar VAS A e VAS B" + +def test_retention_second_decline_handoffs(monkeypatch): + import app.domain.contas.conversation_policy as policy + state = {"user_text": "não, atendente", "route": "human_handoff", "intent": "human_handoff", "route_decision": {"method": "continuity", "intent": "human_handoff"}, "contas_retention": {"pending_stage": "continue", "stages_offered": ["explain", "continue"], "items": [{"name": "VAS A", "msisdn": "11999999999"}]}} + d = policy.evaluate(state) + assert d and d.route == "human_handoff" and d.reason == "human_retention_declined" + + +def test_generic_router_fallback_is_not_counted_as_incomprehensible(): + d = evaluate({"user_text": "quero falar sobre outra empresa", "intent": "fallback", "route": "faturas_agent", "route_decision": {"method": "fallback", "intent": "fallback"}, "no_match_count": 1}) + assert d and d.patch == {"no_match_count": 0} + + +def test_explicit_gratitude_closes_only_without_live_transaction(): + history = [{"role": "assistant", "content": "Com essa explicação, sanei sua dúvida?", "metadata": {"intent": "contas_invoice_explanation"}}] + d = evaluate({"user_text": "entendi, obrigado, era só isso", "intent": "fallback", "route": "suporte_contas_agent", "history": history}) + assert d and d.route == "end_session" + assert d.patch["terminal_status"] == "resolvido" + + active = evaluate({ + "user_text": "entendi, obrigado, era só isso", + "intent": "contas_vas_cancel", + "route": "contestacao_agent", + "history": history, + "transaction_status": "AWAITING_CONFIRMATION", + "active_transaction": {"tool_name": "cancelar_vas_avulso", "status": "AWAITING_CONFIRMATION"}, + }) + assert active is None or active.route != "end_session" + + +def test_plural_confirmation_after_invoice_explanation_stays_in_explanation(): + history = [{ + "role": "assistant", + "content": "Essas duas cobranças de VOD + Canais abertos são as que você não reconhece.", + "metadata": {"intent": "contas_invoice_explanation"}, + }] + d = evaluate({ + "user_text": "as duas mesmo, pode seguir", + "intent": "fallback", + "route": "suporte_contas_agent", + "history": history, + }) + assert d and d.route == "faturas_agent" + assert d.intent == "contas_invoice_explanation" + assert d.patch["mcp_tools"] == ["invoice_explanation"] diff --git a/tests/migration/test_multiple_vas_subject_resolution.py b/tests/migration/test_multiple_vas_subject_resolution.py new file mode 100644 index 00000000..351eb21e --- /dev/null +++ b/tests/migration/test_multiple_vas_subject_resolution.py @@ -0,0 +1,25 @@ +import importlib + +mcp = importlib.import_module("contas_mcp.servers.contas_mcp_server.main") + + +def test_multiple_named_subjects_use_unique_catalog_resolution(monkeypatch): + catalog = [{"name": "Netflix Mensal"}, {"name": "HBO Max Standard"}, {"name": "TIM Fashion Mensal"}] + monkeypatch.setattr(mcp, "_vas_domain_policy_from_invoice_detail", lambda name, args: ("cancelar_vas_avulso", "avulso")) + monkeypatch.setattr(mcp, "_invoice_detail_msisdn", lambda detail, name: "11999999999") + items = mcp._resolve_multiple_vas_subjects("cancela Netflix e HBO", catalog, {"msisdn": "11999999999"}) + assert [x["name"] for x in items] == ["Netflix Mensal", "HBO Max Standard"] + + +def test_generic_todos_does_not_expand_mass_cancellation(monkeypatch): + catalog = [{"name": "Netflix Mensal"}, {"name": "TIM Fashion Mensal"}] + monkeypatch.setattr(mcp, "_vas_domain_policy_from_invoice_detail", lambda name, args: ("cancelar_vas_avulso", "avulso")) + assert mcp._resolve_multiple_vas_subjects("cancela todos", catalog, {}) == [] + + +def test_explicit_all_avulsos_filters_non_avulso(monkeypatch): + catalog = [{"name": "VAS A"}, {"name": "Netflix"}] + monkeypatch.setattr(mcp, "_vas_domain_policy_from_invoice_detail", lambda name, args: ("cancelar_vas_avulso", "avulso") if name == "VAS A" else ("vas_estrategico", "estrategico")) + monkeypatch.setattr(mcp, "_invoice_detail_msisdn", lambda detail, name: "11999999999") + items = mcp._resolve_multiple_vas_subjects("cancele todos os VAS avulsos", catalog, {"msisdn": "11999999999"}) + assert [x["name"] for x in items] == ["VAS A"] diff --git a/tests/migration/test_subject_entity_resolution_all_domains.py b/tests/migration/test_subject_entity_resolution_all_domains.py index 598b1f14..4711601a 100644 --- a/tests/migration/test_subject_entity_resolution_all_domains.py +++ b/tests/migration/test_subject_entity_resolution_all_domains.py @@ -234,3 +234,46 @@ async def test_vas_validator_real_invoice_detail_redirects_youtube_without_resol assert decision['target_tool'] == 'tratar_vas_estrategico' assert decision['action_changed'] is True assert decision['requires_reconfirmation'] is True + + +def test_multi_item_execution_preflight_does_not_reresolve_joined_subject(): + args = { + 'msisdn': '11999999999', + 'subject': 'Tamboro Mensal, Paramount+', + 'items': [ + {'name': 'Tamboro Mensal', 'msisdn': '11999999999'}, + {'name': 'Paramount+', 'msisdn': '11999999999'}, + ], + } + assert main._preflight_subject('cancelar_vas_avulso', args) is None + + +@pytest.mark.asyncio +async def test_contestation_wrong_amount_is_recoverable_with_invoice_amount(monkeypatch): + async def no_enrich(name, args): + return None + + monkeypatch.setattr(main, '_enrich_invoice_context', no_enrich) + monkeypatch.setattr(main, '_preflight_subject', lambda name, args: None) + monkeypatch.setattr(main, 'validate_contestation_items', lambda items, payload: ( + items, + [{ + 'erro': 'valor_ajuste_maior_que_item', + 'valor_item_fatura': '14.99', + 'item_fatura_resolvido': 'Tamboro Mensal', + }], + "Valor de ajuste excede o valor cobrado", + )) + result = await main._validate_contestation({ + 'msisdn': '11999999999', + 'subject': 'Tamboro Mensal', + 'valor': '29.98', + 'billing_analysis': {}, + 'target_tool': 'contestar_cobranca', + }) + assert result['eligible'] is False + assert result['status'] == 'NEEDS_PARAMETER' + assert result['parameter'] == 'valor' + assert result['resolved_value'] == '14.99' + assert 'R$ 14,99' in result['parameter_message'] + assert 'R$ 29,98' in result['parameter_message']