Ajustes conforme relatorio de testes 2026-08-27

This commit is contained in:
2026-08-29 09:53:32 -03:00
parent 0ecff719b7
commit 88e1f070d7
791 changed files with 27040 additions and 29038 deletions

Binary file not shown.

View File

@@ -30,6 +30,29 @@ class FaturasAgent(AgentRuntimeMixin):
self.summary_memory = summary_memory
self.guardrail_pipeline = guardrail_pipeline
@staticmethod
def _handoff_patch_from_tool_context(tool_context):
for item in tool_context or []:
if not isinstance(item, dict):
continue
data = item.get("result")
if not isinstance(data, dict):
continue
nested = data.get("result")
if isinstance(nested, dict) and nested.get("session_control"):
data = nested
if str(data.get("session_control") or "").upper() != "HUMAN_HANDOFF":
continue
return {
"session_control": "HUMAN_HANDOFF",
"human_handoff_requested": True,
"session_ended": True,
"terminal_status": str(data.get("terminal_status") or "human_handoff"),
"handoff_reason": str(data.get("handoff_reason") or ""),
}
return {}
async def run(self, state):
await self._emit_ic(
"IC.FATURAS_AGENT_STARTED",
@@ -48,6 +71,7 @@ class FaturasAgent(AgentRuntimeMixin):
)
state["mcp_results"] = tool_context
handoff_patch = self._handoff_patch_from_tool_context(tool_context)
clarification_message = self.transaction_clarification_message(state)
if clarification_message:
return {
@@ -55,6 +79,7 @@ class FaturasAgent(AgentRuntimeMixin):
"next_state": state.get("next_state") or "COLLECTING_PARAMETERS",
"mcp_results": tool_context,
**self.transaction_state_patch(state),
**handoff_patch,
}
confirmation_message = self.transaction_confirmation_message(state)
@@ -64,6 +89,7 @@ class FaturasAgent(AgentRuntimeMixin):
"next_state": state.get("next_state"),
"mcp_results": tool_context,
**self.transaction_state_patch(state),
**handoff_patch,
}
return result
@@ -75,6 +101,7 @@ class FaturasAgent(AgentRuntimeMixin):
"mcp_results": tool_context,
"rag": {"enabled": False, "skipped": True, "reason": "direct_mcp_answer"},
**self.transaction_state_patch(state),
**handoff_patch,
}
rag_context, rag_metadata = await self._retrieve_rag_context(state)
@@ -113,6 +140,7 @@ class FaturasAgent(AgentRuntimeMixin):
"rag": rag_metadata,
"memory_context_metadata": state.get("memory_context_metadata"),
**self.transaction_state_patch(state),
**handoff_patch,
}
await self._emit_ic(

View File

@@ -402,7 +402,44 @@ class TimApiClient:
def contestar(self, payload: dict[str, Any]) -> Any:
if self.mock:
return self.fixture("contestacao_tool")
# The mock must behave like the real provider for the current request.
# Returning the whole static fixture leaked unrelated invoice items into
# a one-item transaction and also exposed contradictory fixture fields.
fixture = self.fixture("contestacao_tool")
provider = fixture.get("body") if isinstance(fixture, dict) and isinstance(fixture.get("body"), dict) else fixture
provider = dict(provider or {})
requested = [x for x in (payload.get("items") or []) if isinstance(x, dict)]
fixture_rows = [x for x in (provider.get("itemsResponse") or provider.get("items_response") or []) if isinstance(x, dict)]
def norm(value: Any) -> str:
import unicodedata
text = unicodedata.normalize("NFKD", str(value or "").casefold())
text = "".join(ch for ch in text if not unicodedata.combining(ch))
return " ".join("".join(ch if ch.isalnum() else " " for ch in text).split())
selected: list[dict[str, Any]] = []
for req in requested:
name = str(req.get("itemName") or req.get("item_name") or req.get("name") or "").strip()
row = next((dict(x) for x in fixture_rows if norm(x.get("itemName") or x.get("item_name")) == norm(name)), None)
if row is None:
row = {
"correctAccountStatus": "NAO_CRIAR",
"itemName": name,
"message": "Item não existe na fatura com o valor informado",
"status": "NAO_INICIADA",
}
selected.append(row)
if requested:
provider["itemsResponse"] = selected
provider["sr"] = str(payload.get("sr") or provider.get("sr") or "")
# Keep useful provider response fields, but never return the fixture's
# stale top-level normalized/result/protocol fields as if they came
# from the remote API.
if isinstance(fixture, dict):
for key in ("barcode", "codigo_boleto", "contestation_id", "contestationId", "manualContaCertaIndicator"):
if key in fixture and key not in provider:
provider[key] = fixture[key]
return provider
data = dict(payload)
data.setdefault("userId", self._env_first("TIM_CUSTOMER_CONTESTATION_USER_ID", ))
data.setdefault("customerIdCurrent", data.get("customerId") or "")

View File

@@ -36,15 +36,54 @@ def _money(value: Decimal) -> Decimal:
def _parse_amount(value: str) -> Decimal | None:
if not value:
"""Parse monetary values without assuming that every dot is a thousands separator.
Accepted examples include Brazilian and API/JSON representations such as
``R$ 19,99``, ``19.99``, ``1.999,99`` and ``1,999.99``.
"""
if value is None:
return None
cleaned = (
str(value)
.replace("R$", "")
.replace(" ", "")
.replace(".", "")
.replace(",", ".")
)
cleaned = str(value).strip().replace("R$", "").replace(" ", "")
if not cleaned:
return None
# Keep only a numeric sign and decimal/grouping separators. This avoids
# accidentally feeding currency labels or other text to Decimal.
cleaned = re.sub(r"[^0-9, .+\-]", "", cleaned).replace(" ", "")
if not cleaned:
return None
comma = cleaned.rfind(",")
dot = cleaned.rfind(".")
if comma >= 0 and dot >= 0:
# The rightmost separator is the decimal separator; the other one is
# grouping. This supports both 1.999,99 and 1,999.99.
if comma > dot:
cleaned = cleaned.replace(".", "").replace(",", ".")
else:
cleaned = cleaned.replace(",", "")
elif comma >= 0:
# pt-BR decimal notation. Multiple commas are treated conservatively
# by preserving only the last one as decimal separator.
if cleaned.count(",") > 1:
head, tail = cleaned.rsplit(",", 1)
cleaned = head.replace(",", "") + "." + tail
else:
cleaned = cleaned.replace(",", ".")
elif dot >= 0:
# A single dot followed by 1-2 digits is decimal notation (the form
# normally returned by JSON/backends). For multiple dots, keep the
# last one as decimal only when it looks like cents; otherwise treat
# them as grouping separators.
if cleaned.count(".") > 1:
head, tail = cleaned.rsplit(".", 1)
if 1 <= len(tail) <= 2:
cleaned = head.replace(".", "") + "." + tail
else:
cleaned = cleaned.replace(".", "")
try:
return Decimal(cleaned)
except Exception:
@@ -427,7 +466,48 @@ def validate_contestation_items(
0 if _normalize_match_text(candidate.get("name", "")) == _normalize_match_text(item_name) else 1,
)
)
matched_candidate = matching_candidates[0] if matching_candidates else None
# When the same subject appears more than once on the invoice, the
# requested amount is useful evidence for selecting the correct
# occurrence. Prefer an exact amount match. For a partial adjustment
# choose the smallest invoice occurrence that can cover the requested
# amount. If none can, select the largest occurrence so the generic
# ``validated > item_amount`` rule below rejects the request.
matched_candidate = None
if matching_candidates:
requested_amount = validated if validated > 0 else claimed
if requested_amount > 0 and len(matching_candidates) > 1:
monetary_candidates = [
candidate
for candidate in matching_candidates
if isinstance(candidate.get("amount"), Decimal)
and candidate.get("amount") > 0
]
exact_matches = [
candidate
for candidate in monetary_candidates
if _money(candidate["amount"]) == _money(requested_amount)
]
if exact_matches:
matched_candidate = exact_matches[0]
else:
sufficient = sorted(
(
candidate
for candidate in monetary_candidates
if candidate["amount"] >= requested_amount
),
key=lambda candidate: candidate["amount"],
)
if sufficient:
matched_candidate = sufficient[0]
elif monetary_candidates:
matched_candidate = max(
monetary_candidates,
key=lambda candidate: candidate["amount"],
)
if matched_candidate is None:
matched_candidate = matching_candidates[0]
if matched_candidate is None:
_record_failure(
item_log,

View File

@@ -0,0 +1,23 @@
{
"accounts": [
{
"authenticated_msisdn": "11999999999",
"customer_key": "11999999999",
"contract_key": "3000131180",
"authorized_lines": [
{
"msisdn": "11999999999",
"relationship": "titular",
"status": "ACTIVE",
"authorized": true
},
{
"msisdn": "11988884321",
"relationship": "dependente",
"status": "ACTIVE",
"authorized": true
}
]
}
]
}

View File

@@ -0,0 +1,52 @@
{
"accounts": [
{
"msisdn": "11999999999",
"customer_key": "11999999999",
"contract_key": "3000131180",
"discounts": [
{
"discount_id": "DESC-FIDEL-80-BLACK",
"discount_name": "Desc Fidel 80 TIM Black A compartilhado 8.0",
"discount_type": "FIDELITY",
"plan_name": "TIM Black A 8.0",
"previous_value": 80.0,
"current_value": 0.0,
"start_date": "2024-11-20",
"end_date": "2025-11-20",
"discount_status": "EXPIRED",
"termination_reason": "FIM_PERIODO_FIDELIDADE",
"termination_reason_description": "O período de fidelidade contratado foi encerrado.",
"current_value_reference": "contract_as_of_date",
"as_of_date": "2025-11-20",
"last_billed_discount_value": 80.0,
"last_billed_period": "14/10 a 13/11",
"last_invoice_issue_date": "2025-11-20",
"last_billed_status": "APPLIED"
},
{
"discount_id": "DESC-FIDEL-33-CTRL",
"discount_name": "Desc Fidel 33 TIM CTRL Redes Sociais 8.0",
"discount_type": "FIDELITY",
"plan_name": "TIM CTRL Redes Sociais 8.0",
"previous_value": 33.0,
"current_value": 33.0,
"start_date": "2025-04-20",
"end_date": "2026-04-20",
"discount_status": "ACTIVE",
"termination_reason": null,
"termination_reason_description": null,
"current_value_reference": "contract_as_of_date",
"as_of_date": "2025-11-20",
"last_billed_discount_value": 33.0,
"last_billed_period": "14/10 a 13/11",
"last_invoice_issue_date": "2025-11-20",
"last_billed_status": "APPLIED"
}
],
"as_of_date": "2025-11-20",
"last_invoice_issue_date": "2025-11-20",
"last_billed_period": "14/10 a 13/11"
}
]
}

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import re
import unicodedata
from typing import Any
_DIGIT_WORDS = {
"zero": "0", "um": "1", "uma": "1", "dois": "2", "duas": "2",
"tres": "3", "quatro": "4", "cinco": "5", "seis": "6", "sete": "7",
"oito": "8", "nove": "9",
}
def _norm(text: Any) -> str:
value = unicodedata.normalize("NFKD", str(text or "").casefold())
value = "".join(ch for ch in value if not unicodedata.combining(ch))
return re.sub(r"\s+", " ", value).strip()
def extract_requested_line_reference(text: Any) -> dict[str, str] | None:
"""Extrai somente uma referência explícita de linha citada pelo usuário.
Não transforma a referência em identidade autorizada. Essa decisão pertence à
política de linha ativa no domínio Contas.
"""
raw = str(text or "").strip()
if not raw:
return None
norm = _norm(raw)
# Número completo explicitamente presente no texto (10 a 13 dígitos, com
# separadores opcionais). Evita capturar valores monetários ou protocolos curtos.
for match in re.finditer(r"(?<!\d)(?:\+?\d[\s().-]*){10,13}(?!\d)", raw):
digits = "".join(ch for ch in match.group(0) if ch.isdigit())
if 10 <= len(digits) <= 13:
return {"kind": "full", "value": digits, "raw": match.group(0).strip()}
# Referência por final da linha: "final 4321", "final quatro três dois um",
# "termina em 4321". Só a referência é extraída; nunca é assumida como MSISDN.
marker = re.search(r"\b(?:final|termina(?:ndo)?\s+em|terminado\s+em)\b(.{0,45})", norm)
if marker:
tail = marker.group(1)
numeric = re.search(r"\b(\d{4})\b", tail)
if numeric:
return {"kind": "suffix", "value": numeric.group(1), "raw": numeric.group(1)}
tokens = re.findall(r"[a-z]+", tail)
digits: list[str] = []
for token in tokens:
if token in _DIGIT_WORDS:
digits.append(_DIGIT_WORDS[token])
if len(digits) == 4:
return {"kind": "suffix", "value": "".join(digits), "raw": " ".join(tokens[:4])}
elif digits:
break
return None

View File

@@ -114,9 +114,26 @@ class ContasDomainService:
return {
"complete_invoices": complete,
"billing_analysis": billing,
"invoice_detail": args.get("invoice_detail") if isinstance(args.get("invoice_detail"), dict) else {},
"invoice_amount": args.get("invoice_amount"),
"invoice_period": args.get("invoice_period"),
"invoice_emissao": args.get("invoice_emissao"),
"instruction": "Use estes dados como evidência para explicar composição/variação da fatura. Não invente cobranças ausentes.",
}
def buscar_informacao(self, *, queries: list[str] | None = None, **_: Any) -> dict[str, Any]:
"""Preserva a capability legada sem reimplementar RAG no domínio.
O agente/framework é o dono da recuperação. Esta tool apenas converte o
pedido legado em um contrato explícito de RAG, mantendo paridade de API.
"""
normalized = [str(q).strip() for q in (queries or []) if str(q).strip()]
return {
"requires_rag": True,
"source": "agent_framework.rag",
"rag_queries": normalized,
}
def consultar_vas(self, *, msisdn: str, **_: Any) -> Any:
return self.client.consultar_vas(msisdn)

View File

@@ -267,6 +267,31 @@ def _successful_contestation_item(item: dict[str, Any]) -> bool:
return any(str(x or "").strip().upper() in {"ENVIADA", "CRIAR", "INICIADA"} for x in statuses)
def _money_decimal(value: Any) -> Decimal:
"""Parse TIM monetary values without turning 14.99 into 1499.
Accepts canonical decimal-dot values (14.99), pt-BR decimal-comma values
(14,99), and values with thousands separators (1.234,56 / 1,234.56).
"""
text = str(value if value is not None else "0").strip()
if not text:
return Decimal("0")
text = re.sub(r"[^0-9,.-]", "", text)
if "," in text and "." in text:
if text.rfind(",") > text.rfind("."):
text = text.replace(".", "").replace(",", ".")
else:
text = text.replace(",", "")
elif "," in text:
text = text.replace(".", "").replace(",", ".")
# Dot-only input is already the canonical decimal representation used by
# the migrated agent/MCP contract. Do not strip it as a thousands marker.
try:
return Decimal(text)
except InvalidOperation:
return Decimal("0")
def _classify_contestation_items(requested: list[dict[str, Any]], response_items: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str], str, str]:
contested = [x for x in response_items if _successful_contestation_item(x) and not _already_contested_item(x)]
already = [str(x.get("itemName") or x.get("item_name") or "").strip() for x in response_items if _already_contested_item(x)]
@@ -293,13 +318,10 @@ def _classify_contestation_items(requested: list[dict[str, Any]], response_items
name = str(req.get("item_name") or req.get("itemName") or req.get("name") or "")
if not any(_same_contestation_name(name, x) for x in contested_names):
continue
try:
c = str(req.get("claimed_amount", req.get("claimedAmount", "0"))).replace(".", "").replace(",", ".")
v = str(req.get("validated_amount", req.get("validatedAmount", req.get("claimed_amount", req.get("claimedAmount", "0"))))).replace(".", "").replace(",", ".")
claimed += Decimal(c)
validated += Decimal(v)
except InvalidOperation:
pass
claimed += _money_decimal(req.get("claimed_amount", req.get("claimedAmount", "0")))
validated += _money_decimal(
req.get("validated_amount", req.get("validatedAmount", req.get("claimed_amount", req.get("claimedAmount", "0"))))
)
return contested, not_contested, already, f"{claimed:.2f}", f"{validated:.2f}"
@@ -645,7 +667,15 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
message = base
if trailer and trailer.lower() not in message.lower():
message = f"{message.rstrip()} {trailer}".strip()
return {"mensagem": message}
return {
"mensagem": message,
"await_user_input": True,
"requires_llm_composition": True,
"response_instruction": (
"Componha a resposta somente com a evidência fornecida; não invente cobranças, "
"causas, políticas ou valores ausentes. Preserve a pergunta final prevista pelo workflow."
),
}
@reg.action("checar_tentativa_cvn")
def checar_tentativa_cvn(params: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
@@ -660,6 +690,26 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
event_params = {**params, "customerMessage": str(_first(params, state, "resposta_usuario", "customer_message") or path)}
return {"success": True, "accepted": accepted, "business_events": _events_ctx(MPITag.EXPLICACAO_SIM if accepted else MPITag.EXPLICACAO_NAO, event_params, state)}
@reg.action("preparar_handoff_invoice_explanation")
def preparar_handoff_invoice_explanation(params: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
"""Materializa a decisão de handoff declarada no workflow do domínio.
A action não decide quando transferir: ela apenas transforma a configuração
do nó em um resultado estrutural consumível pelo agente/framework.
"""
message = str(params.get("mensagem") or "Para continuar com a sua solicitação, aguarde um instante.").strip()
reason = str(params.get("reason") or "invoice_explanation_not_resolved").strip()
return {
"success": True,
"mensagem": message,
"session_control": "HUMAN_HANDOFF",
"human_handoff_requested": True,
"handoff": True,
"session_ended": True,
"terminal_status": "human_handoff",
"handoff_reason": reason,
}
@reg.action("registrar_protocolo_inicio")
@reg.action("registrar_protocolo")
def registrar_protocolo(params: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
@@ -686,7 +736,18 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
or (result or {}).get("protocolo")
or ""
) if isinstance(result, dict) else ""
return {"success": bool(protocol or result), "protocolo_id": protocol, "protocol_number": protocol, "result": result}
response = {"success": bool(protocol or result), "protocolo_id": protocol, "protocol_number": protocol, "result": result}
# A protocol-opening action is reused by several workflows. Only nodes
# that explicitly request a final workflow response opt into this
# presentation contract; session terminality remains independent.
if bool(params.get("workflow_response_final")):
response["workflow_response_final"] = True
configured = str(params.get("mensagem_final") or "").strip()
if configured:
response["mensagem"] = configured.replace("{protocol}", protocol)
elif protocol:
response["mensagem"] = f"Seu número de protocolo é {protocol}."
return response
@reg.action("checar_vas_variado")
def checar_vas_variado(params: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
@@ -1500,15 +1561,185 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
"business_events": _events_ctx(VAATag.STATUS_SR_OK, params, state, agentProtocolId=protocol_id, adjustedProtocol=protocol_id, **status_meta),
}
def _discount_evidence_context(params: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
source = state.get("input") if isinstance(state.get("input"), dict) else {}
return {**source, **params}
def _first_explicit_discount_reason(value: Any) -> tuple[str, str]:
"""Return (reason, source_key) only from explicit backend evidence.
This deliberately does not infer expiration from installment counters,
absence of a discount, plan names or the customer's wording. Causal
claims must be supplied by a system of record/mock contract.
"""
reason_keys = {
"discount_reason", "discountReason", "motivo_desconto", "motivoDesconto",
"termination_reason", "terminationReason", "motivo_termino", "motivoTermino",
"promotion_end_reason", "promotionEndReason",
}
status_keys = {"discount_status", "discountStatus", "promotion_status", "promotionStatus"}
end_date_keys = {"promotion_end_date", "promotionEndDate", "discount_end_date", "discountEndDate"}
def walk(obj: Any) -> tuple[str, str]:
if isinstance(obj, dict):
for key, item in obj.items():
if key in reason_keys and isinstance(item, (str, int, float)) and str(item).strip():
return str(item).strip(), key
for key, item in obj.items():
if key in status_keys and str(item or "").strip().upper() in {
"EXPIRED", "ENDED", "TERMINATED", "ENCERRADO", "EXPIRADO", "FINALIZADO"
}:
return f"status explícito: {str(item).strip()}", key
for key, item in obj.items():
if key in end_date_keys and isinstance(item, (str, int, float)) and str(item).strip():
return f"data de término registrada: {str(item).strip()}", key
for item in obj.values():
found = walk(item)
if found[0]:
return found
elif isinstance(obj, list):
for item in obj:
found = walk(item)
if found[0]:
return found
return "", ""
return walk(value)
def _discount_record_from_evidence(value: Any) -> dict[str, Any]:
"""Select the most relevant discount record from authoritative evidence."""
if not isinstance(value, dict):
return {}
rows = value.get("discounts") if isinstance(value.get("discounts"), list) else []
candidates = [row for row in rows if isinstance(row, dict)]
if not candidates:
return {}
terminal_statuses = {"EXPIRED", "ENDED", "TERMINATED", "ENCERRADO", "EXPIRADO", "FINALIZADO"}
for row in candidates:
if str(row.get("discount_status") or row.get("status") or "").strip().upper() in terminal_statuses:
return row
return candidates[0]
@staticmethod
def _format_brl(value: Any) -> str:
try:
number = float(value)
except (TypeError, ValueError):
return ""
return f"R$ {number:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
def _format_iso_date_br(value: Any) -> str:
text = str(value or "").strip()
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", text):
year, month, day = text.split("-")
return f"{day}/{month}/{year}"
return text
def _plan_names_from_invoice_detail(invoice_detail: Any) -> list[str]:
names: list[str] = []
if not isinstance(invoice_detail, dict):
return names
for bucket in invoice_detail.values():
if not isinstance(bucket, dict):
continue
planos = bucket.get("Planos")
if isinstance(planos, dict):
for name in planos:
text = str(name or "").strip()
if text and text not in names:
names.append(text)
return names
@reg.action("formatar_capability_resposta")
def formatar_capability_resposta(params: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
typ = str(params.get("tipo") or "")
if typ == "termino_desconto":
plan = str(params.get("nome_plano") or "seu plano")
msg = f"Identifiquei que a variação está relacionada ao término de um desconto do {plan}."
ev = [MPITag.TERMINO_DESCONTO]
context = _discount_evidence_context(params, state)
evidence = {
"discount_evidence": context.get("discount_evidence"),
"invoice_detail": context.get("invoice_detail"),
"billing_analysis": context.get("billing_analysis"),
"plan_data": context.get("plan_data"),
}
reason, reason_source = _first_explicit_discount_reason(evidence)
record = _discount_record_from_evidence(context.get("discount_evidence"))
requested_plan = str(context.get("nome_plano") or "").strip()
discovered_plans = _plan_names_from_invoice_detail(context.get("invoice_detail"))
plan = str(record.get("plan_name") or requested_plan or (discovered_plans[0] if len(discovered_plans) == 1 else "")).strip()
plan_text = f" do plano {plan}" if plan else ""
# Prefer the human-readable causal description from the system of
# record. The code remains audit metadata, not customer-facing prose.
reason_description = str(
record.get("termination_reason_description")
or record.get("discount_reason_description")
or ""
).strip()
if reason_description:
reason = reason_description
reason_source = "termination_reason_description"
if reason:
discount_name = str(record.get("discount_name") or "").strip()
previous_value = _format_brl(record.get("previous_value"))
end_date = _format_iso_date_br(record.get("end_date") or record.get("discount_end_date"))
subject = f"do desconto {discount_name}" if discount_name else "do desconto"
details: list[str] = []
if previous_value:
details.append(f"no valor de {previous_value}")
if end_date:
details.append(f"em {end_date}")
detail_text = (", " + ", ".join(details)) if details else ""
clean_reason = reason.rstrip(" .")
msg = f"Identifiquei o término {subject}{plan_text}{detail_text}. Motivo informado pelo sistema: {clean_reason}."
# O histórico de desconto descreve a situação contratual em uma
# data de referência, enquanto a última fatura pode cobrir um
# período anterior. Quando o backend fornece ambas as referências
# temporais, explicite a diferença sem inferir causalidade.
last_billed_value = _format_brl(record.get("last_billed_discount_value"))
last_billed_period = str(record.get("last_billed_period") or "").strip()
last_invoice_issue_date = _format_iso_date_br(record.get("last_invoice_issue_date"))
as_of_date = _format_iso_date_br(record.get("as_of_date"))
current_value = record.get("current_value")
current_value_reference = str(record.get("current_value_reference") or "").strip()
if (
current_value_reference == "contract_as_of_date"
and current_value in (0, 0.0, "0", "0.0", "0.00")
and last_billed_value
and last_billed_period
):
billed_parts = [
f"O último período faturado com esse desconto foi {last_billed_period}",
f"com {last_billed_value} de desconto",
]
if last_invoice_issue_date:
billed_parts.append(f"na fatura emitida em {last_invoice_issue_date}")
contract_ref = f"; a situação contratual em {as_of_date} já consta como encerrada" if as_of_date else "; a situação contratual atual já consta como encerrada"
msg += " " + ", ".join(billed_parts) + contract_ref + "."
grounded = True
else:
msg = (
f"Identifiquei dados de desconto{plan_text}, mas os dados disponíveis não informam "
"o motivo da retirada ou do término do desconto."
)
grounded = False
return {
"mensagem": msg,
"business_events": _events(MPITag.TERMINO_DESCONTO),
"discount_reason_grounded": grounded,
"discount_reason": reason or None,
"discount_reason_source": reason_source or None,
"discount_record": record or None,
"evidence_policy": "explicit_reason_only",
"epistemic_status": "grounded_fact" if grounded else "insufficient_evidence",
}
elif typ == "valor_divergente":
msg = "Identifiquei divergência de valor na fatura. Vou considerar os dados da fatura e do billing analysis para orientar a tratativa."
msisdn = str(params.get("msisdn") or "").strip()
suffix = msisdn[-2:] if len(msisdn) >= 2 else msisdn
line = f" na linha final {suffix}" if suffix else ""
msg = f"Identifiquei uma alteração no valor do plano{line}."
ev = [MPITag.VALOR_DIVERGENTE]
else:
msg = str(params.get("mensagem") or "")

View File

@@ -19,6 +19,72 @@ def _context_text(context: dict[str, Any]) -> str:
except Exception: return str(context or {})[:16000]
def _authorized_human_handoff(context: dict[str, Any]) -> bool:
"""Return True only for structurally authorized human handoff on this turn."""
ctx = context or {}
route = str(ctx.get('current_route') or ctx.get('route') or '').strip().lower()
intent = str(ctx.get('current_intent') or ctx.get('intent') or '').strip().lower()
session_control = str(ctx.get('session_control') or '').strip().upper()
requested = ctx.get('human_handoff_requested') is True
handoff = ctx.get('handoff') is True
route_decision = ctx.get('route_decision') if isinstance(ctx.get('route_decision'), dict) else {}
route_meta = route_decision.get('metadata') if isinstance(route_decision.get('metadata'), dict) else {}
rd_route = str(route_decision.get('route') or route_decision.get('agent') or '').strip().lower()
rd_intent = str(route_decision.get('intent') or '').strip().lower()
rd_handoff = route_decision.get('handoff') is True
rd_session_control = str(route_meta.get('session_control') or '').strip().upper()
control_evidence = (
session_control == 'HUMAN_HANDOFF'
or requested
or handoff
or rd_handoff
or rd_session_control == 'HUMAN_HANDOFF'
)
route_evidence = (
route == 'human_handoff'
or intent == 'human_handoff'
or rd_route == 'human_handoff'
or rd_intent == 'human_handoff'
)
if control_evidence and route_evidence:
return True
# A resumed domain workflow may decide the handoff after the router has
# already been bypassed for workflow continuation. In that case the
# current route/intent legitimately remain the domain values, while the
# *current-turn workflow result* is the authoritative orchestration
# decision. Accept only a terminal, internally consistent handoff result;
# an isolated `handoff=true` or transfer-like sentence is never enough.
roots = []
for key in ('mcp_results', 'tool_result', 'workflow_result'):
value = ctx.get(key)
if value is not None:
roots.append(value)
def terminal_workflow_handoff(value: Any) -> bool:
if isinstance(value, dict):
workflow_control = str(value.get('session_control') or '').strip().upper()
workflow_terminal = str(value.get('terminal_status') or '').strip().lower()
workflow_requested = value.get('human_handoff_requested') is True
workflow_handoff = value.get('handoff') is True
workflow_session_ended = value.get('session_ended') is True
has_control = workflow_control == 'HUMAN_HANDOFF'
has_request = workflow_requested or workflow_handoff
has_terminal = workflow_terminal == 'human_handoff' or workflow_session_ended
if has_control and has_request and has_terminal:
return True
return any(terminal_workflow_handoff(item) for item in value.values())
if isinstance(value, (list, tuple)):
return any(terminal_workflow_handoff(item) for item in value)
return False
return any(terminal_workflow_handoff(root) for root in roots)
def _parse_json(raw: Any) -> dict[str, Any]:
text = str(getattr(raw, 'content', raw) or '').strip()
m = re.search(r'\{[\s\S]*\}', text)
@@ -49,6 +115,27 @@ class _TimPromptRail(Guardrail):
class TimOutOfScopeRail(_TimPromptRail):
code='TIM_OOS'; stage='output'; prompt_builder=staticmethod(build_oos_prompt)
async def evaluate(self, text: str, context: dict[str, Any]) -> RailDecision:
# A handoff structurally selected by the router/workflow is an authorized
# orchestration response, not a domain answer to be judged as OOS by text.
if _authorized_human_handoff(context):
return RailDecision(
code=self.code,
allowed=True,
reason='handoff_humano_autorizado',
sanitized_text=text,
metadata={
'external': True,
'domain': 'TIM_CONTAS',
'mechanism': 'deterministic_handoff_bypass',
'data': {
'allowed': True,
'reason': 'handoff_humano_autorizado',
},
},
)
return await super().evaluate(text, context)
class TimProactiveOfferRail(_TimPromptRail):
code='TIM_AOFERTA'; stage='output'; prompt_builder=staticmethod(build_aoferta_prompt)
@@ -61,6 +148,10 @@ class TimProactiveOfferRail(_TimPromptRail):
'AWAITING_CONFIRMATION',
}
@staticmethod
def _authorized_human_handoff(context: dict[str, Any]) -> bool:
return _authorized_human_handoff(context)
@classmethod
def _transaction_continuation_status(cls, context: dict[str, Any]) -> str | None:
ctx = context or {}
@@ -83,6 +174,23 @@ class TimProactiveOfferRail(_TimPromptRail):
return None
async def evaluate(self, text: str, context: dict[str, Any]) -> RailDecision:
if self._authorized_human_handoff(context):
return RailDecision(
code=self.code,
allowed=True,
reason='handoff_humano_autorizado',
sanitized_text=text,
metadata={
'external': True,
'domain': 'TIM_CONTAS',
'mechanism': 'deterministic_handoff_bypass',
'data': {
'allowed': True,
'reason': 'handoff_humano_autorizado',
},
},
)
continuation_status = self._transaction_continuation_status(context)
if continuation_status:
return RailDecision(
@@ -106,7 +214,54 @@ class TimProactiveOfferRail(_TimPromptRail):
class TimPrematureActionRail(_TimPromptRail):
code='TIM_REVPREC'; stage='output'; profile_name='grl'; prompt_builder=staticmethod(build_revprec_prompt)
@staticmethod
def _structured_insufficient_evidence_message(text: str, context: dict[str, Any]) -> bool:
"""Allow an epistemically conservative tool/workflow answer without LLM re-judgment.
The bypass is intentionally structural and exact-message based. An arbitrary
assistant sentence saying "não sei" is *not* enough: the current-turn tool
result must explicitly declare ``epistemic_status=insufficient_evidence`` and
expose the same ``mensagem`` that is being sent to the customer.
"""
expected = " ".join(str(text or "").split())
if not expected:
return False
roots = []
ctx = context or {}
for key in ('mcp_results', 'tool_result', 'evidence'):
value = ctx.get(key)
if value is not None:
roots.append(value)
def walk(value: Any) -> bool:
if isinstance(value, dict):
status = str(value.get('epistemic_status') or '').strip().lower()
message = " ".join(str(value.get('mensagem') or '').split())
if status == 'insufficient_evidence' and message and message == expected:
return True
return any(walk(item) for item in value.values())
if isinstance(value, (list, tuple)):
return any(walk(item) for item in value)
return False
return any(walk(root) for root in roots)
async def evaluate(self, text: str, context: dict[str, Any]) -> RailDecision:
if self._structured_insufficient_evidence_message(text, context):
return RailDecision(
code=self.code,
allowed=True,
reason='insufficient_evidence_non_assertive',
sanitized_text=text,
metadata={
'external': True,
'domain': 'TIM_CONTAS',
'mechanism': 'deterministic_epistemic_bypass',
'epistemic_status': 'insufficient_evidence',
},
)
llm = _llm(context)
if llm is None:
return RailDecision(code=self.code, allowed=False, reason='LLM do framework indisponível para guardrail TIM', metadata={'external': True, 'fail_closed': True})

View File

@@ -74,6 +74,9 @@ Responda 0 em todo o resto. Em particular:
parceiro você consegue cancelar".
- NEGATIVA de ação: "não consigo cancelar por aqui", "ainda não cancelei", "esse serviço
não pode ser cancelado neste atendimento".
- AUSÊNCIA DE EVIDÊNCIA ou incerteza explícita: "os dados disponíveis não informam o
motivo", "não há evidência suficiente para confirmar a causa", "não foi possível
identificar o motivo". Isso NÃO afirma execução nem resultado operacional; responda 0.
- EXPLICAÇÃO, valor, data, encerramento, saudação, ou qualquer assunto que não seja
ação de cancelamento dada como feita.

View File

@@ -33,6 +33,7 @@ from agent_framework.billing.usage_repository import create_usage_repository
from agent_framework.sse.events import SSEHub
from app.workflows.agent_graph import AgentWorkflow
from app.observability.telemetry_observer import TelemetryBackedAgentObserver
from app.domain.contas.line_reference import extract_requested_line_reference
logging.basicConfig(level=settings.LOG_LEVEL)
logger = logging.getLogger("agent_template_backend")
@@ -184,6 +185,11 @@ async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False)
raise HTTPException(status_code=422, detail=str(exc)) from exc
payload = req.payload or {}
identity, normalized_context, business_context, missing_identity_keys = _resolve_identity(req, msg)
requested_line_reference = extract_requested_line_reference(msg.text)
if requested_line_reference:
# Referência conversacional apenas. A política de domínio decide se ela
# pode ou não virar linha efetiva; nunca substitui a identidade aqui.
normalized_context["requested_line_reference"] = requested_line_reference
agent_session_id = identity.conversation_key()
message_id = payload.get("message_id") or str(uuid4())
workflow_id = _extract_workflow_id(payload)

View File

@@ -60,3 +60,5 @@ class AgentState(TypedDict, total=False):
long_term_memory_write_result: dict[str, Any]
long_term_memory_subject_key: str
long_term_memory_load_error: str
operational_context_boundary_pending: bool
operational_context_reset: bool

View File

@@ -190,7 +190,8 @@ class AgentWorkflow:
== "intent_shift"
)
stickiness_intent_shift = bool(route_metadata.get("route_stickiness_preempted"))
should_isolate_history = semantic_intent_shift or (terminal_tx and stickiness_intent_shift)
operational_context_reset = bool(state.get("operational_context_reset"))
should_isolate_history = operational_context_reset or semantic_intent_shift or (terminal_tx and stickiness_intent_shift)
current_route = str(
state.get("route")
@@ -206,6 +207,24 @@ class AgentWorkflow:
ctx["current_route"] = current_route
ctx["current_intent"] = current_intent
# Structured control evidence for domain guardrails. A rail must not
# infer an authorized transfer from prose; it receives the router/workflow
# decision explicitly for the current turn.
session_control = str(
state.get("session_control")
or route_metadata.get("session_control")
or ""
).strip().upper()
route_handoff = bool(
(route_decision.get("handoff") if isinstance(route_decision, dict) else False)
or state.get("human_handoff_requested")
or session_control == "HUMAN_HANDOFF"
)
ctx["session_control"] = session_control
ctx["human_handoff_requested"] = route_handoff
ctx["handoff"] = route_handoff
ctx["route_decision"] = route_decision
if should_isolate_history:
operational_history = (
[{"role": "user", "content": current_user_text}]
@@ -292,7 +311,9 @@ class AgentWorkflow:
builder.add_conditional_edges(
"input_guardrails",
self._after_input_guardrails,
{"blocked": "persist", "continue": "load_long_term_memory"},
# Mesmo uma resposta produzida por um bloqueio de input deve passar
# pelos guardrails de saída antes de ser entregue ao usuário.
{"blocked": "output_guardrails", "continue": "load_long_term_memory"},
)
builder.add_edge("load_long_term_memory", "routing_decision")
builder.add_conditional_edges(
@@ -318,7 +339,13 @@ class AgentWorkflow:
builder.add_edge("end_session", "output_supervisor")
builder.add_edge("supervisor_agent", "output_supervisor")
builder.add_edge("output_supervisor", "output_guardrails")
builder.add_edge("output_guardrails", "judge")
builder.add_conditional_edges(
"output_guardrails",
lambda s: "blocked" if s.get("blocked") else "continue",
# Clarificações geradas por guardrail de entrada não devem ser
# julgadas nem gravadas em LTM como se fossem uma resposta normal.
{"blocked": "persist", "continue": "judge"},
)
builder.add_edge("judge", "supervisor_review")
builder.add_edge("supervisor_review", "persist_long_term_memory")
builder.add_edge("persist_long_term_memory", "persist")
@@ -329,6 +356,41 @@ class AgentWorkflow:
def _after_input_guardrails(self, state):
return "blocked" if state.get("blocked") else "continue"
@staticmethod
def _input_guardrail_user_message(decisions, state, sanitized_text):
"""Converte um bloqueio técnico em mensagem útil sem expor internals.
O `reason` bruto continua em `guardrail_decisions`/telemetria para
auditoria. A mensagem ao usuário é específica por classe de rail e
segue depois pelos guardrails de saída.
"""
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
first = blocked[0] if blocked else None
code = str(getattr(first, "code", "") or "").upper()
reason = str(getattr(first, "reason", "") or "").strip()
if code == "COER":
# COER representa ambiguidade/incompletude, não uma violação de
# segurança. Não ecoamos o reason bruto do modelo.
return (
"Não consegui entender sua última mensagem porque ela parece "
"incompleta ou ambígua. Pode reformular ou completar o que você quis dizer?"
)
if code == "INPUT_SIZE":
return "Sua mensagem ficou muito longa para eu processar de uma vez. Pode resumir ou dividir em partes?"
if code == "DLEX_IN":
return "Não posso usar essa informação da forma solicitada. Reformule o pedido sem incluir dados ou conteúdo restrito."
if code == "PINJ":
return "Não posso seguir instruções que tentem alterar as regras do atendimento. Posso continuar ajudando com a sua solicitação."
if code == "TOX":
return "Não consegui prosseguir com essa mensagem. Pode reformular o pedido para continuarmos o atendimento?"
if code == "CMP":
return "Não posso prosseguir com essa solicitação dessa forma. Posso ajudar com uma alternativa permitida."
# Fallback neutro: não atribui falsamente o problema a 'segurança' e
# não expõe nomes, razões ou políticas internas dos guardrails.
return "Não consegui processar essa mensagem. Pode reformular para eu continuar o atendimento?"
async def input_guardrails(self, state):
if state.get("session_ended") is True:
answer = str(getattr(
@@ -353,7 +415,51 @@ class AgentWorkflow:
session_id=state.get("conversation_key") or state.get("session_id"),
input=state.get("user_text"),
):
history_texts = [m.get("content", "") for m in state.get("history", [])]
boundary_pending = bool(state.get("operational_context_boundary_pending"))
tx_status = str(state.get("transaction_status") or "").strip().upper()
terminal_interaction = tx_status in {"COMPLETED", "FAILED", "CANCELLED", "BLOCKED", "OUT_OF_SCOPE"}
reset_operational_context = boundary_pending or terminal_interaction
# The durable history/checkpoint is preserved, but the first turn
# after a completed workflow must look operationally like a fresh
# conversation. Guardrails therefore see only the current utterance.
history_texts = (
[str(state.get("user_text") or "")]
if reset_operational_context
else [m.get("content", "") for m in state.get("history", [])]
)
if reset_operational_context:
# Tombstone every live latch that can make the next turn look
# like a continuation of the closed workflow/transaction.
state.update({
"pending_domain_workflow": None,
"pending_tool_clarification": None,
"workflow_input_reprompt": None,
"active_transaction": None,
"selected_tool_call": {},
"pending_tool_call": {},
"missing_parameters": [],
"confirmation_required": False,
"confirmation_received": False,
"transaction_pre_validation": None,
"tool_policy_result": None,
"tool_terminal_result": None,
"transaction_confirmation_message_override": None,
"next_state": None,
"mcp_tools": [],
"mcp_results": [],
"relevant_transaction_evidence": [],
"route": None,
"intent": None,
"route_decision": {},
"active_agent": None,
"route_bypassed": False,
"continuity_signal": {},
"workflow_id": None,
"transaction_status": None,
"operational_context_boundary_pending": False,
"operational_context_reset": True,
})
await self.observer.emit_grl(
"001",
{
@@ -364,6 +470,13 @@ class AgentWorkflow:
},
component="workflow.input_guardrails.start",
)
pending_workflow = None if reset_operational_context else state.get("pending_domain_workflow")
pause = (
pending_workflow.get("pause")
if isinstance(pending_workflow, dict) and isinstance(pending_workflow.get("pause"), dict)
else {}
)
expected_input = pause.get("expected_input") if isinstance(pause, dict) else None
sanitized, decisions = await self.guardrails.run_input(
state["user_text"],
{
@@ -372,6 +485,17 @@ class AgentWorkflow:
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"agent_profile": state.get("agent_profile") or {},
# Generic workflow contract context. COER delegates only
# conversational coherence to this contract; all other
# safety rails continue to execute normally.
"expected_input": expected_input,
# Active transaction parameter contracts own the semantic
# interpretation of short replies such as a product name or
# identifier. COER delegates coherence only; PINJ/DLEX/TOX
# and every other safety rail still run normally.
"transaction_status": state.get("transaction_status"),
"missing_parameters": list(state.get("missing_parameters") or []),
"active_transaction": state.get("active_transaction") or {},
},
)
for _decision in decisions:
@@ -413,18 +537,73 @@ class AgentWorkflow:
component="workflow.input_guardrails.final",
)
if any(not d.allowed for d in decisions):
user_message = self._input_guardrail_user_message(decisions, state, sanitized)
return {
"sanitized_input": sanitized,
"answer": "Não consegui seguir com essa mensagem por regra de segurança.",
"final_answer": "Não consegui seguir com essa mensagem por regra de segurança.",
"answer": user_message,
# final_answer será calculado por output_guardrails.
"final_answer": None,
"guardrail_decisions": [d.model_dump() for d in decisions],
"route": "blocked",
"intent": "input_guardrail_blocked",
"route_decision": {
"route": "blocked",
"agent": None,
"intent": "input_guardrail_blocked",
"confidence": 1.0,
"reason": "Entrada interrompida por guardrail antes do roteamento.",
"method": "guardrail",
"next_state": state.get("next_state"),
"handoff": False,
"metadata": {},
"domain": state.get("domain"),
"mcp_tools": [],
},
# Evita vazar resultados/rota do turno anterior quando o
# bloqueio acontece antes do roteamento do turno atual.
"mcp_tools": [],
"mcp_results": [],
"judge_results": [],
**({
"pending_domain_workflow": None,
"pending_tool_clarification": None,
"workflow_input_reprompt": None,
"active_transaction": None,
"selected_tool_call": {},
"pending_tool_call": {},
"missing_parameters": [],
"confirmation_required": False,
"confirmation_received": False,
"transaction_pre_validation": None,
"tool_policy_result": None,
"tool_terminal_result": None,
"transaction_confirmation_message_override": None,
"next_state": None,
"mcp_tools": [],
"mcp_results": [],
"relevant_transaction_evidence": [],
"route": None,
"intent": None,
"route_decision": {},
"active_agent": None,
"route_bypassed": False,
"continuity_signal": {},
"workflow_id": None,
"transaction_status": None,
"operational_context_boundary_pending": False,
"operational_context_reset": True,
} if reset_operational_context else {}),
"blocked": True,
}
return {
"sanitized_input": sanitized,
"guardrail_decisions": [d.model_dump() for d in decisions],
"blocked": False,
**({
"pending_domain_workflow": None,
"pending_tool_clarification": None,
"workflow_input_reprompt": None,
} if terminal_interaction else {}),
}
async def routing_decision(self, state):
@@ -615,6 +794,19 @@ class AgentWorkflow:
"session_ended": True,
"terminal_status": "nao_resolvido",
"next_state": "HUMAN_HANDOFF_REQUESTED",
# Human handoff terminates the operational interaction. Keep the
# durable history/checkpoint, but do not leave a paused workflow
# or transactional latch active in the live session.
"pending_domain_workflow": None,
"active_transaction": None,
"transaction_pre_validation": None,
"transaction_status": "CANCELLED",
"pending_tool_call": {},
"selected_tool_call": {},
"missing_parameters": [],
"confirmation_required": False,
"confirmation_received": False,
"mcp_results": [],
}
async def end_session(self, state):
@@ -995,6 +1187,8 @@ class AgentWorkflow:
"answer_chars": len(state.get("final_answer") or ""),
},
)
if state.get("operational_context_reset"):
state["operational_context_reset"] = False
return state
async def ainvoke(self, state):