🐛 fix(orchestration): endurecer execucao de tools e retomada do fluxo de revisao

- saneia argumentos da tool no registry e converte erros de assinatura em falhas controladas

- normaliza argumentos de listagem de agendamentos e rebaixa call_tool incompleto de revisao mesmo com domain inconsistente

- limpa confirmacoes pendentes de revisao quando o usuario inicia um novo agendamento

- adiciona cobertura para listagem de revisoes com kwargs extras e para retomada segura do agendamento
main
parent 95f3ed2f6b
commit 135718bc43

@ -374,9 +374,14 @@ class ReviewFlowMixin:
draft = self.state.get_entry("pending_review_drafts", user_id, expire=True) draft = self.state.get_entry("pending_review_drafts", user_id, expire=True)
extracted = self._normalize_review_fields(extracted_fields) extracted = self._normalize_review_fields(extracted_fields)
pending_reuse = self.state.get_entry("pending_review_reuse_confirmations", user_id, expire=True) pending_reuse = self.state.get_entry("pending_review_reuse_confirmations", user_id, expire=True)
pending_confirmation = self.state.get_entry("pending_review_confirmations", user_id, expire=True)
active_review_context = self._active_domain(user_id) == "review" active_review_context = self._active_domain(user_id) == "review"
review_flow_source = "draft" if draft else None review_flow_source = "draft" if draft else None
if has_intent and draft is None and pending_confirmation and not self._is_affirmative_message(message):
self.state.pop_entry("pending_review_confirmations", user_id)
pending_confirmation = None
if pending_reuse: if pending_reuse:
should_reuse = False should_reuse = False
if self._is_negative_message(message): if self._is_negative_message(message):

@ -78,6 +78,14 @@ class EntityNormalizer:
"limit": "limite", "limit": "limite",
"customer_cpf": "cpf", "customer_cpf": "cpf",
}, },
"listar_agendamentos_revisao": {
"max_results": "limite",
"limit": "limite",
"review_plate": "placa",
"vehicle_plate": "placa",
"review_status": "status",
"schedule_status": "status",
},
"cancelar_agendamento_revisao": { "cancelar_agendamento_revisao": {
"review_id": "protocolo", "review_id": "protocolo",
"schedule_id": "protocolo", "schedule_id": "protocolo",
@ -338,7 +346,9 @@ class EntityNormalizer:
payload["response_to_user"] = None payload["response_to_user"] = None
return payload return payload
if tool_name == "agendar_revisao" and str(payload.get("domain") or "") == "review": if tool_name == "agendar_revisao" and (
str(payload.get("domain") or "") == "review" or payload.get("intent") == "review_schedule"
):
review_entities = self.normalize_review_fields( review_entities = self.normalize_review_fields(
{ {
**(entities.get("review_fields") or {}), **(entities.get("review_fields") or {}),
@ -353,7 +363,10 @@ class EntityNormalizer:
payload["response_to_user"] = None payload["response_to_user"] = None
return payload return payload
if tool_name in {"cancelar_agendamento_revisao", "editar_data_revisao"} and str(payload.get("domain") or "") == "review": if tool_name in {"cancelar_agendamento_revisao", "editar_data_revisao"} and (
str(payload.get("domain") or "") == "review"
or payload.get("intent") in {"review_cancel", "review_reschedule"}
):
review_management_entities = self.normalize_review_management_fields( review_management_entities = self.normalize_review_management_fields(
{ {
**(entities.get("review_management_fields") or {}), **(entities.get("review_management_fields") or {}),
@ -415,6 +428,19 @@ class EntityNormalizer:
coerced["limite"] = max(1, min(int(round(limite)), 50)) coerced["limite"] = max(1, min(int(round(limite)), 50))
return coerced return coerced
if normalized_tool_name == "listar_agendamentos_revisao":
coerced: dict = {}
plate = self.normalize_plate(normalized_arguments.get("placa"))
if plate:
coerced["placa"] = plate
status = str(normalized_arguments.get("status") or "").strip()
if status:
coerced["status"] = status
limite = self.normalize_positive_number(normalized_arguments.get("limite"))
if limite:
coerced["limite"] = max(1, min(int(round(limite)), 100))
return coerced
if normalized_tool_name == "cancelar_agendamento_revisao": if normalized_tool_name == "cancelar_agendamento_revisao":
return self.normalize_review_management_fields(normalized_arguments) return self.normalize_review_management_fields(normalized_arguments)

@ -86,7 +86,41 @@ class ToolRegistry:
) )
call_args = dict(arguments or {}) call_args = dict(arguments or {})
if user_id is not None and "user_id" in inspect.signature(tool.handler).parameters: signature = inspect.signature(tool.handler)
if user_id is not None and "user_id" in signature.parameters:
call_args["user_id"] = user_id call_args["user_id"] = user_id
return await tool.handler(**call_args) supported_args = {
key: value
for key, value in call_args.items()
if key in signature.parameters
}
missing_required = [
parameter.name
for parameter in signature.parameters.values()
if parameter.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
and parameter.default is inspect._empty
and parameter.name not in supported_args
]
if missing_required:
raise HTTPException(
status_code=400,
detail={
"code": "invalid_tool_arguments",
"message": f"Argumentos obrigatorios ausentes para a tool {name}: {', '.join(missing_required)}.",
"retryable": True,
"field": missing_required[0],
},
)
try:
return await tool.handler(**supported_args)
except TypeError as exc:
raise HTTPException(
status_code=400,
detail={
"code": "invalid_tool_arguments",
"message": f"Argumentos invalidos para a tool {name}.",
"retryable": True,
},
) from exc

@ -10,8 +10,10 @@ from fastapi import HTTPException
from app.services.flows.order_flow import OrderFlowMixin from app.services.flows.order_flow import OrderFlowMixin
from app.services.flows.review_flow import ReviewFlowMixin from app.services.flows.review_flow import ReviewFlowMixin
from app.integrations.telegram_satellite_service import _ensure_supported_runtime_configuration from app.integrations.telegram_satellite_service import _ensure_supported_runtime_configuration
from app.models.tool_model import ToolDefinition
from app.services.orchestration.conversation_policy import ConversationPolicy from app.services.orchestration.conversation_policy import ConversationPolicy
from app.services.orchestration.entity_normalizer import EntityNormalizer from app.services.orchestration.entity_normalizer import EntityNormalizer
from app.services.tools.tool_registry import ToolRegistry
from app.services.tools.handlers import _parse_data_hora_revisao from app.services.tools.handlers import _parse_data_hora_revisao
@ -1207,6 +1209,39 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(draft["payload"].get("placa"), "ABC1269") self.assertEqual(draft["payload"].get("placa"), "ABC1269")
self.assertIn("a data e hora desejada para a revisao", response) self.assertIn("a data e hora desejada para a revisao", response)
async def test_review_flow_clears_stale_pending_confirmation_when_user_starts_new_schedule(self):
state = FakeState(
entries={
"pending_review_confirmations": {
21: {
"payload": {
"placa": "ABC9999",
"data_hora": "14/03/2026 10:00",
"modelo": "Corolla",
"ano": 2020,
"km": 30000,
"revisao_previa_concessionaria": True,
},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
}
)
registry = FakeRegistry()
flow = ReviewFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_schedule_review(
message="quero agendar uma revisao",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "review_schedule", "domain": "review", "action": "collect_review_schedule"},
)
self.assertIsNone(state.get_entry("pending_review_confirmations", 21))
self.assertIsNotNone(state.get_entry("pending_review_drafts", 21))
self.assertIn("a placa do veiculo", response)
async def test_review_flow_keeps_draft_and_clears_data_hora_on_retryable_error(self): async def test_review_flow_keeps_draft_and_clears_data_hora_on_retryable_error(self):
state = FakeState( state = FakeState(
entries={ entries={
@ -1431,5 +1466,37 @@ class ContextSwitchPolicyTests(unittest.TestCase):
self.assertIsNone(service._get_user_context(9).get("pending_switch")) self.assertIsNone(service._get_user_context(9).get("pending_switch"))
class ToolRegistryExecutionTests(unittest.IsolatedAsyncioTestCase):
async def test_execute_ignores_extra_arguments_for_review_listing_tool(self):
async def fake_listar_agendamentos_revisao(
user_id: int | None = None,
placa: str | None = None,
status: str | None = None,
limite: int | None = 20,
):
return [{"user_id": user_id, "placa": placa, "status": status, "limite": limite}]
registry = ToolRegistry.__new__(ToolRegistry)
registry._tools = [
ToolDefinition(
name="listar_agendamentos_revisao",
description="",
parameters={},
handler=fake_listar_agendamentos_revisao,
)
]
result = await registry.execute(
"listar_agendamentos_revisao",
{"placa": "ABC1234", "status": "agendado", "limite": 10, "tipo": "revisao"},
user_id=21,
)
self.assertEqual(
result,
[{"user_id": 21, "placa": "ABC1234", "status": "agendado", "limite": 10}],
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

@ -374,6 +374,70 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(decision["entities"]["review_fields"]["modelo"], "Corolla") self.assertEqual(decision["entities"]["review_fields"]["modelo"], "Corolla")
self.assertEqual(decision["entities"]["review_fields"]["ano"], 2020) self.assertEqual(decision["entities"]["review_fields"]["ano"], 2020)
def test_coerce_turn_decision_downgrades_incomplete_review_schedule_tool_call_even_with_general_domain(self):
normalizer = EntityNormalizer()
decision = normalizer.coerce_turn_decision(
{
"intent": "review_schedule",
"domain": "general",
"action": "call_tool",
"tool_name": "agendar_revisao",
"tool_arguments": {
"placa_veiculo": "ABC1234",
"modelo_veiculo": "Corolla",
},
"entities": {
"generic_memory": {},
"review_fields": {},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {},
},
"missing_fields": [],
"response_to_user": None,
}
)
self.assertEqual(decision["action"], "collect_review_schedule")
self.assertIsNone(decision["tool_name"])
self.assertEqual(decision["tool_arguments"], {})
self.assertEqual(decision["entities"]["review_fields"]["placa"], "ABC1234")
self.assertEqual(decision["entities"]["review_fields"]["modelo"], "Corolla")
def test_coerce_turn_decision_normalizes_review_listing_tool_arguments(self):
normalizer = EntityNormalizer()
decision = normalizer.coerce_turn_decision(
{
"intent": "review_list",
"domain": "review",
"action": "call_tool",
"tool_name": "listar_agendamentos",
"tool_arguments": {
"vehicle_plate": "abc1234",
"schedule_status": "agendado",
"limit": 10,
"tipo": "revisao",
},
"entities": {
"generic_memory": {},
"review_fields": {},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {},
},
"missing_fields": [],
"response_to_user": None,
}
)
self.assertEqual(decision["tool_name"], "listar_agendamentos_revisao")
self.assertEqual(
decision["tool_arguments"],
{"placa": "ABC1234", "status": "agendado", "limite": 10},
)
def test_coerce_turn_decision_normalizes_review_management_tool_name_alias(self): def test_coerce_turn_decision_normalizes_review_management_tool_name_alias(self):
normalizer = EntityNormalizer() normalizer = EntityNormalizer()

Loading…
Cancel
Save