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

View File

@@ -25,12 +25,16 @@
"success": true
}
},
"checar_vas_variacao": {
"handoff_pos_explicacao_nao": {
"output": {
"success": true,
"tem_vas_variado": true,
"qtd_vas_variados": 2,
"decisao_vas_variacao": "tem_vas_variado"
"mensagem": "Para continuar com a sua solicitação, aguarde um instante.",
"session_control": "HUMAN_HANDOFF",
"human_handoff_requested": true,
"handoff": true,
"session_ended": true,
"terminal_status": "human_handoff",
"handoff_reason": "invoice_explanation_not_resolved"
}
}
},
@@ -38,21 +42,25 @@
"status": "WAITING_INPUT",
"paused_at": "formatar",
"expected_input_key": "resposta_usuario",
"allowed_values": ["SIM", "NAO"]
"allowed_values": [
"SIM",
"NAO",
"CONTINUAR"
]
},
"resume": {
"resposta_usuario": "NAO"
},
"expect": {
"status": "COMPLETED",
"last_node": "checar_vas_variacao",
"last_node": "handoff_pos_explicacao_nao",
"trace_nodes": [
"preparar",
"formatar",
"formatar",
"decisao",
"registrar_nao",
"checar_vas_variacao"
"handoff_pos_explicacao_nao"
]
}
}

View File

@@ -25,12 +25,16 @@
"success": true
}
},
"checar_vas_variacao": {
"handoff_pos_explicacao_nao": {
"output": {
"success": true,
"tem_vas_variado": false,
"qtd_vas_variados": 0,
"decisao_vas_variacao": "finaliza_nao_resolvido"
"mensagem": "Para continuar com a sua solicitação, aguarde um instante.",
"session_control": "HUMAN_HANDOFF",
"human_handoff_requested": true,
"handoff": true,
"session_ended": true,
"terminal_status": "human_handoff",
"handoff_reason": "invoice_explanation_not_resolved"
}
}
},
@@ -38,22 +42,25 @@
"status": "WAITING_INPUT",
"paused_at": "formatar",
"expected_input_key": "resposta_usuario",
"allowed_values": ["SIM", "NAO"]
"allowed_values": [
"SIM",
"NAO",
"CONTINUAR"
]
},
"resume": {
"resposta_usuario": "NAO"
},
"expect": {
"status": "COMPLETED",
"last_node": "finalizar_nao_resolvido",
"last_node": "handoff_pos_explicacao_nao",
"trace_nodes": [
"preparar",
"formatar",
"formatar",
"decisao",
"registrar_nao",
"checar_vas_variacao",
"finalizar_nao_resolvido"
"handoff_pos_explicacao_nao"
]
}
}

View File

@@ -37,7 +37,11 @@
"status": "WAITING_INPUT",
"paused_at": "formatar",
"expected_input_key": "resposta_usuario",
"allowed_values": ["SIM", "NAO"]
"allowed_values": [
"SIM",
"NAO",
"CONTINUAR"
]
},
"resume": {
"resposta_usuario": "SIM"

View File

@@ -0,0 +1,74 @@
from pathlib import Path
from agent_framework.routing.config_loader import load_intents
from agent_framework.routing.enterprise_router import EnterpriseRouter
ROOT = Path(__file__).resolve().parents[2]
def _router_for_config() -> EnterpriseRouter:
router = EnterpriseRouter.__new__(EnterpriseRouter)
router.intents = load_intents(ROOT / "config" / "routing.yaml")
return router
def test_non_recognition_without_transactional_action_routes_to_invoice_explanation():
router = _router_for_config()
decision = router._route_by_keyword(
"contratamos o plano de duzentos e nove, mas eu não estou reconhecendo "
"itens eventuais sessenta e seis e noventa e seis"
)
assert decision is not None
assert decision.intent == "contas_invoice_explanation"
assert decision.agent == "faturas_agent"
assert decision.mcp_tools == ["invoice_explanation"]
def test_explicit_contestation_wins_over_non_recognition_language():
router = _router_for_config()
decision = router._route_by_keyword(
"não reconheço essa cobrança e quero contestar agora"
)
assert decision is not None
assert decision.intent == "contas_contestation"
assert decision.agent == "contestacao_agent"
assert "contestar_cobranca" in decision.mcp_tools
def test_contestation_intent_supports_explicit_action_and_contextual_reentry():
intents = {item.name: item for item in load_intents(ROOT / "config" / "routing.yaml")}
contest = intents["contas_contestation"]
explanation = intents["contas_invoice_explanation"]
assert "reentrada contextual" in contest.description
assert "não reconheço essa cobrança" not in contest.keywords
assert "não estou reconhecendo" in explanation.keywords
assert explanation.examples[0].startswith("Contratamos um plano")
assert "Contexto anterior" in contest.examples[0]
def test_mig0003_causal_bill_increase_routes_to_invoice_explanation():
router = _router_for_config()
decision = router._route_by_keyword("por que minha conta subiu esse mês?")
assert decision is not None
assert decision.intent == "contas_invoice_explanation"
assert decision.agent == "faturas_agent"
assert decision.mcp_tools == ["invoice_explanation"]
def test_generic_invoice_lookup_still_routes_to_invoice_query():
router = _router_for_config()
decision = router._route_by_keyword("quero minha fatura")
assert decision is not None
assert decision.intent == "contas_invoice_query"
assert decision.mcp_tools == ["consultar_faturas"]
def test_invoice_query_does_not_keep_ambiguous_minha_conta_keyword():
intents = {item.name: item for item in load_intents(ROOT / "config" / "routing.yaml")}
invoice_query = intents["contas_invoice_query"]
explanation = intents["contas_invoice_explanation"]
assert "minha conta" not in invoice_query.keywords
assert "minha conta subiu" in explanation.keywords
assert explanation.priority > invoice_query.priority

View File

@@ -0,0 +1,30 @@
from decimal import Decimal
from app.domain.contas.client import TimApiClient
from app.domain.contas.workflow_actions import _classify_contestation_items
def test_mock_contestar_filters_provider_response_to_current_transaction(monkeypatch):
monkeypatch.setenv("TIM_USE_MOCK_GATEWAY", "true")
client = TimApiClient()
result = client.contestar({
"sr": "1234567890",
"items": [{"itemName": "Tamboro Mensal", "claimedAmount": "14.99", "validatedAmount": "14.99"}],
})
assert result["sr"] == "1234567890"
assert [x["itemName"] for x in result["itemsResponse"]] == ["Tamboro Mensal"]
assert result["itemsResponse"][0]["message"] == "Contestação criada/atualizada com sucesso"
assert "items_response" not in result
assert "validated_items" not in result
assert "protocol_number" not in result
def test_contestation_amount_does_not_convert_decimal_dot_to_cents():
requested = [{"item_name": "Tamboro Mensal", "claimed_amount": "14.99", "validated_amount": "14.99"}]
response = [{"itemName": "Tamboro Mensal", "status": "INICIADA", "correctAccountStatus": "CRIAR"}]
contested, not_contested, already, open_amount, total_amount = _classify_contestation_items(requested, response)
assert len(contested) == 1
assert not not_contested
assert not already
assert Decimal(open_amount) == Decimal("14.99")
assert Decimal(total_amount) == Decimal("14.99")

View File

@@ -0,0 +1,55 @@
from pathlib import Path
import asyncio
import yaml
def test_contestation_subject_schema_requires_concrete_invoice_entity():
cfg = yaml.safe_load(Path("config/tools.yaml").read_text(encoding="utf-8"))
tools = cfg["tools"] if "tools" in cfg else cfg
for tool_name in ("validar_contestacao", "contestar_cobranca"):
subject = tools[tool_name]["args_schema"]["subject"]
desc = subject["description"].lower()
assert "item concreto" in desc
assert "evidência da fatura" in desc or "evidencia da fatura" in desc
assert subject["user_prompt"]
def test_contestation_prompt_forbids_internal_validation_language():
prompt = Path("config/prompts/contestation.yaml").read_text(encoding="utf-8")
assert "nunca exponha códigos" in prompt.lower()
assert "CVAL" in prompt
assert "JSON" in prompt
assert "guardrail_code" in prompt
def test_validator_turns_unresolved_subject_into_recoverable_parameter_request(monkeypatch):
from contas_mcp.servers.contas_mcp_server import main
async def no_enrich(_name, _args):
return None
monkeypatch.setattr(main, "_enrich_invoice_context", no_enrich)
monkeypatch.setattr(main, "_preflight_subject", lambda *_args, **_kwargs: None)
args = {
"subject": "fatura",
"valor": 10.0,
"target_tool": "contestar_cobranca",
"billing_analysis": {
"currentInvoice": [
{
"type": "servicos_contratados_de_parceiros",
"items": [
{"desc": "TIM Fashion Mensal", "value": "10.0", "contestable": True}
],
}
]
},
}
result = asyncio.run(main._validate_contestation(args))
assert result["eligible"] is False
assert result["status"] == "NEEDS_PARAMETER"
assert result["parameter"] == "subject"
assert result["reason"] == "subject_not_resolved"
assert result["resolved_value"] == 10.0
assert result["metadata"]["entity_resolution"] == "invoice_evidence"
assert "error" not in result

View File

@@ -0,0 +1,90 @@
from pathlib import Path
from contas_mcp.servers.contas_mcp_server.discount_history_service import DiscountHistoryMockService
from app.domain.contas import ContasDomainService
from app.domain.contas.workflow_actions import build_contas_workflow_actions
FIXTURE = Path(__file__).resolve().parents[2] / "app" / "domain" / "contas" / "fixtures" / "discount_history.json"
def test_discount_history_mock_returns_structured_authoritative_reason():
service = DiscountHistoryMockService(FIXTURE)
result = service.consultar_historico_descontos(msisdn="11999999999")
assert result["success"] is True
expired = next(row for row in result["discounts"] if row["discount_status"] == "EXPIRED")
assert expired["plan_name"] == "TIM Black A 8.0"
assert expired["previous_value"] == 80.0
assert expired["current_value"] == 0.0
assert expired["termination_reason"] == "FIM_PERIODO_FIDELIDADE"
assert expired["termination_reason_description"] == "O período de fidelidade contratado foi encerrado."
def test_discount_history_mock_can_filter_plan_without_inventing_other_reason():
service = DiscountHistoryMockService(FIXTURE)
result = service.consultar_historico_descontos(msisdn="11999999999", nome_plano="CTRL")
assert result["success"] is True
assert len(result["discounts"]) == 1
assert result["discounts"][0]["discount_status"] == "ACTIVE"
assert result["discounts"][0]["termination_reason"] is None
def test_termino_desconto_formats_only_structured_discount_history_facts():
service = DiscountHistoryMockService(FIXTURE)
evidence = service.consultar_historico_descontos(msisdn="11999999999", nome_plano="TIM Black")
action = build_contas_workflow_actions(ContasDomainService()).get("formatar_capability_resposta")
result = action({"tipo": "termino_desconto", "discount_evidence": evidence}, {"input": {}})
text = result["mensagem"]
assert result["discount_reason_grounded"] is True
assert result["epistemic_status"] == "grounded_fact"
assert "TIM Black A 8.0" in text
assert "R$ 80,00" in text
assert "20/11/2025" in text
assert "O período de fidelidade contratado foi encerrado." in text
assert "FIM_PERIODO_FIDELIDADE" not in text
def test_termino_desconto_active_discount_without_reason_remains_non_conclusive():
service = DiscountHistoryMockService(FIXTURE)
evidence = service.consultar_historico_descontos(msisdn="11999999999", nome_plano="CTRL")
action = build_contas_workflow_actions(ContasDomainService()).get("formatar_capability_resposta")
result = action({"tipo": "termino_desconto", "discount_evidence": evidence}, {"input": {}})
assert result["discount_reason_grounded"] is False
assert result["epistemic_status"] == "insufficient_evidence"
assert "não informam o motivo" in result["mensagem"]
def test_discount_history_temporal_reference_matches_last_invoice_fixture():
import json
service = DiscountHistoryMockService(FIXTURE)
result = service.consultar_historico_descontos(msisdn="11999999999", nome_plano="TIM Black")
expired = result["discounts"][0]
invoice_fixture = FIXTURE.parent / "invoice_pdf_include_danfe_true.json"
invoice = json.loads(invoice_fixture.read_text(encoding="utf-8"))
summary = {row.get("desc"): row for row in invoice.get("Fatura Resumo", []) if isinstance(row, dict)}
line = invoice["1199999999"]["Planos"]["TIM Black A 8.0"]
billed_discount = next(row for row in line["descontos"] if row["desc"].startswith("Desc Fidel 80 TIM Black"))
assert result["as_of_date"] == "2025-11-20"
assert result["last_billed_period"] == summary["PERÍODO"]["period"] == "14/10 a 13/11"
assert result["last_invoice_issue_date"] == "2025-11-20"
assert summary["EMISSÃO"]["emissao"] == "20/11/2025"
assert expired["current_value"] == 0.0
assert expired["current_value_reference"] == "contract_as_of_date"
assert expired["last_billed_discount_value"] == abs(float(billed_discount["value"])) == 80.0
assert expired["last_billed_status"] == "APPLIED"
def test_termino_desconto_explains_contract_vs_last_billed_period_without_contradiction():
service = DiscountHistoryMockService(FIXTURE)
evidence = service.consultar_historico_descontos(msisdn="11999999999", nome_plano="TIM Black")
action = build_contas_workflow_actions(ContasDomainService()).get("formatar_capability_resposta")
result = action({"tipo": "termino_desconto", "discount_evidence": evidence}, {"input": {}})
text = result["mensagem"]
assert "O último período faturado com esse desconto foi 14/10 a 13/11" in text
assert "com R$ 80,00 de desconto" in text
assert "na fatura emitida em 20/11/2025" in text
assert "a situação contratual em 20/11/2025 já consta como encerrada" in text

View File

@@ -0,0 +1,35 @@
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[2]
def test_contas_transactional_parameters_have_customer_facing_prompts():
data = yaml.safe_load((ROOT / "config" / "tools.yaml").read_text(encoding="utf-8"))
tools = data["tools"]
expected = {
("cancelar_vas_avulso", "subject"): "Qual serviço você deseja cancelar?",
("tratar_vas_estrategico", "subject"): "Qual serviço ou benefício você deseja tratar?",
("validar_contestacao", "subject"): "Qual cobrança ou item você não reconhece?",
("validar_contestacao", "valor"): "Qual é o valor da cobrança?",
("contestar_cobranca", "subject"): "Qual cobrança ou item você deseja contestar?",
("contestar_cobranca", "valor"): "Qual é o valor da cobrança que você deseja contestar?",
}
for (tool, field), prompt in expected.items():
assert tools[tool]["args_schema"][field]["user_prompt"] == prompt
assert field not in prompt.lower().replace("subject", "") if field == "subject" else True
def test_billing_prompt_forbids_unsupported_causal_hypotheses():
prompt = yaml.safe_load((ROOT / "config" / "prompts" / "billing.yaml").read_text(encoding="utf-8"))["system"]
assert "fim de promoção" in prompt
assert "perda de elegibilidade" in prompt
assert "sem evidência explícita" in prompt
assert "Não prometa nem estime valores de faturas futuras" in prompt
def test_support_prompt_does_not_simulate_handoff_or_terminal_success():
prompt = yaml.safe_load((ROOT / "config" / "prompts" / "support.yaml").read_text(encoding="utf-8"))["system"]
assert "Nunca anuncie transferência" in prompt
assert "não simule handoff em texto livre" in prompt
assert "OUT_OF_SCOPE" in prompt

View File

@@ -0,0 +1,68 @@
from __future__ import annotations
import pytest
from app.workflows.agent_graph import AgentWorkflow
def test_output_guardrail_context_exposes_structural_handoff_evidence():
state = {
'context': {},
'user_text': 'quero falar com um atendente',
'route': 'human_handoff',
'intent': 'human_handoff',
'session_control': 'HUMAN_HANDOFF',
'human_handoff_requested': True,
'route_decision': {
'route': 'human_handoff',
'agent': 'human_handoff',
'intent': 'human_handoff',
'handoff': True,
'metadata': {'session_control': 'HUMAN_HANDOFF'},
},
'mcp_results': [{'tool_name': 'invoice_explanation', 'ok': True, 'result': {'status': 'PAUSED'}}],
}
ctx = AgentWorkflow._output_guardrail_context(state)
assert ctx['current_route'] == 'human_handoff'
assert ctx['current_intent'] == 'human_handoff'
assert ctx['session_control'] == 'HUMAN_HANDOFF'
assert ctx['human_handoff_requested'] is True
assert ctx['handoff'] is True
assert ctx['route_decision']['handoff'] is True
@pytest.mark.asyncio
async def test_human_handoff_clears_live_paused_workflow_and_transaction_state():
graph = object.__new__(AgentWorkflow)
class _Span:
async def __aenter__(self): return self
async def __aexit__(self, *args): return False
class _Telemetry:
def span(self, *args, **kwargs): return _Span()
async def event(self, *args, **kwargs): return None
class _Settings:
HUMAN_HANDOFF_MESSAGE = 'Vou encaminhar seu atendimento para uma pessoa.'
graph.telemetry = _Telemetry()
graph.settings = _Settings()
graph.tool_router = None
result = await graph.human_handoff({
'session_id': 's1',
'pending_domain_workflow': {'execution_id': 'wf1', 'status': 'PAUSED'},
'active_transaction': {'tool_name': 'invoice_explanation', 'status': 'WORKFLOW_PAUSED'},
'mcp_results': [{'tool_name': 'invoice_explanation', 'ok': True}],
'route_decision': {'reason': 'pedido explicito'},
})
assert result['session_control'] == 'HUMAN_HANDOFF'
assert result['pending_domain_workflow'] is None
assert result['active_transaction'] is None
assert result['mcp_results'] == []
assert result['transaction_status'] == 'CANCELLED'
assert result['confirmation_required'] is False

View File

@@ -0,0 +1,41 @@
from types import SimpleNamespace
from app.workflows.agent_graph import AgentWorkflow
def decision(code, allowed=False, reason=""):
return SimpleNamespace(code=code, allowed=allowed, reason=reason)
def test_coer_block_is_clarification_not_fake_security_message():
msg = AgentWorkflow._input_guardrail_user_message(
[decision("COER", reason="fala incompreensível ou negação ambígua na transcrição")],
{},
"está cobrando um",
)
assert "incompleta ou ambígua" in msg
assert "regra de segurança" not in msg.lower()
assert "COER" not in msg
def test_other_input_block_has_safe_non_internal_fallback():
msg = AgentWorkflow._input_guardrail_user_message(
[decision("UNKNOWN_RAIL", reason="internal implementation detail")],
{},
"texto",
)
assert "reformular" in msg.lower()
assert "internal implementation detail" not in msg
def test_blocked_input_is_routed_through_output_guardrails():
src = open("app/workflows/agent_graph.py", encoding="utf-8").read()
assert '{"blocked": "output_guardrails", "continue": "load_long_term_memory"}' in src
assert '{"blocked": "persist", "continue": "judge"}' in src
def test_blocked_input_clears_stale_tool_state():
src = open("app/workflows/agent_graph.py", encoding="utf-8").read()
assert '"mcp_tools": []' in src
assert '"mcp_results": []' in src
assert '"intent": "input_guardrail_blocked"' in src

View File

@@ -0,0 +1,39 @@
from pathlib import Path
import yaml
from app.domain.contas.workflow_actions import build_contas_workflow_actions
def test_invoice_explanation_sim_final_node_declares_final_response():
spec = yaml.safe_load(Path("workflows/invoice_explanation.v2.yaml").read_text(encoding="utf-8"))
nodes = {node["id"]: node for node in spec["nodes"]}
cfg = nodes["registrar_protocolo_aceite"]["input"]
assert cfg["workflow_response_final"] is True
assert "{protocol}" in cfg["mensagem_final"]
def test_protocol_action_can_materialize_workflow_final_response(monkeypatch):
class Client:
def abrir_protocolo(self, payload):
return {"interactionProtocol": "1234567890", "status": "OPENED"}
class Service:
client = Client()
registry = build_contas_workflow_actions(Service())
action = registry.get("registrar_protocolo_inicio")
result = action(
{
"msisdn": "11999999999",
"scenario": "invoice_explanation_aceite_fechado",
"request_status": "Fechado",
"status": "CLOSED",
"workflow_response_final": True,
"mensagem_final": "Seu número de protocolo é {protocol}.",
},
{"input": {}},
)
assert result["workflow_response_final"] is True
assert result["protocol_number"]
assert result["mensagem"] == f"Seu número de protocolo é {result['protocol_number']}."

View File

@@ -0,0 +1,130 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from app.agents.faturas_agent import FaturasAgent
from app.domain.contas.workflow_actions import build_contas_workflow_actions
from contas_mcp.servers.contas_mcp_server import main as mcp_main
def test_invoice_explanation_handoff_action_is_structural_and_uses_configured_message():
class Service:
pass
action = build_contas_workflow_actions(Service()).get("preparar_handoff_invoice_explanation")
result = action(
{
"mensagem": "Para continuar com a sua solicitação, aguarde um instante.",
"reason": "invoice_explanation_not_resolved",
},
{"input": {}},
)
assert result["mensagem"] == "Para continuar com a sua solicitação, aguarde um instante."
assert result["session_control"] == "HUMAN_HANDOFF"
assert result["human_handoff_requested"] is True
assert result["handoff"] is True
assert result["session_ended"] is True
assert result["terminal_status"] == "human_handoff"
def test_result_payload_promotes_invoice_explanation_handoff_control():
result = {
"execution_id": "exec-1",
"status": "COMPLETED",
"output": {
"handoff_pos_explicacao_nao": {
"success": True,
"mensagem": "Para continuar com a sua solicitação, aguarde um instante.",
"session_control": "HUMAN_HANDOFF",
"handoff_reason": "invoice_explanation_not_resolved",
}
},
"state": {"current_node": "handoff_pos_explicacao_nao"},
"trace": [{"node": "handoff_pos_explicacao_nao", "status": "COMPLETED"}],
}
payload = mcp_main._result_payload(result, workflow_name="invoice_explanation")
assert payload["mensagem"] == "Para continuar com a sua solicitação, aguarde um instante."
assert payload["session_control"] == "HUMAN_HANDOFF"
assert payload["human_handoff_requested"] is True
assert payload["handoff"] is True
assert payload["session_ended"] is True
def test_faturas_agent_promotes_handoff_from_tool_context_to_graph_state():
context = [{
"tool_name": "retomar_workflow",
"ok": True,
"result": {
"workflow_name": "invoice_explanation",
"status": "COMPLETED",
"mensagem": "Para continuar com a sua solicitação, aguarde um instante.",
"session_control": "HUMAN_HANDOFF",
"human_handoff_requested": True,
"session_ended": True,
"terminal_status": "human_handoff",
"handoff_reason": "invoice_explanation_not_resolved",
},
}]
patch = FaturasAgent._handoff_patch_from_tool_context(context)
assert patch == {
"session_control": "HUMAN_HANDOFF",
"human_handoff_requested": True,
"session_ended": True,
"terminal_status": "human_handoff",
"handoff_reason": "invoice_explanation_not_resolved",
}
def test_completed_terminal_workflow_short_circuits_llm_composition_directive():
agent = FaturasAgent.__new__(FaturasAgent)
context = [{
"tool_name": "retomar_workflow",
"ok": True,
"result": {
"status": "COMPLETED",
"workflow_name": "generic_workflow",
# Earlier node still carries a composition directive. It must not win
# over the terminal last node.
"requires_llm_composition": True,
"response_instruction": "Componha uma nova resposta.",
"output": {
"formatar": {
"requires_llm_composition": True,
"mensagem": "mensagem anterior",
},
"final": {
"mensagem": "Encaminhando para atendimento humano.",
"session_control": "HUMAN_HANDOFF",
"handoff": True,
"session_ended": True,
"terminal_status": "human_handoff",
},
},
"state": {"current_node": "final"},
},
}]
answer = agent.build_direct_mcp_answer({}, context, agent_label="AnyAgent")
assert answer == "Encaminhando para atendimento humano."
def test_result_payload_promotes_generic_terminal_last_node_contract():
result = {
"execution_id": "exec-generic",
"status": "COMPLETED",
"output": {
"final": {
"mensagem": "Sessão encerrada.",
"session_control": "END_SESSION",
"session_ended": True,
"terminal_status": "done",
}
},
"state": {"current_node": "final"},
"trace": [{"node": "final", "status": "COMPLETED"}],
}
payload = mcp_main._result_payload(result, workflow_name="generic_example")
assert payload["terminal"] is True
assert payload["terminal_action"] == "end_session"
assert payload["mensagem"] == "Sessão encerrada."
assert payload["session_ended"] is True

View File

@@ -0,0 +1,18 @@
from pathlib import Path
import yaml
def test_invoice_explanation_declares_configurable_semantic_classifier():
root = Path(__file__).resolve().parents[2]
data = yaml.safe_load((root / "workflows" / "invoice_explanation.v2.yaml").read_text(encoding="utf-8"))
formatar = next(node for node in data["nodes"] if node["id"] == "formatar")
expected = formatar["pause"]["expected_input"]
assert expected["allowed_values"] == ["SIM", "NAO", "CONTINUAR"]
classifier = expected["semantic_classifier"]
assert classifier["enabled"] is True
assert classifier["include_relevant_context"] is True
assert "{{ relevant_conversation_context }}" in classifier["prompt"]
assert "{{ allowed_values }}" in classifier["prompt"]
assert "R$ 275,00" in classifier["prompt"]
assert "=> CONTINUAR" in classifier["prompt"]
assert classifier["option_actions"]["CONTINUAR"]["action"] == "contextual_reentry"

View File

@@ -0,0 +1,157 @@
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
from app.domain.contas.line_reference import extract_requested_line_reference
ROOT = Path(__file__).resolve().parents[2]
POLICY_DIR = ROOT / "contas_mcp" / "servers" / "contas_mcp_server"
def _load(name: str):
path = POLICY_DIR / name
spec = importlib.util.spec_from_file_location(name.replace(".py", ""), path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def test_extrai_final_falado_sem_transformar_em_msisdn():
ref = extract_requested_line_reference("quero cancelar o streaming do número da minha esposa, final quatro três dois um")
assert ref == {"kind": "suffix", "value": "4321", "raw": "quatro tres dois um"}
def test_alt1_bloqueia_outra_linha_e_mantem_linha_autenticada():
alt1 = _load("line_policy_alt1.py")
decision = alt1.apply_line_policy("cancelar_vas_avulso", {
"msisdn": "11999999999",
"requested_line_reference": {"kind": "suffix", "value": "4321"},
})
assert decision.allowed is False
assert decision.effective_msisdn == "11999999999"
assert decision.reason == "other_line_not_allowed"
def test_alt1_permite_referencia_a_mesma_linha():
alt1 = _load("line_policy_alt1.py")
decision = alt1.apply_line_policy("consultar_faturas", {
"msisdn": "11999994321",
"requested_line_reference": {"kind": "suffix", "value": "4321"},
})
assert decision.allowed is True
assert decision.effective_msisdn == "11999994321"
def test_alt2_resolve_dependente_unico_por_sufixo_retornado_pelo_servico_autorizador():
alt2 = _load("line_policy_alt2.py")
decision = alt2.apply_line_policy("cancelar_vas_avulso", {
"msisdn": "11999999999",
"requested_line_reference": {"kind": "suffix", "value": "4321"},
"authorized_lines_evidence": {
"success": True,
"authorized_lines": [
{"msisdn": "11999999999", "relationship": "titular", "status": "ACTIVE", "authorized": True},
{"msisdn": "11988884321", "relationship": "dependente", "status": "ACTIVE", "authorized": True},
],
"authorized_msisdns": ["11999999999", "11988884321"],
},
})
assert decision.allowed is True
assert decision.effective_msisdn == "11988884321"
def test_alt2_nao_trata_invoice_detail_como_fonte_de_autorizacao():
alt2 = _load("line_policy_alt2.py")
decision = alt2.apply_line_policy("cancelar_vas_avulso", {
"msisdn": "11999999999",
"requested_line_reference": {"kind": "suffix", "value": "4321"},
"invoice_detail": {
"parsed_content": {
"11999999999": {},
"11988884321": {"SVA Detalhe Total": []},
}
},
})
assert decision.allowed is False
assert decision.reason == "other_line_not_authorized_or_not_resolved"
def test_alt2_nao_permite_linha_arbitraria_fora_da_resposta_do_servico():
alt2 = _load("line_policy_alt2.py")
decision = alt2.apply_line_policy("cancelar_vas_avulso", {
"msisdn": "11999999999",
"requested_line_reference": {"kind": "full", "value": "11977774321"},
"authorized_lines_evidence": {
"success": True,
"authorized_msisdns": ["11999999999", "11988884321"],
},
})
assert decision.allowed is False
assert decision.reason == "other_line_not_authorized_or_not_resolved"
def test_alt2_falha_fechada_se_servico_autorizador_falhar():
alt2 = _load("line_policy_alt2.py")
decision = alt2.apply_line_policy("consultar_vas", {
"msisdn": "11999999999",
"requested_line_reference": {"kind": "suffix", "value": "4321"},
"authorized_lines_evidence": {"success": False, "status": "TIMEOUT"},
})
assert decision.allowed is False
assert decision.authorized_lines == ("11999999999",)
def test_alt2_falha_fechada_se_sufixo_for_ambiguo():
alt2 = _load("line_policy_alt2.py")
decision = alt2.apply_line_policy("consultar_vas", {
"msisdn": "11999999999",
"requested_line_reference": {"kind": "suffix", "value": "4321"},
"authorized_lines_evidence": {
"success": True,
"authorized_msisdns": ["11911114321", "11922224321"],
},
})
assert decision.allowed is False
assert decision.reason == "other_line_ambiguous"
def test_mock_consultar_linhas_autorizadas_expoe_dependente_4321():
from contas_mcp.servers.contas_mcp_server.authorized_lines_service import AuthorizedLinesMockService
service = AuthorizedLinesMockService(ROOT / "app" / "domain" / "contas" / "fixtures" / "authorized_lines.json")
result = service.consultar_linhas_autorizadas(
authenticated_msisdn="11999999999",
customer_key="11999999999",
contract_key="3000131180",
)
assert result["success"] is True
assert "11988884321" in result["authorized_msisdns"]
dependent = next(row for row in result["authorized_lines"] if row["msisdn"] == "11988884321")
assert dependent["relationship"] == "dependente"
def test_mcp_registra_consultar_linhas_autorizadas_e_alt2_consume_evidencia_explicita():
source = (POLICY_DIR / "main.py").read_text(encoding="utf-8")
assert '"consultar_linhas_autorizadas"' in source
assert 'args["authorized_lines_evidence"] = authorized_lines_service.consultar_linhas_autorizadas' in source
alt2_source = (POLICY_DIR / "line_policy_alt2.py").read_text(encoding="utf-8")
assert 'args.get("authorized_lines_evidence")' in alt2_source
assert 'args.get("invoice_detail")' not in alt2_source
def test_politica_ativa_entregue_eh_alt1():
active = (POLICY_DIR / "line_policy.py").read_text(encoding="utf-8")
alt1 = (POLICY_DIR / "line_policy_alt1.py").read_text(encoding="utf-8")
assert active == alt1
def test_line_policy_block_result_declares_generic_terminal_contract():
source = (POLICY_DIR / "main.py").read_text(encoding="utf-8")
block = source[source.index('"status": "LINE_POLICY_BLOCKED"'):source.index('"metadata": {', source.index('"status": "LINE_POLICY_BLOCKED"'))]
assert '"terminal": True' in block
assert '"terminal_action": "block"' in block
assert '"user_message": decision.user_message' in block

View File

@@ -522,3 +522,38 @@ def test_validate_contestation_items_prefere_evidencia_vas_quando_item_aparece_e
assert validation_log[-1]["status"] == "aprovado"
assert validation_log[-1]["secao_vas"] is True
assert validation_log[-1]["tipo_fatura"] == "servicos_contratados_de_parceiros"
def test_parse_amount_preserva_decimal_json_e_ptbr() -> None:
assert contestation_validation._parse_amount("19.99") == contestation_validation.Decimal("19.99")
assert contestation_validation._parse_amount("R$ 19,99") == contestation_validation.Decimal("19.99")
assert contestation_validation._parse_amount("1.999,99") == contestation_validation.Decimal("1999.99")
assert contestation_validation._parse_amount("1,999.99") == contestation_validation.Decimal("1999.99")
def test_validate_contestation_items_resolve_homonimo_por_valor_exato() -> None:
items = [{"item_name": "Tamboro Mensal", "claimed_amount": "19.99", "validated_amount": "19.99"}]
_, validation_log, error = validate_contestation_items(
items,
_vas_invoice(
{"desc": "Tamboro Mensal", "valor": "14.99"},
{"desc": "Tamboro Mensal", "valor": "19.99"},
),
)
assert error is None
assert validation_log[-1]["status"] == "aprovado"
assert validation_log[-1]["valor_item_fatura"] == "19.99"
def test_validate_contestation_items_bloqueia_valor_acima_de_todos_homonimos() -> None:
items = [{"item_name": "Tamboro Mensal", "claimed_amount": "29.98", "validated_amount": "29.98"}]
_, validation_log, error = validate_contestation_items(
items,
_vas_invoice(
{"desc": "Tamboro Mensal", "valor": "14.99"},
{"desc": "Tamboro Mensal", "valor": "19.99"},
),
)
assert error == "Valor de ajuste do item 'Tamboro Mensal' excede o valor cobrado na fatura."
assert validation_log[-1]["erro"] == "valor_ajuste_maior_que_item"
assert validation_log[-1]["valor_item_fatura"] == "19.99"

View File

@@ -0,0 +1,80 @@
from __future__ import annotations
import pytest
from app.extensions.tim_guardrails import TimPrematureActionRail
class FakeLLM:
def __init__(self, content: str):
self.content = content
async def ainvoke(self, messages, **kwargs):
return self.content
@pytest.mark.asyncio
async def test_revprec_allows_structured_insufficient_evidence_without_calling_llm():
text = "Identifiquei dados de desconto, mas os dados disponíveis não informam o motivo da retirada ou do término do desconto."
class MustNotRunLLM:
async def ainvoke(self, *args, **kwargs):
raise AssertionError("LLM não deveria ser chamada no bypass estrutural")
ctx = {
"guardrail_llm": MustNotRunLLM(),
"mcp_results": [
{
"tool_name": "termino_desconto",
"ok": True,
"result": {
"output": {
"formatar": {
"mensagem": text,
"epistemic_status": "insufficient_evidence",
"discount_reason_grounded": False,
}
}
},
}
],
}
out = await TimPrematureActionRail().evaluate(text, ctx)
assert out.allowed is True
assert out.reason == "insufficient_evidence_non_assertive"
assert out.metadata["mechanism"] == "deterministic_epistemic_bypass"
@pytest.mark.asyncio
async def test_revprec_does_not_bypass_if_message_does_not_match_structured_result():
structured = "Os dados disponíveis não informam o motivo."
text = "Cancelei o serviço com sucesso."
ctx = {
"guardrail_llm": FakeLLM("1"),
"mcp_results": [{"result": {"output": {"mensagem": structured, "epistemic_status": "insufficient_evidence"}}}],
}
out = await TimPrematureActionRail().evaluate(text, ctx)
assert out.allowed is False
@pytest.mark.asyncio
async def test_revprec_still_blocks_real_premature_action_without_structured_marker():
out = await TimPrematureActionRail().evaluate(
"Cancelei o serviço com sucesso.",
{"guardrail_llm": FakeLLM("1"), "mcp_results": []},
)
assert out.allowed is False
def test_termino_desconto_declares_epistemic_status():
from app.domain.contas.service import ContasDomainService
from app.domain.contas.workflow_actions import build_contas_workflow_actions
action = build_contas_workflow_actions(ContasDomainService()).get("formatar_capability_resposta")
no_reason = action({"tipo": "termino_desconto"}, {"input": {}})
with_reason = action(
{"tipo": "termino_desconto", "discount_evidence": {"discount_reason": "fim da campanha"}},
{"input": {}},
)
assert no_reason["epistemic_status"] == "insufficient_evidence"
assert with_reason["epistemic_status"] == "grounded_fact"

View File

@@ -0,0 +1,236 @@
from pathlib import Path
import yaml
import pytest
from contas_mcp.servers.contas_mcp_server import main
ROOT = Path(__file__).resolve().parents[2]
def test_all_concrete_subject_domains_have_domain_prevalidation():
policies = yaml.safe_load((ROOT / 'config' / 'tool_policies.yaml').read_text(encoding='utf-8'))['tool_policies']
assert policies['contestar_cobranca']['pre_validation']['tool'] == 'validar_contestacao'
for tool in ('cancelar_vas_avulso', 'tratar_vas_estrategico'):
assert policies[tool]['pre_validation'] == {
'enabled': True,
'tool': 'validar_vas_subject',
'fail_open': False,
}
def test_vas_subject_schema_requires_concrete_entity_semantically():
tools = yaml.safe_load((ROOT / 'config' / 'tools.yaml').read_text(encoding='utf-8'))['tools']
for tool in ('cancelar_vas_avulso', 'tratar_vas_estrategico'):
description = tools[tool]['args_schema']['subject']['description'].lower()
assert 'concreto' in description
assert 'item real' in description
assert 'ausente/null' in description
def test_generic_vas_category_does_not_resolve_to_arbitrary_product():
catalog = [
{'name': 'TIM Fashion Mensal'},
{'name': 'Aya Audiobooks Premium'},
{'name': 'Neymar Jr'},
{'name': 'Tamboro Mensal'},
{'name': 'Youtube Premium'},
]
canonical, matches = main._resolve_catalog_entity('streaming', catalog)
assert canonical is None
assert matches == []
def test_partial_unique_concrete_vas_name_resolves():
catalog = [
{'name': 'TIM Fashion Mensal'},
{'name': 'Aya Audiobooks Premium'},
{'name': 'Neymar Jr'},
]
canonical, matches = main._resolve_catalog_entity('TIM Fashion', catalog)
assert canonical == 'TIM Fashion Mensal'
assert len(matches) == 1
def test_ambiguous_reference_is_not_silently_resolved():
catalog = [
{'name': 'Apple Music SVA Mes'},
{'name': 'Apple Music Dados Mes'},
]
canonical, matches = main._resolve_catalog_entity('Apple Music', catalog)
assert canonical is None
assert {item['name'] for item in matches} == {'Apple Music SVA Mes', 'Apple Music Dados Mes'}
@pytest.mark.asyncio
async def test_vas_validator_returns_needs_parameter_for_generic_category(monkeypatch):
async def no_enrich(name, args):
return None
monkeypatch.setattr(main, '_enrich_invoice_context', no_enrich)
monkeypatch.setattr(main, '_vas_entity_catalog', lambda args: [
{'name': 'TIM Fashion Mensal'},
{'name': 'Aya Audiobooks Premium'},
{'name': 'Neymar Jr'},
{'name': 'Tamboro Mensal'},
])
result = await main._validate_vas_subject({
'msisdn': '11999999999',
'subject': 'streaming',
'target_tool': 'cancelar_vas_avulso',
})
assert result['eligible'] is False
assert result['status'] == 'NEEDS_PARAMETER'
assert result['parameter'] == 'subject'
assert result['reason'] == 'subject_not_resolved'
@pytest.mark.asyncio
async def test_vas_validator_accepts_unique_concrete_entity(monkeypatch):
async def no_enrich(name, args):
return None
monkeypatch.setattr(main, '_enrich_invoice_context', no_enrich)
monkeypatch.setattr(main, '_vas_entity_catalog', lambda args: [
{'name': 'TIM Fashion Mensal'},
{'name': 'Tamboro Mensal'},
])
result = await main._validate_vas_subject({
'msisdn': '11999999999',
'subject': 'TIM Fashion',
'target_tool': 'cancelar_vas_avulso',
})
assert result['eligible'] is True
assert result['status'] == 'ELIGIBLE'
assert result['resolved_subject'] == 'TIM Fashion Mensal'
def test_vas_preflight_never_falls_through_when_subject_is_unresolved():
# Existing execution-time preflight is a second defensive layer. It must not
# let an unresolved generic entity proceed even if the pre-validation stage
# was bypassed by a caller outside the framework runtime.
args = {
'msisdn': '11999999999',
'subject': 'streaming',
'billing_analysis': {'currentInvoice': []},
}
result = main._preflight_subject('cancelar_vas_avulso', args)
assert result is not None
assert result['status'] == 'NEEDS_PARAMETER'
assert result['parameter'] == 'subject'
@pytest.mark.asyncio
async def test_vas_validator_redirects_canonical_strategic_entity_before_execution(monkeypatch):
from types import SimpleNamespace
async def no_enrich(name, args):
args['billing_analysis'] = {'currentInvoice': []}
return None
monkeypatch.setattr(main, '_enrich_invoice_context', no_enrich)
monkeypatch.setattr(main, '_vas_entity_catalog', lambda args: [
{'name': 'Tamboro Mensal'},
{'name': 'Youtube Premium'},
])
monkeypatch.setattr(
main.invoice_resolver,
'resolve',
lambda subjects, billing: SimpleNamespace(
resolved=[SimpleNamespace(
canonical_name='Youtube Premium',
tool_category='vas_estrategico',
item_type='streaming',
)],
ambiguous=[],
out_of_scope=[],
),
)
result = await main._validate_vas_subject({
'msisdn': '11999999999',
'subject': 'youtube',
'target_tool': 'cancelar_vas_avulso',
})
assert result['eligible'] is True
assert result['resolved_subject'] == 'Youtube Premium'
decision = result['transaction_decision']
assert decision['resolved_arguments']['subject'] == 'Youtube Premium'
assert decision['target_tool'] == 'tratar_vas_estrategico'
assert decision['action_changed'] is True
assert decision['requires_reconfirmation'] is True
assert 'Youtube Premium' in decision['confirmation_message']
def test_vas_domain_policy_uses_invoice_detail_classe_for_strategic_item():
args = {
'invoice_detail': {
'parsed_content': {
'1199999999': {
'SVA Detalhe Total': [
{
'desc': 'Youtube Premium',
'value': 10,
'classe': 'estrategico',
'estrategico': True,
'verb': 'falar sobre',
},
{
'desc': 'Tamboro Mensal',
'value': 14.99,
'classe': 'avulso',
'verb': 'cancelar',
},
]
}
}
}
}
assert main._vas_domain_policy_from_invoice_detail('Youtube Premium', args) == ('vas_estrategico', 'estrategico')
assert main._vas_domain_policy_from_invoice_detail('Tamboro Mensal', args) == ('cancelar_vas_avulso', 'avulso')
@pytest.mark.asyncio
async def test_vas_validator_real_invoice_detail_redirects_youtube_without_resolver_guess(monkeypatch):
async def enrich(name, args):
args['billing_analysis'] = {
'currentInvoice': [
{
'type': 'streaming',
'desc': 'Streamings',
'items': [{'desc': 'Youtube Premium', 'value': '10.0'}],
}
]
}
args['invoice_detail'] = {
'parsed_content': {
'1199999999': {
'SVA Detalhe Total': [
{
'desc': 'Youtube Premium',
'value': 10,
'classe': 'estrategico',
'estrategico': True,
'verb': 'falar sobre',
}
]
}
}
}
monkeypatch.setattr(main, '_enrich_invoice_context', enrich)
monkeypatch.setattr(main, '_vas_entity_catalog', lambda args: [{'name': 'Youtube Premium'}])
# If invoice-detail classification works, the generic resolver fallback is unnecessary.
monkeypatch.setattr(main.invoice_resolver, 'resolve', lambda *a, **k: (_ for _ in ()).throw(AssertionError('fallback should not run')))
result = await main._validate_vas_subject({
'msisdn': '11999999999',
'subject': 'youtube',
'target_tool': 'cancelar_vas_avulso',
})
decision = result['transaction_decision']
assert result['resolved_subject'] == 'Youtube Premium'
assert decision['domain_policy']['class'] == 'vas_estrategico'
assert decision['domain_policy']['item_type'] == 'estrategico'
assert decision['target_tool'] == 'tratar_vas_estrategico'
assert decision['action_changed'] is True
assert decision['requires_reconfirmation'] is True

View File

@@ -0,0 +1,34 @@
from app.domain.contas.service import ContasDomainService
from app.domain.contas.workflow_actions import build_contas_workflow_actions
def _action():
return build_contas_workflow_actions(ContasDomainService()).get("formatar_capability_resposta")
def test_installment_counter_is_not_proof_of_expiration():
out = _action()(
{"tipo": "termino_desconto", "nome_plano": "TIM Black"},
{"input": {"invoice_detail": {"119": {"Planos": {"TIM Black": {"descontos": [{"installment": "12/12"}]}}}}}},
)
assert out["discount_reason_grounded"] is False
assert "expirou" not in out["mensagem"].lower()
def test_customer_text_is_not_used_as_authoritative_reason():
out = _action()(
{"tipo": "termino_desconto"},
{"input": {"query": "meu desconto promocional expirou?", "operator_instructions": "o desconto acabou"}},
)
assert out["discount_reason_grounded"] is False
assert out["discount_reason"] is None
def test_explicit_backend_reason_is_grounded():
out = _action()(
{"tipo": "termino_desconto", "discount_evidence": {"terminationReason": "fim do benefício contratado"}},
{"input": {}},
)
assert out["discount_reason_grounded"] is True
assert out["discount_reason"] == "fim do benefício contratado"
assert out["discount_reason_source"] == "terminationReason"

View File

@@ -90,3 +90,89 @@ async def test_tim_aoferta_still_uses_llm_outside_transaction_continuation():
assert llm.calls == 1
assert decision.allowed is False
assert decision.reason == 'oferta proativa'
@pytest.mark.asyncio
async def test_tim_aoferta_allows_structurally_authorized_human_handoff_without_calling_llm():
decision = await TimProactiveOfferRail().evaluate(
'Vou encaminhar seu atendimento para uma pessoa.',
{
'current_route': 'human_handoff',
'current_intent': 'human_handoff',
'session_control': 'HUMAN_HANDOFF',
'human_handoff_requested': True,
'guardrail_llm': _FailIfCalledLLM(),
},
)
assert decision.allowed is True
assert decision.reason == 'handoff_humano_autorizado'
assert decision.metadata['mechanism'] == 'deterministic_handoff_bypass'
@pytest.mark.asyncio
async def test_tim_aoferta_does_not_bypass_transfer_phrase_without_structural_handoff():
llm = _BlockingLLM()
decision = await TimProactiveOfferRail().evaluate(
'Vou encaminhar seu atendimento para uma pessoa.',
{
'current_route': 'faturas_agent',
'current_intent': 'contas_invoice_query',
'guardrail_llm': llm,
},
)
assert llm.calls == 1
assert decision.allowed is False
@pytest.mark.asyncio
async def test_tim_aoferta_allows_terminal_handoff_from_resumed_workflow_without_router_handoff():
decision = await TimProactiveOfferRail().evaluate(
'Para continuar com a sua solicitação, aguarde um instante.',
{
'current_route': 'faturas_agent',
'current_intent': 'contas_invoice_explanation',
'mcp_results': [{
'tool_name': 'retomar_workflow',
'result': {
'status': 'COMPLETED',
'output': {
'mensagem': 'Para continuar com a sua solicitação, aguarde um instante.',
'session_control': 'HUMAN_HANDOFF',
'human_handoff_requested': True,
'handoff': True,
'session_ended': True,
'terminal_status': 'human_handoff',
},
},
}],
'guardrail_llm': _FailIfCalledLLM(),
},
)
assert decision.allowed is True
assert decision.reason == 'handoff_humano_autorizado'
assert decision.metadata['mechanism'] == 'deterministic_handoff_bypass'
@pytest.mark.asyncio
async def test_tim_aoferta_does_not_trust_non_terminal_workflow_handoff_flag():
llm = _BlockingLLM()
decision = await TimProactiveOfferRail().evaluate(
'Vou encaminhar seu atendimento para uma pessoa.',
{
'current_route': 'faturas_agent',
'current_intent': 'contas_invoice_explanation',
'mcp_results': [{
'result': {
'output': {
'handoff': True,
},
},
}],
'guardrail_llm': llm,
},
)
assert llm.calls == 1
assert decision.allowed is False

View File

@@ -0,0 +1,136 @@
from __future__ import annotations
import pytest
from app.extensions.tim_guardrails import TimOutOfScopeRail
class _FailIfCalledLLM:
async def ainvoke(self, *args, **kwargs):
raise AssertionError('TIM_OOS LLM must not run for structurally authorized human handoff')
class _BlockingLLM:
def __init__(self):
self.calls = 0
async def ainvoke(self, *args, **kwargs):
self.calls += 1
return '{"allowed": false, "reason": "mensagem fora do escopo de contas/faturas TIM"}'
@pytest.mark.asyncio
async def test_tim_oos_allows_structurally_authorized_human_handoff_without_calling_llm():
decision = await TimOutOfScopeRail().evaluate(
'Vou encaminhar seu atendimento para uma pessoa.',
{
'current_route': 'human_handoff',
'current_intent': 'human_handoff',
'session_control': 'HUMAN_HANDOFF',
'human_handoff_requested': True,
'guardrail_llm': _FailIfCalledLLM(),
},
)
assert decision.allowed is True
assert decision.code == 'TIM_OOS'
assert decision.reason == 'handoff_humano_autorizado'
assert decision.metadata['mechanism'] == 'deterministic_handoff_bypass'
@pytest.mark.asyncio
async def test_tim_oos_allows_route_decision_structural_handoff_without_calling_llm():
decision = await TimOutOfScopeRail().evaluate(
'Vou encaminhar seu atendimento para uma pessoa.',
{
'route_decision': {
'route': 'human_handoff',
'intent': 'human_handoff',
'handoff': True,
'metadata': {'session_control': 'HUMAN_HANDOFF'},
},
'guardrail_llm': _FailIfCalledLLM(),
},
)
assert decision.allowed is True
assert decision.metadata['mechanism'] == 'deterministic_handoff_bypass'
@pytest.mark.asyncio
async def test_tim_oos_does_not_bypass_transfer_phrase_without_structural_handoff():
llm = _BlockingLLM()
decision = await TimOutOfScopeRail().evaluate(
'Vou encaminhar seu atendimento para uma pessoa.',
{
'current_route': 'faturas_agent',
'current_intent': 'contas_invoice_query',
'guardrail_llm': llm,
},
)
assert llm.calls == 1
assert decision.allowed is False
assert decision.reason == 'mensagem fora do escopo de contas/faturas TIM'
@pytest.mark.asyncio
async def test_tim_oos_requires_route_and_control_evidence_not_only_handoff_boolean():
llm = _BlockingLLM()
decision = await TimOutOfScopeRail().evaluate(
'Vou encaminhar seu atendimento para uma pessoa.',
{
'handoff': True,
'current_route': 'faturas_agent',
'current_intent': 'contas_invoice_query',
'guardrail_llm': llm,
},
)
assert llm.calls == 1
assert decision.allowed is False
@pytest.mark.asyncio
async def test_tim_oos_allows_terminal_handoff_from_resumed_workflow_without_router_handoff():
decision = await TimOutOfScopeRail().evaluate(
'Para continuar com a sua solicitação, aguarde um instante.',
{
'current_route': 'faturas_agent',
'current_intent': 'contas_invoice_explanation',
'mcp_results': [{
'tool_name': 'retomar_workflow',
'result': {
'status': 'COMPLETED',
'output': {
'session_control': 'HUMAN_HANDOFF',
'human_handoff_requested': True,
'handoff': True,
'session_ended': True,
'terminal_status': 'human_handoff',
},
},
}],
'guardrail_llm': _FailIfCalledLLM(),
},
)
assert decision.allowed is True
assert decision.reason == 'handoff_humano_autorizado'
assert decision.metadata['mechanism'] == 'deterministic_handoff_bypass'
@pytest.mark.asyncio
async def test_tim_oos_does_not_trust_non_terminal_workflow_handoff_flag():
llm = _BlockingLLM()
decision = await TimOutOfScopeRail().evaluate(
'Vou encaminhar seu atendimento para uma pessoa.',
{
'current_route': 'faturas_agent',
'current_intent': 'contas_invoice_explanation',
'mcp_results': [{'result': {'output': {'handoff': True}}}],
'guardrail_llm': llm,
},
)
assert llm.calls == 1
assert decision.allowed is False

View File

@@ -50,3 +50,46 @@ def test_transaction_state_patch_preserva_latch():
runtime = Runtime()
patch = runtime.transaction_state_patch({"business_workflows_executed": ["invoice_explanation"]})
assert patch["business_workflows_executed"] == ["invoice_explanation"]
def test_transaction_state_patch_preserva_boundary_de_soft_reset():
runtime = Runtime()
patch = runtime.transaction_state_patch({
"transaction_status": "COMPLETED",
"operational_context_boundary_pending": True,
})
assert patch["transaction_status"] == "COMPLETED"
assert patch["operational_context_boundary_pending"] is True
def test_workflow_response_final_fecha_latch_mesmo_se_adapter_retornar_paused():
runtime = Runtime()
state = {
"pending_domain_workflow": {
"execution_id": "exec-1",
"workflow_name": "invoice_explanation",
},
"transaction_status": "WORKFLOW_PAUSED",
}
envelope = {
"result": {
"result": {
"status": "PAUSED",
"execution_id": "exec-1",
"metadata": {
"workflow_name": "invoice_explanation",
"workflow_execution_id": "exec-1",
"resume_tool": "retomar_workflow",
},
"output": {
"workflow_response_final": True,
"mensagem": "Seu número de protocolo é 1234567890.",
},
"state": {"current_node": "registrar_protocolo_aceite"},
}
}
}
runtime._capture_pending_domain_workflow(state, envelope)
assert state["pending_domain_workflow"] is None
assert state["transaction_status"] == "COMPLETED"
assert state["operational_context_boundary_pending"] is True