diff --git a/app/services/flows/review_flow.py b/app/services/flows/review_flow.py index 5e39e38..234be9c 100644 --- a/app/services/flows/review_flow.py +++ b/app/services/flows/review_flow.py @@ -374,9 +374,14 @@ class ReviewFlowMixin: draft = self.state.get_entry("pending_review_drafts", user_id, expire=True) extracted = self._normalize_review_fields(extracted_fields) 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" 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: should_reuse = False if self._is_negative_message(message): diff --git a/app/services/orchestration/entity_normalizer.py b/app/services/orchestration/entity_normalizer.py index a80bafd..dab2a04 100644 --- a/app/services/orchestration/entity_normalizer.py +++ b/app/services/orchestration/entity_normalizer.py @@ -78,6 +78,14 @@ class EntityNormalizer: "limit": "limite", "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": { "review_id": "protocolo", "schedule_id": "protocolo", @@ -338,7 +346,9 @@ class EntityNormalizer: payload["response_to_user"] = None 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( { **(entities.get("review_fields") or {}), @@ -353,7 +363,10 @@ class EntityNormalizer: payload["response_to_user"] = None 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( { **(entities.get("review_management_fields") or {}), @@ -415,6 +428,19 @@ class EntityNormalizer: coerced["limite"] = max(1, min(int(round(limite)), 50)) 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": return self.normalize_review_management_fields(normalized_arguments) diff --git a/app/services/tools/tool_registry.py b/app/services/tools/tool_registry.py index 4a51d21..9c516b8 100644 --- a/app/services/tools/tool_registry.py +++ b/app/services/tools/tool_registry.py @@ -86,7 +86,41 @@ class ToolRegistry: ) 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 - 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 diff --git a/tests/test_conversation_adjustments.py b/tests/test_conversation_adjustments.py index 66e63c9..31b9b47 100644 --- a/tests/test_conversation_adjustments.py +++ b/tests/test_conversation_adjustments.py @@ -10,8 +10,10 @@ from fastapi import HTTPException from app.services.flows.order_flow import OrderFlowMixin from app.services.flows.review_flow import ReviewFlowMixin 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.entity_normalizer import EntityNormalizer +from app.services.tools.tool_registry import ToolRegistry 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.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): state = FakeState( entries={ @@ -1431,5 +1466,37 @@ class ContextSwitchPolicyTests(unittest.TestCase): 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__": unittest.main() diff --git a/tests/test_turn_decision_contract.py b/tests/test_turn_decision_contract.py index d79ebea..0d82ab1 100644 --- a/tests/test_turn_decision_contract.py +++ b/tests/test_turn_decision_contract.py @@ -374,6 +374,70 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(decision["entities"]["review_fields"]["modelo"], "Corolla") 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): normalizer = EntityNormalizer()