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 "") previous_count = int(state.get("no_match_count") or 0) 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. It must take precedence over router no-match so deterministic closure # phrases are not turned into generic fallback responses. 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."}, ) # The first utterance after a completed operational boundary is a fresh # interaction, never a resume of the workflow that just ended. A short # re-engagement such as "ah espera" may legitimately have no business intent # yet; answer with a neutral invitation instead of treating it as an error. if intent == "contas_no_match" and bool(state.get("operational_context_reset")): return PolicyDecision( route="conversation_policy_response", intent="contas_post_terminal_reentry", answer="Claro. Pode falar. Em que posso ajudar agora?", reason="fresh_interaction_after_operational_boundary", patch={**reset_patch, "no_match_count": 0}, ) # 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. 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}, ) # 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