new feature: LOCALE pt-BR/en

This commit is contained in:
T3782834
2026-10-27 11:45:37 -03:00
parent 4ca6c8a274
commit ccf509dd21
137 changed files with 12177 additions and 1018 deletions

View File

@@ -30,7 +30,10 @@ async def test_dlex_out_masks_protocol_explicitly_authorized_by_expected_protoco
)
assert decision.allowed is True
assert decision.sanitized_text == "Seu número de protocolo é 1234567890."
# Histórico: 36f9781 (2026-09-18) mudou sanitized_text para representar
# somente uma substituição. None significa que a saída original é mantida.
assert decision.sanitized_text is None
assert captured["text"] == "Seu número de protocolo é <AUTHORIZED_PROTOCOL>."
assert decision.metadata["protocol_authorization"] == "expected_values"
assert decision.metadata["authorized_protocols_masked"] == 1
@@ -61,29 +64,38 @@ async def test_dlex_out_does_not_mask_unexpected_protocol(monkeypatch):
@pytest.mark.asyncio
async def test_dlex_out_masks_expected_protocol_but_keeps_other_sensitive_content_visible(monkeypatch):
async def test_dlex_out_sanitizes_secret_and_revalidates_without_changing_authorized_protocol(monkeypatch):
# Histórico: 36f9781 (2026-09-18) adicionou sanitização + reavaliação.
# Antes, este teste exigia o segredo visível em TODAS as chamadas. Agora
# verificamos que aparece só na primeira, é removido e a saída revalidada
# mantém o protocolo autorizado para o cliente.
calls = []
async def fake_classifier(_llm, task, payload, **_kwargs):
assert task == "DLEX_OUT"
calls.append(payload)
assert "1234567890" not in payload["text"]
assert "<AUTHORIZED_PROTOCOL>" in payload["text"]
assert "sk-abcdefghijklmnop" in payload["text"]
return {"allowed": False, "label": "DLEX_OUT", "reason": "secret remains visible"}
if len(calls) == 1:
assert "sk-abcdefghijklmnop" in payload["text"]
return {"allowed": False, "label": "DLEX_OUT", "reason": "secret remains visible"}
assert len(calls) == 2
assert "sk-abcdefghijklmnop" not in payload["text"]
assert "[CREDENCIAL_OCULTADA]" in payload["text"]
return {"allowed": True, "label": "OK", "reason": "sanitized output contains no secret"}
monkeypatch.setattr(
"agent_framework.guardrails.rails.classify_with_framework_llm", fake_classifier
)
rail = DataLeakageOutputRail()
decision = await rail.evaluate(
monkeypatch.setattr("agent_framework.guardrails.rails.classify_with_framework_llm", fake_classifier)
decision = await DataLeakageOutputRail().evaluate(
"Protocolo 1234567890; token sk-abcdefghijklmnop",
{
"__guardrails_yaml_controlled": True,
"expected_protocols": ["1234567890"],
},
{"__guardrails_yaml_controlled": True, "expected_protocols": ["1234567890"]},
)
assert decision.allowed is False
assert len(calls) == 2
assert decision.allowed is True
assert decision.sanitized_text == "Protocolo 1234567890; token [CREDENCIAL_OCULTADA]"
assert "sk-abcdefghijklmnop" not in decision.sanitized_text
assert decision.metadata["protocol_authorization"] == "expected_values"
assert decision.metadata["sanitized"] is True
assert decision.metadata["original_decision"]["allowed"] is False
@pytest.mark.asyncio
async def test_dlex_out_rechecks_and_allows_false_positive_caused_only_by_authorized_protocol(monkeypatch):
@@ -124,30 +136,35 @@ async def test_dlex_out_rechecks_and_allows_false_positive_caused_only_by_author
@pytest.mark.asyncio
async def test_dlex_out_recheck_does_not_hide_other_leakage(monkeypatch):
async def test_dlex_out_keeps_block_when_sanitized_output_is_still_rejected(monkeypatch):
# Histórico: 36f9781 introduziu a tentativa de recuperação por sanitização.
# Mantemos a garantia de segurança do teste antigo: protocolo autorizado
# não libera outra violação. A segunda avaliação pode rejeitar o restante;
# nesse caso nenhuma saída é aprovada, mesmo após remover a credencial.
calls = []
async def fake_classifier(_llm, task, payload, **_kwargs):
assert task == "DLEX_OUT"
calls.append(payload)
# First pass blocks; second pass must still see the unrelated secret.
assert "sk-abcdefghijklmnop" in payload["text"]
return {"allowed": False, "label": "DLEX_OUT", "reason": "token secreto exposto"}
assert "1234567890" not in payload["text"]
assert "<AUTHORIZED_PROTOCOL>" in payload["text"]
assert "INTERNAL_ONLY_CONTENT" in payload["text"]
if len(calls) == 1:
assert "sk-abcdefghijklmnop" in payload["text"]
else:
assert len(calls) == 2
assert "sk-abcdefghijklmnop" not in payload["text"]
return {"allowed": False, "label": "DLEX_OUT", "reason": "unrelated internal content remains forbidden"}
monkeypatch.setattr(
"agent_framework.guardrails.rails.classify_with_framework_llm", fake_classifier
monkeypatch.setattr("agent_framework.guardrails.rails.classify_with_framework_llm", fake_classifier)
decision = await DataLeakageOutputRail().evaluate(
"Protocolo 1234567890; token sk-abcdefghijklmnop; INTERNAL_ONLY_CONTENT",
{"__guardrails_yaml_controlled": True, "expected_protocols": ["1234567890"]},
)
rail = DataLeakageOutputRail()
decision = await rail.evaluate(
"Protocolo 1234567890; token sk-abcdefghijklmnop",
{
"__guardrails_yaml_controlled": True,
"expected_protocols": ["1234567890"],
},
)
assert len(calls) == 2
assert decision.allowed is False
assert len(calls) == 1
assert decision.sanitized_text is None
assert decision.metadata["action"] == "block"
assert decision.metadata["sanitization_recheck"]["allowed"] is False
assert decision.metadata["protocol_authorization"] == "expected_values"
assert decision.metadata["protocol_authorization_verified"] is False

View File

@@ -0,0 +1,249 @@
"""Behavioral checks for configurable vocabulary and customer-facing messages."""
import asyncio
import copy
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from agent_framework.localization import (
catalog, confirmation_decision, lookup, message, protocol_pattern,
resolve_locale, use_locale, vocalize_protocol,
)
from agent_framework.runtime.transaction_parameters import parse_transaction_confirmation
from agent_framework.runtime.transaction_input import confirmation_decision as legacy_confirmation
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
from agent_framework.routing.models import IntentDefinition, RouteDecision
from agent_framework.routing.enterprise_router import EnterpriseRouter
from agent_framework.routing.multi_intent import MultiIntentPlanner
from agent_framework.workflows.input_contract import expected_input_reprompt, match_expected_input
from agent_framework.memory.long_term_extractor import extract_long_term_memory
from agent_framework.guardrails.rails import GroundednessRail, HallucinationRiskRail, ComplianceRail
from agent_framework.guardrails.calibrated.prompts.fallback import (
FALLBACK_TEXT_BY_CODE, build_fallback_prompt, regen_directive,
)
from agent_framework.guardrails.calibrated.pipeline import _FALLBACK_BY_CODE
from agent_framework.guardrails.calibrated.rules.pinj_patterns import is_obvious_injection
from agent_framework.guardrails.calibrated.rules.tox_blocklist import is_obvious_toxic
from agent_framework.guardrails.calibrated.rules.oos_blocklist import is_obvious_oos
from agent_framework.guardrails.framework_llm_client import _mock_classify
from agent_framework.guardrails.rail_decision import RailDecisionV2
from agent_framework.guardrails.rail_action import RailAction
from agent_framework.guardrails.output_supervisor import OutputSupervisor
@pytest.mark.parametrize('locale,accept,reject', [('pt-BR','sim','não'),('en','yes','no')])
def test_confirmation_uses_selected_catalog_and_shared_parser(locale, accept, reject):
with use_locale(locale):
for parser in (confirmation_decision, parse_transaction_confirmation, legacy_confirmation):
assert parser(' '+accept.upper()+'! ') == 'confirm'
assert parser(reject+'?') == 'reject'
assert parser('maybe tomorrow') is None
assert parser('') is None
@pytest.mark.parametrize('text', ['yes, but change the amount','understood','I understand','no, wait','yes or no'])
def test_ambiguous_or_qualified_answers_do_not_authorize_execution(text):
with use_locale('en'):
assert confirmation_decision(text) is None
@pytest.mark.parametrize('locale', ['pt-BR','en'])
def test_legacy_workflow_codes_remain_stable_and_contract_takes_precedence(locale):
with use_locale(locale):
accept = 'yes' if locale == 'en' else 'sim'
reject = 'no' if locale == 'en' else 'não'
assert AgentRuntimeMixin._workflow_resume_decision(accept) == 'SIM'
assert AgentRuntimeMixin._workflow_resume_decision(reject) == 'NAO'
pending = {'pause': {'expected_input': {'allowed_values':['CHOICE_A'], 'normalize':'upper_strip'}}}
assert AgentRuntimeMixin._workflow_resume_decision(accept,pending) == 'OUTRO'
assert AgentRuntimeMixin._workflow_resume_decision('choice_a',pending) == 'CHOICE_A'
assert match_expected_input('yes',{'allowed_values':['YES'], 'normalize':'upper_strip'}) == 'YES'
assert expected_input_reprompt({'reprompt':'CUSTOM'}) == 'CUSTOM'
@pytest.mark.parametrize('locale,keywords,text,confirmation', [
('pt-BR', ['cancelar pedido','consultar fatura'], 'quero cancelar pedido e consultar fatura', 'sim e consultar fatura'),
('en', ['cancel order','check invoice'], 'cancel order and check invoice', 'yes and check invoice'),
])
def test_multi_intent_and_confirmation_secondary(locale,keywords,text,confirmation):
planner = MultiIntentPlanner([
IntentDefinition(name='cancel', agent='orders', keywords=[keywords[0]],mcp_tools=['cancel_order']),
IntentDefinition(name='invoice', agent='billing', keywords=[keywords[1]],mcp_tools=['invoice']),
],transactional_tools={'cancel_order'})
with use_locale(locale):
plan=planner.plan(text)
assert [op.intent for op in plan.operations] == ['cancel','invoice']
assert planner.looks_compound(text)
assert not planner.looks_compound('Candy is a name')
secondary=planner.plan_confirmation_secondary(confirmation,primary_intent='cancel',primary_agent='orders')
assert [op.intent for op in secondary.operations] == ['cancel','invoice']
def test_unknown_compound_requests_can_reach_semantic_fallback():
with use_locale('en'):
assert MultiIntentPlanner([]).looks_compound('Please help with X and explain Z')
@pytest.mark.parametrize('locale,utterance', [('pt-BR','me chame de Alice'),('en','call me Alice')])
def test_memory_extraction(locale,utterance):
with use_locale(locale):
memories=extract_long_term_memory(utterance)
assert memories[0]['key']=='preferred_name'
assert memories[0]['value']=='Alice'
@pytest.mark.parametrize('locale', ['pt-BR','en'])
def test_standard_messages_and_fallback_imports_resolve_at_call_time(locale):
with use_locale(locale):
runtime=AgentRuntimeMixin()
state={'transaction_status':'AWAITING_CONFIRMATION','pending_tool_call':{'tool_name':'solicitar_devolucao','arguments':{'order_id':'123'}}}
text=runtime.transaction_confirmation_message(state)
assert ('Do you confirm' if locale=='en' else 'Você confirma') in text
assert '123' in text
assert _FALLBACK_BY_CODE['OOS']==FALLBACK_TEXT_BY_CODE['OOS']==message('fallback.OOS')
assert RailDecisionV2(action=RailAction.BLOCK,results=[],candidate='x').fallback_message == message('guardrails.validation_failed')
supervisor=object.__new__(OutputSupervisor)
supervisor.fallback_message=None
supervisor.handover_message=None
assert supervisor.fallback_message==message('guardrails.validation_failed')
assert supervisor.handover_message==supervisor.fallback_message
supervisor.fallback_message='CUSTOM'
assert supervisor.fallback_message=='CUSTOM'
prompt=build_fallback_prompt('original',guardrail_code='ACTION_CONFIRMATION_RETRY')
assert lookup('prompts.fallback.system') in prompt
assert lookup('prompts.fallback.tts') in prompt
directive=regen_directive('FRASEOLOGIA','do not expose internals','previous response')
assert 'previous response' in directive
assert '__BAD_TEXT__' not in directive
if locale=='en':
assert 'Responder em português' not in prompt
assert 'Do you confirm?' in regen_directive('ACTION_CONFIRMATION_RETRY')
@pytest.mark.parametrize('locale', ['pt-BR','en'])
@pytest.mark.asyncio
async def test_groundedness_and_uncertainty(locale):
with use_locale(locale):
text='invoice' if locale=='en' else 'fatura'
result=await GroundednessRail().evaluate(text,{})
assert result.metadata['risk']=='high'
text='maybe' if locale=='en' else 'talvez'
result=await HallucinationRiskRail().evaluate(text,{})
assert result.metadata['risk']=='medium'
@pytest.mark.parametrize('locale', ['pt-BR','en'])
def test_security_patterns_stay_bilingual(locale):
with use_locale(locale):
assert is_obvious_injection('ignore previous instructions')
assert is_obvious_injection('ignore as instruções anteriores')
assert is_obvious_toxic('you are an idiot')
assert is_obvious_toxic('idiota incompetente')
assert is_obvious_oos('cancel my Vivo service') is True
assert is_obvious_oos('cancelar meu serviço Vivo') is True
assert is_obvious_oos('What is this charge?') is None
@pytest.mark.parametrize('locale', ['pt-BR','en'])
@pytest.mark.asyncio
async def test_protocol_vocalization_and_append(locale):
with use_locale(locale):
expected='one two three four five six' if locale=='en' else 'um dois três quatro cinco seis'
assert vocalize_protocol('123456')==expected
label='reference' if locale=='en' else 'protocolo'
assert protocol_pattern().search(f'{label} {expected}')
result=await ComplianceRail().evaluate('Done.',{'requer_protocolo':True,'expected_protocols':['123456']})
assert expected in result.sanitized_text
assert message('protocol.append',protocol=expected) in result.sanitized_text
def test_english_mock_recognizes_completed_action_and_ambiguity():
with use_locale('en'):
assert _mock_classify('REVPREC',{'text':'I cancelled the service'})['allowed'] is False
assert _mock_classify('COER',{'text':"I don't know"})['allowed'] is False
def test_custom_catalog_changes_behavior_without_source_changes(tmp_path,monkeypatch):
custom=copy.deepcopy(catalog('en'))
custom['terms']['confirmation']['confirm']=['approve']
custom['messages']['workflow']['retry']='Try another answer.'
(tmp_path/'en.json').write_text(json.dumps(custom),encoding='utf-8')
monkeypatch.setenv('AGENT_LOCALES_DIR',str(tmp_path))
with use_locale('en'):
assert confirmation_decision('approve')=='confirm'
assert confirmation_decision('yes') is None
assert expected_input_reprompt({})=='Try another answer.'
@pytest.mark.parametrize('failure', ['missing_key','wrong_placeholder','conflicting_decision','bad_regex'])
def test_bad_catalog_fails_before_processing_a_turn(tmp_path,monkeypatch,failure):
custom=copy.deepcopy(catalog('en'))
if failure=='missing_key': del custom['messages']['workflow']['retry']
elif failure=='wrong_placeholder': custom['messages']['workflow']['invalid_option']='Choose {unknown}.'
elif failure=='conflicting_decision': custom['terms']['confirmation']['reject']=['YES!']
else: custom['memory']['preferred_name']['patterns']=['[']
(tmp_path/'en.json').write_text(json.dumps(custom),encoding='utf-8')
monkeypatch.setenv('AGENT_LOCALES_DIR',str(tmp_path))
with pytest.raises((ValueError,__import__('re').error)):
with use_locale('en'): pass
@pytest.mark.parametrize('alias', ['en-US','en_GB','EN'])
def test_locale_aliases(alias):
assert resolve_locale(alias)=='en'
def test_invalid_or_missing_locale_is_not_silently_portuguese():
with pytest.raises(ValueError): resolve_locale('../../etc/passwd')
with pytest.raises(FileNotFoundError):
with use_locale('xx'): pass
@pytest.mark.asyncio
async def test_concurrent_requests_do_not_share_locale():
async def worker(locale,answer):
with use_locale(locale):
await asyncio.sleep(0)
assert resolve_locale()==locale
assert confirmation_decision(answer)=='confirm'
return message('workflow.retry')
pt,en=await asyncio.gather(worker('pt-BR','sim'),worker('en','yes'))
assert pt!=en
@pytest.mark.parametrize('locale,answer,expected', [('en','yes','confirm'),('en','no','reject'),('pt-BR','sim','confirm'),('pt-BR','não','reject')])
@pytest.mark.asyncio
async def test_router_consumes_confirmation_without_llm(locale,answer,expected):
router=object.__new__(EnterpriseRouter)
router.multi_intent_planner=MultiIntentPlanner([])
router.enable_llm_router=False
router.llm=None
router.transaction_confirmation={}
decision=RouteDecision(route='orders',agent='orders',intent='cancel',method='state')
with use_locale(locale):
result=await router._transaction_parameter_precedence(
{'transaction_status':'AWAITING_CONFIRMATION'},text=answer,state_decision=decision)
assert result.metadata['transaction_confirmation_decision']==expected
assert result.metadata['transaction_confirmation_source']=='deterministic'
@pytest.mark.parametrize('locale,answer,cancelled', [('en','yes',False),('en','no',True),('pt-BR','sim',False),('pt-BR','não',True)])
@pytest.mark.asyncio
async def test_actual_runtime_executes_or_cancels_in_selected_language(locale,answer,cancelled):
class Runtime(AgentRuntimeMixin):
def __init__(self):
self.calls=[]
self.llm=None
self.tool_router=SimpleNamespace(registry=SimpleNamespace(tools={},get_tool=lambda name:None))
async def _call_mcp_tool(self,tool_name,arguments,state):
self.calls.append((tool_name,arguments))
return {'ok':True,'tool_name':tool_name,'result':{}}
runtime=Runtime()
state={'user_text':answer,'sanitized_input':answer,'transaction_status':'AWAITING_CONFIRMATION','pending_tool_call':{'tool_name':'cancel_order','arguments':{'order_id':'123'}},'route':'orders','intent':'cancel'}
with use_locale(locale):
await runtime.execute_tools_for_intent(state,tools=[])
assert state['transaction_status']==('CANCELLED' if cancelled else 'COMPLETED')
assert len(runtime.calls)==(0 if cancelled else 1)
if runtime.calls: assert runtime.calls[0][1]['confirmed'] is True

View File

@@ -332,25 +332,25 @@ class _PolicyRouter:
}
class _UnresolvedReconcileLLM:
# Histórico: 1711575 (2026-09-07) removeu candidatos estruturais e
# 82636ec (2026-09-08) consolidou a coleta no single_extractor.
# Os doubles abaixo respondem pelo contrato da chamada, sem procurar frases
# como "FOCO PRINCIPAL DA RECONCILIAÇÃO" no prompt para escolher uma resposta.
class _TransactionExtractorLLM:
def __init__(self, subject=None):
self.subject = subject
self.prompts = []
async def ainvoke(self, messages, **kwargs):
prompt = messages[-1]["content"]
if kwargs.get("profile_name") == "transaction_parameter_extraction":
if "FOCO PRINCIPAL DA RECONCILIAÇÃO" in prompt:
return {"content": json.dumps({
"fields": {
"subject": {"decision": "unresolved", "value": None, "source": ""},
"valor": {"decision": "preserve", "value": 14.99, "source": "state"},
}
})}
return {"content": json.dumps({"subject": None, "valor": 14.99})}
return {"content": "{}"}
assert kwargs.get("profile_name") == "transaction_parameter_extraction"
self.prompts.append(messages[-1]["content"])
return {"content": json.dumps({"subject": self.subject, "valor": 14.99})}
class _CandidateRuntime(AgentRuntimeMixin):
def __init__(self):
def __init__(self, subject=None):
self.tool_router = _PolicyRouter()
self.llm = _UnresolvedReconcileLLM()
self.llm = _TransactionExtractorLLM(subject)
self.calls = []
async def _call_mcp_tool(self, tool_name, arguments, state):
@@ -373,7 +373,10 @@ class _CandidateRuntime(AgentRuntimeMixin):
@pytest.mark.asyncio
async def test_unique_anchored_block_is_candidate_only_and_prevalidator_canonicalizes():
async def test_unresolved_subject_remains_pending_without_structural_guessing():
# Histórico: 1711575 removeu unique_anchored_evidence_block. A presença de
# um item com o mesmo valor no histórico não autoriza o core a inventar o
# subject quando o extractor o deixou indefinido.
runtime = _CandidateRuntime()
state = {
"user_text": "é a de quatorze e noventa e nove",
@@ -404,20 +407,21 @@ async def test_unique_anchored_block_is_candidate_only_and_prevalidator_canonica
result = await runtime.execute_tools_for_intent(state, tools=[])
assert state["transaction_status"] == "AWAITING_CONFIRMATION"
assert state["pending_tool_call"]["arguments"]["subject"] == "Tamboro Mensal"
assert state["pending_tool_call"]["arguments"]["valor"] == 14.99
assert state["transaction_parameter_reconciliation"]["applied_structural_candidates"] == {
"subject": "unique_anchored_evidence_block"
}
validator_args = runtime.calls[0][1]
assert validator_args["subject"].startswith("Cobrança Tamboro Mensal")
assert result[-1]["awaiting_confirmation"] is True
assert state["transaction_status"] == "COLLECTING_PARAMETERS"
assert "subject" in state["missing_parameters"]
assert state["active_transaction"]["arguments"]["valor"] == 14.99
assert not state["active_transaction"]["arguments"].get("subject")
assert runtime.calls == [] # Nem validação incompleta, nem execução de negócio.
assert not state.get("pending_tool_call")
assert not state.get("confirmation_received")
assert result[-1]["collecting_parameters"] is True
@pytest.mark.asyncio
async def test_current_turn_value_then_unique_anchor_reaches_confirmation_from_empty_transaction():
"""Regression for Contas scenario 08: current amount first, temporal subject second."""
runtime = _CandidateRuntime()
async def test_contextual_extraction_is_prevalidated_before_confirmation_from_empty_transaction():
# Histórico: 82636ec substituiu as duas etapas (valor atual + candidato
# estrutural) por um extractor com contexto. Mantemos o cenário 08:
# identificar a cobrança de 14,99, validar e só então pedir confirmação.
runtime = _CandidateRuntime(subject="Cobrança Tamboro Mensal")
state = {
"user_text": "é a de quatorze e noventa e nove",
"sanitized_input": "é a de quatorze e noventa e nove",
@@ -447,31 +451,34 @@ async def test_current_turn_value_then_unique_anchor_reaches_confirmation_from_e
result = await runtime.execute_tools_for_intent(state, tools=[])
assert state["transaction_parameter_collection"]["resolved_fields"] == ["valor"]
assert len(runtime.llm.prompts) == 1
assert "é a de quatorze e noventa e nove" in runtime.llm.prompts[0]
assert "TIM Fashion Mensal" in runtime.llm.prompts[0]
assert [(name, args["subject"], args["valor"]) for name, args in runtime.calls] == [
("validar_contestacao", "Cobrança Tamboro Mensal", 14.99)
]
assert state["transaction_status"] == "AWAITING_CONFIRMATION"
assert state["pending_tool_call"]["arguments"]["subject"] == "Tamboro Mensal"
assert state["pending_tool_call"]["arguments"]["valor"] == 14.99
assert result[-1]["awaiting_confirmation"] is True
class _ClearMissingSubjectReconcileLLM(_UnresolvedReconcileLLM):
class _ClearMissingSubjectReconcileLLM:
async def ainvoke(self, messages, **kwargs):
prompt = messages[-1]["content"]
if kwargs.get("profile_name") == "transaction_parameter_extraction":
if "FOCO PRINCIPAL DA RECONCILIAÇÃO" in prompt:
return {"content": json.dumps({
"fields": {
"subject": {"decision": "clear", "value": None, "source": "history:1"},
"valor": {"decision": "preserve", "value": 14.99, "source": "state"},
}
})}
return {"content": json.dumps({"subject": None, "valor": 14.99})}
return {"content": "{}"}
assert kwargs.get("profile_name") == "transaction_parameter_extraction"
# Resposta estruturada do reconciliador; independe do texto do prompt.
return {"content": json.dumps({"fields": {
"subject": {"decision": "clear", "value": None, "source": "history:1"},
"valor": {"decision": "preserve", "value": 14.99, "source": "state"},
}})}
@pytest.mark.asyncio
async def test_initially_missing_subject_clear_still_allows_unique_anchored_candidate():
"""Scenario 08: clear on an already-missing field must not suppress candidate fallback."""
async def test_clear_on_missing_subject_preserves_other_fields_without_inventing_value():
# Histórico: 1711575 removeu a geração de candidatos estruturais. O antigo
# double dependia de uma frase removida do prompt e devolvia a saída errada.
# O contrato remanescente é respeitar clear e preservar os outros campos,
# sem transformar um trecho do histórico em subject automaticamente.
rec = await reconcile_transaction_parameters(
_ClearMissingSubjectReconcileLLM(),
text="é a de quatorze e noventa e nove",
@@ -494,12 +501,9 @@ async def test_initially_missing_subject_clear_still_allows_unique_anchored_cand
assert rec["decisions"]["subject"] == "clear"
assert rec["clear_fields"] == ["subject"]
assert rec["candidates"] == {
"subject": "Cobrança Tamboro Mensal no valor de R$ 14,99 no dia 01/11/25."
}
assert rec["candidate_provenance"] == {
"subject": "unique_anchored_evidence_block"
}
assert rec["values"] == {"valor": 14.99}
assert rec["decisions"]["valor"] == "preserve"
assert rec["provenance"]["subject"] == "history:1"
class _AbandonRuntime(AgentRuntimeMixin):

View File

@@ -746,32 +746,41 @@ async def test_correction_of_missing_numeric_value_does_not_reopen_resolved_text
assert out == {"valor": 19.99}
@pytest.mark.asyncio
async def test_reconciler_exposes_schema_driven_field_evidence_views_for_missing_fields():
async def test_reconciler_receives_schema_context_and_preserves_known_values():
# Histórico: 1711575 (2026-09-07) removeu field_evidence_views e anchor_matches.
# O teste anterior verificava a montagem interna de um prompt descontinuado.
# Validamos o contrato: schema/contexto chegam ao provedor, o campo resolvido
# é aceito e preserve mantém o valor conhecido, mesmo se o modelo sugerir outro.
class _LLM:
def __init__(self):
self.prompt = ""
async def ainvoke(self, messages, **kwargs):
self.prompt = messages[0]["content"]
return {"content": '{"fields":{"subject":{"decision":"resolved","value":"Produto A","source":"history:1"},"valor":{"decision":"preserve","value":14.99,"source":"state"}}}'}
return {"content": json.dumps({"fields": {
"subject": {"decision": "resolved", "value": "Produto A", "source": "history:1"},
"valor": {"decision": "preserve", "value": 999.0, "source": "state"},
}})}
from agent_framework.runtime.transaction_parameters import reconcile_transaction_parameters
llm = _LLM()
schema = {
"subject": {"type": "string", "description": "referência concreta identificável"},
"valor": {"type": "number", "description": "quantia associada"},
}
current_text = "é a de quatorze e noventa e nove"
context = "history:1: assistant: Produto A no atributo 14,99. Produto B no atributo 10,00."
result = await reconcile_transaction_parameters(
llm,
text="é a de quatorze e noventa e nove",
tool_name="acao_generica",
parameter_names=["subject", "valor"],
known_arguments={"valor": 14.99},
parameter_schema={
"subject": {"type": "string", "description": "referência concreta identificável"},
"valor": {"type": "number", "description": "quantia associada"},
},
conversational_context=(
"priority_3_previous_assistant_tool_or_evidence_context:\n"
"history:1: assistant: * Produto A no atributo 14,99. * Produto B no atributo 10,00."
),
llm, text=current_text, tool_name="acao_generica",
parameter_names=["subject", "valor"], known_arguments={"valor": 14.99},
parameter_schema=schema, conversational_context=context,
)
assert result["values"]["subject"] == "Produto A"
assert "field_evidence_views:" in llm.prompt
assert '"subject"' in llm.prompt
assert '"anchor_matches"' in llm.prompt
assert current_text in llm.prompt
assert context in llm.prompt
for field, definition in schema.items():
assert json.dumps(field) in llm.prompt
assert definition["description"] in llm.prompt
assert result["values"] == {"subject": "Produto A", "valor": 14.99}
assert result["decisions"] == {"subject": "resolved", "valor": "preserve"}
assert result["provenance"] == {"subject": "history:1", "valor": "state"}
assert result["clear_fields"] == []

View File

@@ -1285,11 +1285,14 @@ async def test_prevalidation_parameter_message_is_used_once_without_changing_tra
assert runtime.transaction_clarification_message(state) != expected
assert "transaction_parameter_message_override" not in state
class _TemporalFallbackRuntime(AgentRuntimeMixin):
class _SingleExtractorValidationRuntime(AgentRuntimeMixin):
def __init__(self):
from types import SimpleNamespace
self.calls = []
self.llm = None
self.extractions = iter([
{"subject": "dezenove e noventa e nove"},
{"subject": "Tamboro Mensal"},
])
self.tool_router = SimpleNamespace(
registry=SimpleNamespace(
get_tool=lambda name: SimpleNamespace(
@@ -1308,72 +1311,63 @@ class _TemporalFallbackRuntime(AgentRuntimeMixin):
},
)
async def _extract_transaction_parameters_current_only(self, state, *, tool_name, missing_parameters, known_arguments=None):
# Simula o collector tradicional interpretando literalmente a fala atual.
return {"subject": "dezenove e noventa e nove"}
async def _reconcile_transaction_parameters(self, state, *, tool_name, parameter_names, known_arguments=None):
# Simula o reconciliador temporal usando tools.yaml + contexto newest->oldest.
return {
"values": {"subject": "Tamboro Mensal"},
"decisions": {"subject": "resolved"},
"provenance": {"subject": "history:2"},
"clear_fields": [],
}
async def _extract_transaction_parameters(self, state, *, tool_name, missing_parameters, known_arguments=None):
# Simula a saída do extractor por turno; não interpreta frases do prompt.
return next(self.extractions)
async def _call_mcp_tool(self, tool_name, arguments, state):
self.calls.append((tool_name, dict(arguments)))
if tool_name == "validar_vas_subject":
subject = arguments.get("subject")
if subject == "Tamboro Mensal":
if arguments.get("subject") == "Tamboro Mensal":
return {"ok": True, "tool_name": tool_name, "result": {"eligible": True, "status": "ELIGIBLE"}}
return {"ok": True, "tool_name": tool_name, "result": {
"eligible": False,
"status": "NEEDS_PARAMETER",
"parameter": "subject",
"reason": "subject_not_resolved",
"eligible": False, "status": "NEEDS_PARAMETER",
"parameter": "subject", "reason": "subject_not_resolved",
}}
return {"ok": True, "tool_name": tool_name, "result": {"status": "OK"}}
raise AssertionError("A operação não pode executar antes da confirmação")
@pytest.mark.asyncio
async def test_temporal_reconciler_is_fallback_after_traditional_candidate_fails_prevalidation():
runtime = _TemporalFallbackRuntime()
async def test_prevalidation_rejection_recollects_next_turn_without_hidden_retry():
# Histórico: 1711575 (2026-09-07) removeu candidatos estruturais;
# 82636ec (2026-09-08) adotou single_extractor. O antigo teste exigia
# uma segunda reconciliação/validação escondida no mesmo turno.
# Contrato atual: respeitar NEEDS_PARAMETER, aguardar nova informação e
# validar novamente antes de pedir confirmação, sem executar a operação.
runtime = _SingleExtractorValidationRuntime()
state = {
"user_text": "é a de dezenove e noventa e nove",
"sanitized_input": "é a de dezenove e noventa e nove",
"route": "contestacao_agent",
"intent": "state:COLLECTING_CONTESTACAO_PARAMETERS",
"route": "contestacao_agent", "intent": "state:COLLECTING_CONTESTACAO_PARAMETERS",
"transaction_status": "COLLECTING_PARAMETERS",
"selected_tool_call": {"tool_name": "cancelar_vas_avulso", "arguments": {}},
"active_transaction": {
"transaction_id": "tx-1",
"tool_name": "cancelar_vas_avulso",
"arguments": {},
"status": "COLLECTING_PARAMETERS",
"started_from_intent": "contas_vas_cancel",
"requires": ["subject"],
"transaction_id": "tx-1", "tool_name": "cancelar_vas_avulso",
"arguments": {}, "status": "COLLECTING_PARAMETERS",
"started_from_intent": "contas_vas_cancel", "requires": ["subject"],
"parameter_schema": {"subject": {"type": "string", "description": "Nome do serviço ou VAS alvo do cancelamento."}},
"tool_description": "Cancela o serviço VAS selecionado pelo cliente.",
"parameter_conversational_context": (
"user: não reconheço esse Tamboro Mensal na minha fatura\n"
"assistant: identifiquei Tamboro Mensal por R$ 14,99 e R$ 19,99"
),
},
"context": {},
}
result = await runtime.execute_tools_for_intent(state, tools=[])
await runtime.execute_tools_for_intent(state, tools=[])
assert [(name, args["subject"]) for name, args in runtime.calls] == [
("validar_vas_subject", "dezenove e noventa e nove")
]
assert state["transaction_status"] == "COLLECTING_PARAMETERS"
assert "subject" in state["missing_parameters"]
assert not state["active_transaction"]["arguments"].get("subject")
assert not state.get("confirmation_received")
validations = [(name, args.get("subject")) for name, args in runtime.calls if name == "validar_vas_subject"]
assert validations == [
state["user_text"] = state["sanitized_input"] = "o serviço Tamboro Mensal"
result = await runtime.execute_tools_for_intent(state, tools=[])
assert [(name, args["subject"]) for name, args in runtime.calls] == [
("validar_vas_subject", "dezenove e noventa e nove"),
("validar_vas_subject", "Tamboro Mensal"),
]
assert state["transaction_parameter_collection"]["mode"] == "current_turn"
assert state["transaction_parameter_reconciliation"]["trigger"] == "prevalidation_needs_parameter"
assert state["pending_tool_call"]["arguments"]["subject"] == "Tamboro Mensal"
assert state["transaction_status"] == "AWAITING_CONFIRMATION"
assert not state.get("confirmation_received")
assert result[-1]["awaiting_confirmation"] is True
@pytest.mark.asyncio