Ajustes conforme relatorio de testes 2026-08-27
This commit is contained in:
Binary file not shown.
Binary file not shown.
BIN
app/__pycache__/mcp_gateway_client_factory.cpython-313.pyc
Normal file
BIN
app/__pycache__/mcp_gateway_client_factory.cpython-313.pyc
Normal file
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.
@@ -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(
|
||||
|
||||
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.
BIN
app/domain/contas/__pycache__/line_reference.cpython-313.pyc
Normal file
BIN
app/domain/contas/__pycache__/line_reference.cpython-313.pyc
Normal file
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.
@@ -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 "")
|
||||
|
||||
@@ -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,
|
||||
|
||||
23
app/domain/contas/fixtures/authorized_lines.json
Normal file
23
app/domain/contas/fixtures/authorized_lines.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
52
app/domain/contas/fixtures/discount_history.json
Normal file
52
app/domain/contas/fixtures/discount_history.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
55
app/domain/contas/line_reference.py
Normal file
55
app/domain/contas/line_reference.py
Normal 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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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})
|
||||
|
||||
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.
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user