Ajustes no agente de contas
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
277
app/domain/contas/conversation_policy.py
Normal file
277
app/domain/contas/conversation_policy.py
Normal file
@@ -0,0 +1,277 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .invoice_resolver import InvoiceResolver
|
||||
from .vas_variation import varied_avulso_items
|
||||
|
||||
_REGULATORY_RE = re.compile(r"\b(anatel|procon|justi[cç]a|judicial|advogad[oa]|meus direitos|processar)\b", re.I)
|
||||
_HUMAN_RE = re.compile(r"\b(atendente|humano|pessoa|operador|supervisor)\b", re.I)
|
||||
_YES_RE = re.compile(r"^\s*(sim|s|pode|pode sim|isso|isso mesmo|quero|claro|ok|confirmo|vamos)\b", re.I)
|
||||
_NO_RE = re.compile(r"^\s*(n[aã]o|nao|n|negativo|deixa|deixe|prefiro n[aã]o|quero atendente|atendente)\b", re.I)
|
||||
_CLOSE_RE = re.compile(r"\b(era s[oó] isso|s[oó] isso mesmo|obrigad[oa].*era isso|entendi.*obrigad[oa]|pode encerrar|pode finalizar)\b", re.I)
|
||||
_PLURAL_CONTINUE_RE = re.compile(r"\b(as duas|os dois|ambas|ambos)\b.*\b(pode|seguir|isso|sim|mesmo)\b", re.I)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PolicyDecision:
|
||||
route: str | None = None
|
||||
intent: str | None = None
|
||||
answer: str | None = None
|
||||
reason: str | None = None
|
||||
patch: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _walk_dicts(value: Any) -> Iterable[dict[str, Any]]:
|
||||
if isinstance(value, dict):
|
||||
yield value
|
||||
for child in value.values():
|
||||
yield from _walk_dicts(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
yield from _walk_dicts(child)
|
||||
|
||||
|
||||
def _first_dict_value(state: dict[str, Any], keys: tuple[str, ...]) -> dict[str, Any] | None:
|
||||
for obj in _walk_dicts(state):
|
||||
for key in keys:
|
||||
value = obj.get(key)
|
||||
if isinstance(value, dict) and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _explicit_answer(text: str) -> str | None:
|
||||
raw = str(text or "").strip()
|
||||
if _YES_RE.search(raw):
|
||||
return "YES"
|
||||
if _NO_RE.search(raw):
|
||||
return "NO"
|
||||
return None
|
||||
|
||||
|
||||
def _previous_assistant_intent(state: dict[str, Any]) -> str:
|
||||
for item in reversed(state.get("history") or []):
|
||||
if not isinstance(item, dict) or str(item.get("role") or "") != "assistant":
|
||||
continue
|
||||
metadata = item.get("metadata") if isinstance(item.get("metadata"), dict) else {}
|
||||
direct = str(metadata.get("intent") or "").strip()
|
||||
if direct:
|
||||
return direct
|
||||
decision = metadata.get("route_decision")
|
||||
if isinstance(decision, dict):
|
||||
return str(decision.get("intent") or "").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _has_live_transaction(state: dict[str, Any]) -> bool:
|
||||
status = str(state.get("transaction_status") or "").upper()
|
||||
if status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION", "WORKFLOW_PAUSED", "TOOL_RESULT_CLARIFICATION"}:
|
||||
return True
|
||||
return bool(state.get("active_transaction") or state.get("pending_tool_call") or state.get("pending_domain_workflow"))
|
||||
|
||||
|
||||
def is_regulatory_threat(text: str) -> bool:
|
||||
return bool(_REGULATORY_RE.search(str(text or "")))
|
||||
|
||||
|
||||
def _focused_subjects(state: dict[str, Any]) -> list[str]:
|
||||
out: list[str] = []
|
||||
candidates = [
|
||||
state.get("active_transaction"), state.get("last_transaction"),
|
||||
state.get("selected_tool_call"), state.get("pending_tool_call"),
|
||||
(state.get("context") or {}).get("focused_items") if isinstance(state.get("context"), dict) else None,
|
||||
]
|
||||
for candidate in candidates:
|
||||
if isinstance(candidate, list):
|
||||
for item in candidate:
|
||||
if isinstance(item, str) and item.strip():
|
||||
out.append(item.strip())
|
||||
continue
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
args = candidate.get("arguments") if isinstance(candidate.get("arguments"), dict) else candidate
|
||||
for key in ("subject", "item", "service", "name"):
|
||||
value = args.get(key) if isinstance(args, dict) else None
|
||||
if isinstance(value, str) and value.strip():
|
||||
out.append(value.strip())
|
||||
values = args.get("items") if isinstance(args, dict) else None
|
||||
if isinstance(values, list):
|
||||
for item in values:
|
||||
if isinstance(item, dict):
|
||||
value = item.get("name") or item.get("desc") or item.get("subject")
|
||||
if value:
|
||||
out.append(str(value).strip())
|
||||
return list(dict.fromkeys(x for x in out if x))
|
||||
|
||||
|
||||
def _retention_items(state: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
invoice_detail = _first_dict_value(state, ("invoice_detail", "invoiceDetail"))
|
||||
if not invoice_detail:
|
||||
return []
|
||||
invoice_variation = _first_dict_value(state, ("invoice_variation", "invoiceVariation", "billing_analysis"))
|
||||
try:
|
||||
items = varied_avulso_items(
|
||||
invoice_variation=invoice_variation,
|
||||
invoice_detail=invoice_detail,
|
||||
resolver=InvoiceResolver(),
|
||||
by_charge=True,
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
if not items:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"name": item.canonical_name,
|
||||
"msisdn": item.msisdn,
|
||||
"value": str(item.value) if item.value is not None else None,
|
||||
"charge_date": item.charge_date,
|
||||
}
|
||||
for item in items if item.msisdn and item.canonical_name
|
||||
]
|
||||
|
||||
|
||||
def evaluate(state: dict[str, Any]) -> PolicyDecision | None:
|
||||
"""Contas-only conversational policy executed after generic routing.
|
||||
|
||||
It never performs a side effect. It may only (a) answer/reprompt, (b) enrich
|
||||
routing, or (c) prepare explicit domain context consumed by normal framework
|
||||
transaction machinery.
|
||||
"""
|
||||
text = str(state.get("sanitized_input") or state.get("user_text") or "").strip()
|
||||
route_decision = state.get("route_decision") if isinstance(state.get("route_decision"), dict) else {}
|
||||
method = str(route_decision.get("method") or "")
|
||||
intent = str(state.get("intent") or route_decision.get("intent") or "")
|
||||
|
||||
# 1) Consecutive incomprehensible utterances. A router fallback is the generic,
|
||||
# architecture-native signal that no intent was understood. Any understood turn
|
||||
# resets the counter. Default requirement is three consecutive failures.
|
||||
previous_count = int(state.get("no_match_count") or 0)
|
||||
if intent == "contas_no_match":
|
||||
count = previous_count + 1
|
||||
limit = max(1, int(os.getenv("CONTAS_NO_MATCH_MAX_CONSECUTIVE", "3")))
|
||||
if count >= limit:
|
||||
return PolicyDecision(
|
||||
route="end_session", intent="contas_no_match_terminal",
|
||||
reason="no_match_limit_reached",
|
||||
patch={"no_match_count": count, "terminal_status": "erro_no_match",
|
||||
"conversation_terminal_message": "Não consegui compreender sua solicitação após três tentativas. Vou encerrar este atendimento para evitar uma ação incorreta."},
|
||||
)
|
||||
return PolicyDecision(
|
||||
route="conversation_policy_response", intent="contas_no_match_retry",
|
||||
answer="Desculpe, não entendi. Poderia repetir de outra forma?",
|
||||
reason="no_match_retry", patch={"no_match_count": count},
|
||||
)
|
||||
|
||||
reset_patch: dict[str, Any] = {"no_match_count": 0} if previous_count else {}
|
||||
|
||||
# Explicit conversational closure is a lifecycle signal, not a new business
|
||||
# intent. Never consume it while a transaction/workflow is still live.
|
||||
if _CLOSE_RE.search(text) and not _has_live_transaction(state):
|
||||
return PolicyDecision(
|
||||
route="end_session", intent="contas_conversation_close",
|
||||
reason="explicit_customer_closure",
|
||||
patch={**reset_patch, "terminal_status": "resolvido",
|
||||
"conversation_terminal_message": "Atendimento encerrado. Obrigado pelo contato."},
|
||||
)
|
||||
|
||||
# A short plural continuation after an invoice explanation belongs to that
|
||||
# explanation; it must not be mistaken for generic finalization merely because
|
||||
# it contains an affirmative such as "pode seguir". The invoice agent keeps
|
||||
# the prior evidence/context and can explain the two referenced charges.
|
||||
previous_intent = _previous_assistant_intent(state)
|
||||
if (
|
||||
previous_intent == "contas_invoice_explanation"
|
||||
and _PLURAL_CONTINUE_RE.search(text)
|
||||
and not _has_live_transaction(state)
|
||||
):
|
||||
return PolicyDecision(
|
||||
route="faturas_agent", intent="contas_invoice_explanation",
|
||||
reason="invoice_plural_context_continuation",
|
||||
patch={**reset_patch, "mcp_tools": ["invoice_explanation"],
|
||||
"_route_metadata": {"contextual_reentry": True, "original_input": text}},
|
||||
)
|
||||
|
||||
# 2) Regulatory/legal wording is not itself an action. Only preserve action
|
||||
# semantics when an entity is already concretely in focus in transaction state.
|
||||
if is_regulatory_threat(text):
|
||||
focused = _focused_subjects(state)
|
||||
if focused:
|
||||
subject = focused[0]
|
||||
return PolicyDecision(
|
||||
route="contestacao_agent", intent="contas_vas_cancel",
|
||||
reason="regulatory_threat_with_focused_entity",
|
||||
patch={**reset_patch, "regulatory_context": {"threat": True, "focused_items": focused},
|
||||
"mcp_tools": ["consultar_vas", "cancelar_vas_avulso"],
|
||||
"context": {**(state.get("context") or {}), "focused_items": focused, "regulatory_threat": True}},
|
||||
)
|
||||
# Broad complaint: never invent an item/action from the invoice.
|
||||
return PolicyDecision(
|
||||
route="suporte_contas_agent", intent="contas_regulatory_complaint",
|
||||
reason="regulatory_threat_without_focused_entity",
|
||||
patch={**reset_patch, "regulatory_context": {"threat": True, "focused_items": []}, "mcp_tools": []},
|
||||
)
|
||||
|
||||
# 3) Two-step human-retention policy. It is only eligible when the router would
|
||||
# hand off AND authoritative invoice evidence proves a varied cancellable VAS.
|
||||
retention = state.get("contas_retention") if isinstance(state.get("contas_retention"), dict) else {}
|
||||
pending = str(retention.get("pending_stage") or "")
|
||||
offered = list(retention.get("stages_offered") or [])
|
||||
retained_items = list(retention.get("items") or [])
|
||||
|
||||
if pending:
|
||||
answer_kind = _explicit_answer(text)
|
||||
if pending == "explain":
|
||||
if answer_kind == "YES":
|
||||
return PolicyDecision(
|
||||
route="faturas_agent", intent="contas_invoice_explanation",
|
||||
reason="human_retention_explanation_accepted",
|
||||
patch={**reset_patch, "mcp_tools": ["invoice_explanation"],
|
||||
"contas_retention": {"pending_stage": None, "stages_offered": offered, "items": retained_items}},
|
||||
)
|
||||
if answer_kind == "NO" or _HUMAN_RE.search(text):
|
||||
names = ", ".join(str(x.get("name")) for x in retained_items if isinstance(x, dict) and x.get("name"))
|
||||
return PolicyDecision(
|
||||
route="conversation_policy_response", intent="contas_retention_continue",
|
||||
answer=(f"Antes de transferir, posso tratar o cancelamento dos serviços que participaram da variação da conta: {names}. Deseja que eu prossiga?" if names else "Antes de transferir, posso tratar os serviços que participaram da variação da conta. Deseja que eu prossiga?"),
|
||||
reason="human_retention_offer_continue",
|
||||
patch={**reset_patch, "contas_retention": {"pending_stage": "continue", "stages_offered": list(dict.fromkeys([*offered, "continue"])), "items": retained_items}},
|
||||
)
|
||||
elif pending == "continue":
|
||||
if answer_kind == "YES":
|
||||
names = [str(x.get("name")) for x in retained_items if isinstance(x, dict) and x.get("name")]
|
||||
context = {**(state.get("context") or {}), "retention_items": retained_items, "focused_items": names}
|
||||
# Route through the normal transactional agent. No side effect here.
|
||||
semantic_request = "cancelar " + " e ".join(names) if names else text
|
||||
return PolicyDecision(
|
||||
route="contestacao_agent", intent="contas_vas_cancel",
|
||||
reason="human_retention_cancel_accepted",
|
||||
patch={**reset_patch, "mcp_tools": ["consultar_vas", "cancelar_vas_avulso"], "context": context,
|
||||
"_route_metadata": {"contextual_reentry": True, "original_input": semantic_request,
|
||||
"relevant_conversation_context": "Serviços avulsos comprovados na variação: " + ", ".join(names)},
|
||||
"contas_retention": {"pending_stage": None, "stages_offered": offered, "items": retained_items}},
|
||||
)
|
||||
if answer_kind == "NO" or _HUMAN_RE.search(text):
|
||||
return PolicyDecision(
|
||||
route="human_handoff", intent="human_handoff", reason="human_retention_declined",
|
||||
patch={**reset_patch, "contas_retention": {"pending_stage": None, "stages_offered": offered, "items": retained_items}},
|
||||
)
|
||||
|
||||
if state.get("route") == "human_handoff" and "explain" not in offered:
|
||||
items = _retention_items(state)
|
||||
if items:
|
||||
return PolicyDecision(
|
||||
route="conversation_policy_response", intent="contas_retention_explain",
|
||||
answer="Antes de transferir, identifiquei serviços avulsos que participaram da variação da sua conta. Posso primeiro explicar essa variação para você?",
|
||||
reason="human_retention_offer_explain",
|
||||
patch={**reset_patch, "human_handoff_requested": False, "session_control": "",
|
||||
"contas_retention": {"pending_stage": "explain", "stages_offered": ["explain"], "items": items}},
|
||||
)
|
||||
|
||||
if reset_patch:
|
||||
return PolicyDecision(patch=reset_patch)
|
||||
return None
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -62,3 +62,9 @@ class AgentState(TypedDict, total=False):
|
||||
long_term_memory_load_error: str
|
||||
operational_context_boundary_pending: bool
|
||||
operational_context_reset: bool
|
||||
no_match_count: int
|
||||
contas_retention: dict[str, Any]
|
||||
regulatory_context: dict[str, Any]
|
||||
terminal_status: str
|
||||
conversation_terminal_message: str
|
||||
|
||||
|
||||
Binary file not shown.
@@ -7,6 +7,7 @@ from agent_framework.guardrails.rail_action import RailAction
|
||||
from agent_framework.guardrails.rail_result import RailResult
|
||||
from agent_framework.judges.judge import JudgePipeline
|
||||
from agent_framework.routing.enterprise_router import EnterpriseRouter
|
||||
from app.domain.contas.conversation_policy import evaluate as evaluate_contas_conversation_policy
|
||||
from agent_framework.supervisor.supervisor import Supervisor
|
||||
from agent_framework.observability.workflow_events import WorkflowTelemetry
|
||||
from agent_framework.observability.guardrail_events import GuardrailTelemetry
|
||||
@@ -292,6 +293,8 @@ class AgentWorkflow:
|
||||
builder.add_node("input_guardrails", self._node("input_guardrails", self.input_guardrails))
|
||||
builder.add_node("load_long_term_memory", self._node("load_long_term_memory", self.load_long_term_memory))
|
||||
builder.add_node("routing_decision", self._node("routing_decision", self.routing_decision))
|
||||
builder.add_node("conversation_policy", self._node("conversation_policy", self.conversation_policy))
|
||||
builder.add_node("conversation_policy_response", self._node("conversation_policy_response", self.conversation_policy_response))
|
||||
builder.add_node("faturas_agent", self._node("faturas_agent", self.faturas_agent))
|
||||
builder.add_node("vas_agent", self._node("vas_agent", self.vas_agent))
|
||||
builder.add_node("contestacao_agent", self._node("contestacao_agent", self.contestacao_agent))
|
||||
@@ -316,8 +319,9 @@ class AgentWorkflow:
|
||||
{"blocked": "output_guardrails", "continue": "load_long_term_memory"},
|
||||
)
|
||||
builder.add_edge("load_long_term_memory", "routing_decision")
|
||||
builder.add_edge("routing_decision", "conversation_policy")
|
||||
builder.add_conditional_edges(
|
||||
"routing_decision",
|
||||
"conversation_policy",
|
||||
lambda s: s.get("route", "faturas_agent"),
|
||||
{
|
||||
"faturas_agent": "faturas_agent",
|
||||
@@ -328,6 +332,7 @@ class AgentWorkflow:
|
||||
"human_handoff": "human_handoff",
|
||||
"end_session": "end_session",
|
||||
"supervisor_agent": "supervisor_agent",
|
||||
"conversation_policy_response": "conversation_policy_response",
|
||||
},
|
||||
)
|
||||
builder.add_edge("faturas_agent", "output_supervisor")
|
||||
@@ -338,6 +343,7 @@ class AgentWorkflow:
|
||||
builder.add_edge("human_handoff", "output_supervisor")
|
||||
builder.add_edge("end_session", "output_supervisor")
|
||||
builder.add_edge("supervisor_agent", "output_supervisor")
|
||||
builder.add_edge("conversation_policy_response", "output_supervisor")
|
||||
builder.add_edge("output_supervisor", "output_guardrails")
|
||||
builder.add_conditional_edges(
|
||||
"output_guardrails",
|
||||
@@ -676,6 +682,26 @@ class AgentWorkflow:
|
||||
} if decision.method == "continuity" else {},
|
||||
}
|
||||
|
||||
async def conversation_policy(self, state):
|
||||
decision = evaluate_contas_conversation_policy(state)
|
||||
if decision is None:
|
||||
return {}
|
||||
patch = dict(decision.patch or {})
|
||||
route_metadata_patch = patch.pop("_route_metadata", {}) if isinstance(patch.get("_route_metadata", {}), dict) else {}
|
||||
if decision.route:
|
||||
patch["route"] = decision.route
|
||||
if decision.intent:
|
||||
patch["intent"] = decision.intent
|
||||
if decision.answer is not None:
|
||||
patch["answer"] = decision.answer
|
||||
if decision.reason:
|
||||
current = state.get("route_decision") if isinstance(state.get("route_decision"), dict) else {}
|
||||
patch["route_decision"] = {**current, "route": patch.get("route", state.get("route")), "intent": patch.get("intent", state.get("intent")), "reason": decision.reason, "metadata": {**(current.get("metadata") or {}), **route_metadata_patch, "contas_conversation_policy": decision.reason}}
|
||||
return patch
|
||||
|
||||
async def conversation_policy_response(self, state):
|
||||
return {"answer": str(state.get("answer") or ""), "next_state": state.get("next_state") or "CONVERSATION_POLICY"}
|
||||
|
||||
async def faturas_agent(self, state):
|
||||
async with self.telemetry.span(
|
||||
"workflow.agent.billing",
|
||||
@@ -820,13 +846,13 @@ class AgentWorkflow:
|
||||
runtime_context = (state.get("context") or {}).get("business_context") or {}
|
||||
await self.tool_router.call(
|
||||
"finalizar_atendimento",
|
||||
{"status": "resolvido", "summary": "Encerramento solicitado pelo agent_framework_oci", "confirmed": True},
|
||||
{"status": str(state.get("terminal_status") or "resolvido"), "summary": "Encerramento solicitado pelo agent_framework_oci", "confirmed": True},
|
||||
business_context=runtime_context,
|
||||
original_context=state.get("context") or {},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
answer = str(getattr(self.settings, "END_SESSION_MESSAGE", "Atendimento encerrado. Obrigado pelo contato."))
|
||||
answer = str(state.get("conversation_terminal_message") or getattr(self.settings, "END_SESSION_MESSAGE", "Atendimento encerrado. Obrigado pelo contato."))
|
||||
await self.telemetry.event(
|
||||
"session.end.requested",
|
||||
{
|
||||
@@ -840,7 +866,7 @@ class AgentWorkflow:
|
||||
"answer": answer,
|
||||
"session_control": "END_SESSION",
|
||||
"session_ended": True,
|
||||
"terminal_status": "resolvido",
|
||||
"terminal_status": str(state.get("terminal_status") or "resolvido"),
|
||||
"human_handoff_requested": False,
|
||||
"next_state": "SESSION_ENDED",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user