bugfix reconciliation extractor

This commit is contained in:
2026-09-07 20:16:57 -03:00
parent d84d833694
commit 2c3aa58b59
193 changed files with 1689 additions and 68 deletions

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import json
import pytest
from agent_framework.runtime.transaction_parameters import extract_transaction_parameters
from agent_framework.runtime.transaction_parameters import extract_transaction_parameters, reconcile_transaction_parameters
class _ContextAwareLLM:
@@ -42,3 +42,370 @@ async def test_contextual_reentry_separates_current_claim_from_prior_context_for
assert "Cobrança Tamboro Mensal" in llm.prompt
assert "user_message: é a de quatorze e noventa e nove" in llm.prompt
assert "Não trate texto do contexto como uma nova afirmação do cliente" in llm.prompt
class _TwoPassContextLLM:
def __init__(self):
self.calls = []
async def ainvoke(self, messages, **kwargs):
prompt = messages[-1]["content"]
self.calls.append(prompt)
# First pass: current utterance provides only the amount.
if "user_message: é a de vinte e cinco e cinquenta" in prompt:
return json.dumps({"subject": None, "valor": 25.50}, ensure_ascii=False)
# Second bounded pass: previous USER utterance provides the missing entity.
if "user_message: não reconheço esse Tamboro Mensal na minha fatura" in prompt:
return json.dumps({"subject": "Tamboro Mensal"}, ensure_ascii=False)
return "{}"
@pytest.mark.asyncio
async def test_contextual_reentry_second_pass_recovers_only_missing_candidate_from_prior_user_turn():
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
class Runtime(AgentRuntimeMixin):
pass
runtime = Runtime()
runtime.llm = _TwoPassContextLLM()
state = {
"route_decision": {
"metadata": {
"contextual_reentry": True,
"original_input": "é a de vinte e cinco e cinquenta",
"relevant_conversation_context": (
"user: não reconheço esse Tamboro Mensal na minha fatura\n"
"assistant: identifiquei duas cobranças de Tamboro Mensal"
),
}
},
"sanitized_input": "é a de vinte e cinco e cinquenta",
}
out = await runtime._extract_transaction_parameters(
state=state,
tool_name="contestar_cobranca",
missing_parameters=["subject", "valor"],
known_arguments={},
)
assert float(out["valor"]) == 25.5
assert out["subject"] == "Tamboro Mensal"
assert len(runtime.llm.calls) >= 2
class _CoherentTemporalLLM:
"""Simulates the structured decisions expected from temporal reconciliation."""
def __init__(self):
self.prompts = []
async def ainvoke(self, messages, **kwargs):
prompt = messages[-1]["content"]
self.prompts.append(prompt)
low = prompt.lower()
# Product A + value X established.
if "user_message: na verdade é tim fashion mensal" in low:
return json.dumps({"fields": {
"subject": {"decision": "resolved", "value": "TIM Fashion Mensal", "source": "current"},
# Previous 14.99 was tied to the former product and must not be transplanted.
"valor": {"decision": "clear", "value": None, "source": "history:1"},
}}, ensure_ascii=False)
if "user_message: na verdade é produto inexistente" in low:
return json.dumps({"fields": {
# Keep the newest candidate so authoritative validation can reject it.
"subject": {"decision": "resolved", "value": "Produto Inexistente", "source": "current"},
"valor": {"decision": "clear", "value": None, "source": "history:1"},
}}, ensure_ascii=False)
if "user_message: desculpa, era tamboro mensal mesmo" in low:
return json.dumps({"fields": {
"subject": {"decision": "resolved", "value": "Tamboro Mensal", "source": "current"},
# Full temporal text makes the old amount coherent again with the restored product.
"valor": {"decision": "resolved", "value": 14.99, "source": "history:2"},
}}, ensure_ascii=False)
return json.dumps({"fields": {
"subject": {"decision": "preserve", "value": None, "source": "state"},
"valor": {"decision": "preserve", "value": None, "source": "state"},
}}, ensure_ascii=False)
@pytest.mark.asyncio
async def test_temporal_reconciliation_product_change_rechecks_old_value_as_part_of_coherent_set():
from agent_framework.runtime.transaction_parameters import reconcile_transaction_parameters
llm = _CoherentTemporalLLM()
out = await reconcile_transaction_parameters(
llm,
text="na verdade é TIM Fashion Mensal",
conversational_context=(
"history:1: user: é R$ 14,99\n"
"history:2: user: quero contestar Tamboro Mensal"
),
tool_name="contestar_cobranca",
parameter_names=["subject", "valor"],
known_arguments={"subject": "Tamboro Mensal", "valor": 14.99},
parameter_schema={
"subject": {"type": "string", "description": "Nome do serviço, produto, item ou cobrança objeto da contestação."},
"valor": {"type": "number", "description": "Valor monetário associado ao item objeto da contestação."},
},
tool_description="Contesta uma cobrança após validação.",
)
assert out["values"] == {"subject": "TIM Fashion Mensal"}
assert out["clear_fields"] == ["valor"]
assert out["provenance"]["subject"] == "current"
@pytest.mark.asyncio
async def test_temporal_reconciliation_invalid_new_product_does_not_erase_history_or_fall_back_silently():
from agent_framework.runtime.transaction_parameters import reconcile_transaction_parameters
llm = _CoherentTemporalLLM()
history = (
"history:1: user: é R$ 14,99\n"
"history:2: user: quero contestar Tamboro Mensal"
)
out = await reconcile_transaction_parameters(
llm,
text="na verdade é Produto Inexistente",
conversational_context=history,
tool_name="contestar_cobranca",
parameter_names=["subject", "valor"],
known_arguments={"subject": "Tamboro Mensal", "valor": 14.99},
parameter_schema={
"subject": {"type": "string", "description": "Nome do serviço, produto, item ou cobrança objeto da contestação."},
"valor": {"type": "number", "description": "Valor monetário associado ao item objeto da contestação."},
},
tool_description="Contesta uma cobrança após validação.",
)
# The newest candidate stays visible for pre-validation; the old product is not silently restored.
assert out["values"] == {"subject": "Produto Inexistente"}
assert "valor" in out["clear_fields"]
assert "Tamboro Mensal" in llm.prompts[-1] # history remains available to future reconciliation.
@pytest.mark.asyncio
async def test_temporal_reconciliation_can_restore_coherent_old_value_when_product_is_explicitly_restored():
from agent_framework.runtime.transaction_parameters import reconcile_transaction_parameters
llm = _CoherentTemporalLLM()
out = await reconcile_transaction_parameters(
llm,
text="desculpa, era Tamboro Mensal mesmo",
conversational_context=(
"history:1: user: na verdade é Produto Inexistente\n"
"history:2: user: é R$ 14,99\n"
"history:3: user: quero contestar Tamboro Mensal"
),
tool_name="contestar_cobranca",
parameter_names=["subject", "valor"],
known_arguments={},
parameter_schema={
"subject": {"type": "string", "description": "Nome do serviço, produto, item ou cobrança objeto da contestação."},
"valor": {"type": "number", "description": "Valor monetário associado ao item objeto da contestação."},
},
tool_description="Contesta uma cobrança após validação.",
)
assert out["values"] == {"subject": "Tamboro Mensal", "valor": 14.99}
assert out["provenance"] == {"subject": "current", "valor": "history:2"}
def test_temporal_context_priority_places_assistant_before_older_user_history():
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
context = (
"user: tem uma cobrança aqui que eu não reconheço\n"
"assistant: Cobrança Tamboro Mensal no valor de R$ 14,99; TIM Fashion Mensal no valor de R$ 10,00."
)
prioritized = AgentRuntimeMixin._transaction_context_priority_view(context)
assert "priority_3_previous_assistant_tool_or_evidence_context:" in prioritized
assert "priority_4_previous_user_utterances:" in prioritized
assert prioritized.index("assistant: Cobrança Tamboro Mensal") < prioritized.index("user: tem uma cobrança")
class _AssistantRelationshipReconcilerLLM:
def __init__(self):
self.prompt = ""
async def ainvoke(self, messages, **kwargs):
self.prompt = messages[-1]["content"]
# Simula o comportamento desejado: a fala atual dá o valor, enquanto a
# relação item->valor está numa resposta anterior do assistant.
assert "user_message: é a de quatorze e noventa e nove" in self.prompt
assert "priority_3_previous_assistant_tool_or_evidence_context:" in self.prompt
assert "assistant: Cobrança Tamboro Mensal no valor de R$ 14,99" in self.prompt
assert "anchor_relation_candidates:" in self.prompt
assert "anchor[valor=14.99]" in self.prompt
return json.dumps({"fields": {
"subject": {"decision": "resolved", "value": "Tamboro Mensal", "source": "history:1"},
"valor": {"decision": "resolved", "value": 14.99, "source": "current"},
}}, ensure_ascii=False)
@pytest.mark.asyncio
async def test_temporal_reconciler_can_use_grounded_assistant_relationship_after_current_only_extraction_is_incomplete():
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
class Runtime(AgentRuntimeMixin):
pass
runtime = Runtime()
runtime.llm = _AssistantRelationshipReconcilerLLM()
runtime.tool_router = type("TR", (), {
"registry": type("REG", (), {
"get_tool": staticmethod(lambda name: type("CFG", (), {
"requires": ["subject", "valor"],
"args_schema": {
"subject": {"type": "string", "description": "Nome do item ou cobrança a contestar."},
"valor": {"type": "number", "description": "Valor monetário do item a contestar."},
},
"description": "Contesta uma cobrança após validação.",
})())
})(),
"resolve_execution_policy": staticmethod(lambda name, arguments=None: {
"operation_type": "transactional",
"requires": ["subject", "valor"],
}),
})()
state = {
"sanitized_input": "é a de quatorze e noventa e nove",
"user_text": "é a de quatorze e noventa e nove",
"route_decision": {"metadata": {
"contextual_reentry": True,
"original_input": "é a de quatorze e noventa e nove",
"relevant_conversation_context": (
"user: tem uma cobrança aqui que eu não reconheço\n"
"assistant: Cobrança Tamboro Mensal no valor de R$ 14,99; TIM Fashion Mensal no valor de R$ 10,00."
),
}},
}
# O happy path corrente só consegue o valor; o subject fica faltando.
class _CurrentOnlyLLM:
async def ainvoke(self, messages, **kwargs):
return json.dumps({"valor": 14.99}, ensure_ascii=False)
original_llm = runtime.llm
runtime.llm = _CurrentOnlyLLM()
current = await runtime._extract_transaction_parameters_current_only(
state,
tool_name="contestar_cobranca",
missing_parameters=["subject", "valor"],
known_arguments={},
)
assert current == {"valor": 14.99}
runtime.llm = original_llm
reconciled = await runtime._reconcile_transaction_parameters(
state,
tool_name="contestar_cobranca",
parameter_names=["subject", "valor"],
known_arguments=current,
)
assert reconciled["values"]["subject"] == "Tamboro Mensal"
assert float(reconciled["values"]["valor"]) == 14.99
assert "(3) respostas anteriores do assistente" in runtime.llm.prompt
class _UnifiedAnchorScanLLM:
def __init__(self):
self.calls = []
async def ainvoke(self, messages, **kwargs):
prompt = messages[-1]["content"]
self.calls.append(prompt)
assert "UMA ÚNICA VARREDURA TEMPORAL" in prompt
assert "known_parameters: {\"valor\": 14.99}" in prompt
assert "missing_parameters: [\"subject\", \"data\"]" in prompt
assert "anchor_relation_candidates:" in prompt
assert "anchor[valor=14.99]" in prompt
assert "Tamboro Mensal no valor de R$ 14,99" in prompt
return json.dumps({"fields": {
"subject": {"decision": "resolved", "value": "Tamboro Mensal", "source": "history:1"},
"valor": {"decision": "preserve", "value": None, "source": "state"},
"data": {"decision": "resolved", "value": "01/11/2025", "source": "history:1"},
}}, ensure_ascii=False)
@pytest.mark.asyncio
async def test_temporal_reconciler_single_scan_uses_all_known_fields_as_anchors_and_resolves_all_missing_fields():
from agent_framework.runtime.transaction_parameters import reconcile_transaction_parameters
llm = _UnifiedAnchorScanLLM()
result = await reconcile_transaction_parameters(
llm,
text="é a de quatorze e noventa e nove",
tool_name="contestar_cobranca",
parameter_names=["subject", "valor", "data"],
known_arguments={"valor": 14.99},
parameter_schema={
"subject": {"type": "string", "description": "Nome do item ou cobrança a contestar."},
"valor": {"type": "number", "description": "Valor monetário do item a contestar."},
"data": {"type": "string", "description": "Data da cobrança selecionada."},
},
tool_description="Contesta uma cobrança após validação.",
conversational_context=(
"priority_3_previous_assistant_tool_or_evidence_context:\n"
"history:1: assistant: Cobrança Tamboro Mensal no valor de R$ 14,99 no dia 01/11/2025 * "
"TIM Fashion Mensal no valor de R$ 10,00 no dia 01/11/2025.\n"
"priority_4_previous_user_utterances:\n"
"history:2: user: tem uma cobrança aqui que eu não reconheço"
),
)
assert len(llm.calls) == 1
assert result["values"]["subject"] == "Tamboro Mensal"
assert float(result["values"]["valor"]) == 14.99
assert result["values"]["data"] == "01/11/2025"
assert result["provenance"]["subject"] == "history:1"
class _AssistantEvidenceSegmentationLLM:
def __init__(self):
self.calls = 0
async def ainvoke(self, messages, **kwargs):
import json
self.calls += 1
prompt = messages[-1]["content"]
evidence = json.loads(prompt.split("evidence_blocks: ", 1)[1].split("\n", 1)[0])
anchored = [
block for block in evidence
if block.get("role") == "assistant"
and any(match.get("field") == "valor" and float(match.get("value")) == 14.99 for match in block.get("anchor_matches", []))
]
assert len(anchored) == 1
assert "Tamboro Mensal" in anchored[0]["text"]
assert "TIM Fashion" not in anchored[0]["text"]
assert "Neymar" not in anchored[0]["text"]
return {
"content": json.dumps({
"fields": {
"subject": {"decision": "resolved", "value": "Tamboro Mensal", "source": "history:1"},
"valor": {"decision": "preserve", "value": 14.99, "source": "state"},
}
}, ensure_ascii=False)
}
@pytest.mark.asyncio
async def test_reconciler_segments_assistant_list_so_known_anchor_resolves_missing_field():
llm = _AssistantEvidenceSegmentationLLM()
result = await reconcile_transaction_parameters(
llm,
text="é a de quatorze e noventa e nove",
tool_name="contestar_cobranca",
parameter_names=["subject", "valor"],
known_arguments={"valor": 14.99},
parameter_schema={
"subject": {"type": "string", "description": "Nome do item ou cobrança a contestar."},
"valor": {"type": "number", "description": "Valor monetário do item a contestar."},
},
conversational_context=(
"priority_3_previous_assistant_tool_or_evidence_context:\n"
"history:1: assistant: Houve as cobranças: * Cobrança Tamboro Mensal no valor de R$ 14,99 no dia 01/11/25. "
"* Cobrança TIM Fashion Mensal no valor de R$ 10,00 no dia 01/11/25. "
"* Cobrança Neymar Jr no valor de R$ 12,00 no dia 01/11/25.\n"
"priority_4_previous_user_utterances:\n"
"history:2: user: tem uma cobrança aqui que eu não reconheço"
),
)
assert llm.calls == 1
assert result["values"]["subject"] == "Tamboro Mensal"
assert float(result["values"]["valor"]) == 14.99