From 2c4e1dd688d66d9252a6b59436f6f81cc7d4779d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vitor=20Hugo=20Belorio=20Sim=C3=A3o?= Date: Wed, 18 Mar 2026 18:12:26 -0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20feat(rental):=20blindar=20follow?= =?UTF-8?q?-ups=20e=20comprovantes=20multimodais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/ai/llm_service.py | 31 +- app/services/flows/rental_flow.py | 57 ++++ .../orchestration/orquestrador_service.py | 296 +++++++++++++++++ app/services/orchestration/prompt_builders.py | 4 +- .../orchestration/response_formatter.py | 4 +- tests/test_conversation_adjustments.py | 18 + tests/test_llm_service.py | 14 + tests/test_turn_decision_contract.py | 312 ++++++++++++++++++ 8 files changed, 722 insertions(+), 14 deletions(-) diff --git a/app/services/ai/llm_service.py b/app/services/ai/llm_service.py index e658306..5a2ed45 100644 --- a/app/services/ai/llm_service.py +++ b/app/services/ai/llm_service.py @@ -40,18 +40,7 @@ class LLMService: if not attachments: return str(caption or "").strip() - prompt = ( - "Voce esta preparando uma mensagem textual curta para um orquestrador de atendimento automotivo e locacao. " - "Analise a imagem enviada pelo usuario e a legenda opcional. " - "Se for comprovante de pagamento de aluguel, responda com uma frase objetiva em portugues no formato: " - "Registrar pagamento de aluguel: contrato <...>; placa <...>; valor <...>; data_pagamento <...>; favorecido <...>; identificador_comprovante <...>; observacoes <...>. " - "Se for multa de transito relacionada a carro alugado, responda com uma frase objetiva em portugues no formato: " - "Registrar multa de aluguel: placa <...>; contrato <...>; auto_infracao <...>; orgao_emissor <...>; valor <...>; data_infracao <...>; vencimento <...>; observacoes <...>. " - "Se for outro documento automotivo util, resuma em uma frase com os dados importantes. " - "Se nao conseguir identificar com seguranca, responda exatamente: Nao consegui identificar os dados da imagem. Descreva o documento ou envie uma foto mais nitida. " - "Use apenas dados observaveis e nao invente informacoes. " - f"Legenda do usuario: {(caption or '').strip() or 'sem legenda'}" - ) + prompt = self._build_image_workflow_prompt(caption=caption) contents: List[Any] = [prompt] for attachment in attachments: @@ -86,6 +75,24 @@ class LLMService: payload = self._extract_response_payload(response) return (payload.get("response") or "").strip() or (caption or "").strip() + def _build_image_workflow_prompt(self, *, caption: str | None) -> str: + normalized_caption = (caption or "").strip() or "sem legenda" + return ( + "Voce esta preparando uma mensagem textual curta para um orquestrador de atendimento automotivo e locacao. " + "Analise a imagem enviada pelo usuario e a legenda opcional. " + "Se for comprovante de pagamento de aluguel, responda com uma frase objetiva em portugues no formato: " + "Registrar pagamento de aluguel: contrato <...>; placa <...>; valor <...>; data_pagamento <...>; favorecido <...>; identificador_comprovante <...>; observacoes <...>. " + "Se a data de pagamento incluir hora e minuto visiveis na imagem, preserve a data e a hora no campo data_pagamento no formato DD/MM/AAAA HH:MM. " + "Nao reduza para somente a data quando a hora estiver visivel. " + "Se apenas a data estiver visivel, use somente a data. " + "Se for multa de transito relacionada a carro alugado, responda com uma frase objetiva em portugues no formato: " + "Registrar multa de aluguel: placa <...>; contrato <...>; auto_infracao <...>; orgao_emissor <...>; valor <...>; data_infracao <...>; vencimento <...>; observacoes <...>. " + "Se for outro documento automotivo util, resuma em uma frase com os dados importantes. " + "Se nao conseguir identificar com seguranca, responda exatamente: Nao consegui identificar os dados da imagem. Descreva o documento ou envie uma foto mais nitida. " + "Use apenas dados observaveis e nao invente informacoes. " + f"Legenda do usuario: {normalized_caption}" + ) + def build_vertex_tools(self, tools: List[ToolDefinition]) -> Optional[List[Tool]]: """Converte tools internas para o formato esperado pelo Vertex AI.""" # Vertex espera uma lista de Tool, com function_declarations agrupadas em um unico Tool. diff --git a/app/services/flows/rental_flow.py b/app/services/flows/rental_flow.py index d6df897..cf77f00 100644 --- a/app/services/flows/rental_flow.py +++ b/app/services/flows/rental_flow.py @@ -88,6 +88,62 @@ class RentalFlowMixin: selected_vehicle = context.get("selected_rental_vehicle") return dict(selected_vehicle) if isinstance(selected_vehicle, dict) else None + def _sanitize_rental_contract_snapshot(self, payload) -> dict | None: + if not isinstance(payload, dict): + return None + + contract_number = str(payload.get("contrato_numero") or "").strip().upper() + plate = technical_normalizer.normalize_plate(payload.get("placa")) + if not contract_number and not plate: + return None + + snapshot: dict = {} + if contract_number: + snapshot["contrato_numero"] = contract_number + if plate: + snapshot["placa"] = plate + + for field_name in ( + "modelo_veiculo", + "categoria", + "status", + "status_veiculo", + "data_inicio", + "data_fim_prevista", + "data_devolucao", + ): + value = str(payload.get(field_name) or "").strip() + if value: + snapshot[field_name] = value + + for field_name in ("valor_diaria", "valor_previsto", "valor_final"): + number = technical_normalizer.normalize_positive_number(payload.get(field_name)) + if number is not None: + snapshot[field_name] = float(number) + + return snapshot + + def _get_last_rental_contract(self, user_id: int | None) -> dict | None: + context = self._get_user_context(user_id) + if not isinstance(context, dict): + return None + contract = context.get("last_rental_contract") + return dict(contract) if isinstance(contract, dict) else None + + def _store_last_rental_contract(self, user_id: int | None, payload) -> None: + if user_id is None: + return + context = self._get_user_context(user_id) + if not isinstance(context, dict): + return + sanitized = self._sanitize_rental_contract_snapshot(payload) + if sanitized is None: + context.pop("last_rental_contract", None) + else: + context["last_rental_contract"] = sanitized + self._save_user_context(user_id=user_id, context=context) + + def _remember_rental_results(self, user_id: int | None, rental_results: list[dict] | None) -> None: context = self._get_user_context(user_id) if not isinstance(context, dict): @@ -503,5 +559,6 @@ class RentalFlowMixin: except HTTPException as exc: return self._http_exception_detail(exc) + self._store_last_rental_contract(user_id=user_id, payload=tool_result) self._reset_pending_rental_states(user_id=user_id) return self._fallback_format_tool_result("abrir_locacao_aluguel", tool_result) diff --git a/app/services/orchestration/orquestrador_service.py b/app/services/orchestration/orquestrador_service.py index 2a8fc8c..91135e4 100644 --- a/app/services/orchestration/orquestrador_service.py +++ b/app/services/orchestration/orquestrador_service.py @@ -181,6 +181,16 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin): if queued_followup: return queued_followup + deterministic_rental_management = await self._try_handle_deterministic_rental_management( + message=message, + user_id=user_id, + queue_notice=None, + finish=finish, + ) + if deterministic_rental_management: + return deterministic_rental_management + + message_plan = await self._extract_message_plan_with_llm( message=message, user_id=user_id, @@ -741,6 +751,285 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin): return await finish(response) return None + def _clean_extracted_rental_value(self, value: str | None) -> str | None: + text = str(value or "").strip(" \t\r\n.;,") + if not text: + return None + if re.fullmatch(r"<[^>]*>", text): + text = text[1:-1].strip(" \t\r\n.;,") + if not text: + return None + normalized = self._normalize_text(text).strip() + if normalized in { + "n/a", + "na", + "nao informado", + "nao informada", + "nao identificado", + "nao identificada", + "desconhecido", + "desconhecida", + "sem informacao", + "null", + "none", + "...", + }: + return None + return text + + def _extract_rental_labeled_value(self, text: str, labels: tuple[str, ...]) -> str | None: + if not labels: + return None + label_pattern = "|".join(re.escape(label) for label in labels) + match = re.search( + rf"(?:^|[\s;\n])(?:{label_pattern})\s*[:=]?\s*(?P[^;\n]+)", + str(text or ""), + flags=re.IGNORECASE, + ) + if not match: + return None + return self._clean_extracted_rental_value(match.group("value")) + + def _extract_rental_contract_number_from_text(self, text: str) -> str | None: + match = re.search(r"\bLOC-[A-Z0-9-]+\b", str(text or ""), flags=re.IGNORECASE) + if match: + return str(match.group(0)).strip().upper() + labeled_value = self._extract_rental_labeled_value(text, ("contrato_numero", "contrato")) + if not labeled_value: + return None + labeled_match = re.search(r"\bLOC-[A-Z0-9-]+\b", labeled_value, flags=re.IGNORECASE) + if labeled_match: + return str(labeled_match.group(0)).strip().upper() + return None + + def _extract_rental_plate_from_text(self, text: str) -> str | None: + labeled_value = self._extract_rental_labeled_value(text, ("placa",)) + if labeled_value: + labeled_plate = self._normalize_plate(labeled_value) + if labeled_plate: + return labeled_plate + + extracted: dict = {} + self._try_capture_rental_fields_from_message(message=text, payload=extracted) + return self._normalize_plate(extracted.get("placa")) + + def _merge_last_rental_reference(self, user_id: int | None, arguments: dict) -> dict: + if not isinstance(arguments, dict): + return {} + last_contract = self._get_last_rental_contract(user_id) + if not isinstance(last_contract, dict): + return arguments + if not arguments.get("contrato_numero") and last_contract.get("contrato_numero"): + arguments["contrato_numero"] = str(last_contract["contrato_numero"]) + if not arguments.get("placa") and last_contract.get("placa"): + arguments["placa"] = str(last_contract["placa"]) + return arguments + + def _has_rental_return_management_request(self, message: str, user_id: int | None = None) -> bool: + if not self._has_rental_return_request(message): + return False + normalized_message = self._normalize_text(message).strip() + return bool( + "aluguel" in normalized_message + or "locacao" in normalized_message + or self._get_last_rental_contract(user_id) + or self._extract_rental_contract_number_from_text(message) + or self._extract_rental_plate_from_text(message) + ) + + def _has_rental_payment_request(self, message: str, user_id: int | None = None) -> bool: + normalized_message = self._normalize_text(message).strip() + if "multa" in normalized_message: + return False + payment_terms = ("pagamento", "comprovante", "pix", "boleto") + if not any(term in normalized_message for term in payment_terms): + return False + return bool( + "aluguel" in normalized_message + or "locacao" in normalized_message + or self._get_last_rental_contract(user_id) + or self._extract_rental_contract_number_from_text(message) + ) + + def _has_rental_fine_request(self, message: str, user_id: int | None = None) -> bool: + normalized_message = self._normalize_text(message).strip() + if "multa" not in normalized_message: + return False + return bool( + "aluguel" in normalized_message + or "locacao" in normalized_message + or "auto_infracao" in normalized_message + or self._get_last_rental_contract(user_id) + or self._extract_rental_contract_number_from_text(message) + or self._extract_rental_plate_from_text(message) + ) + + def _is_deterministic_rental_management_candidate(self, message: str, user_id: int | None) -> bool: + has_policy = hasattr(self, "policy") and getattr(self, "policy") is not None + if has_policy and user_id is not None and ( + self._has_open_flow(user_id, "sales") or self._has_open_flow(user_id, "review") + ): + return False + return bool( + self._has_rental_return_management_request(message, user_id=user_id) + or self._has_rental_payment_request(message, user_id=user_id) + or self._has_rental_fine_request(message, user_id=user_id) + ) + + def _build_rental_return_arguments_from_message(self, message: str, user_id: int | None) -> dict: + arguments: dict = {} + contract_number = self._extract_rental_contract_number_from_text(message) + if contract_number: + arguments["contrato_numero"] = contract_number + plate = self._extract_rental_plate_from_text(message) + if plate: + arguments["placa"] = plate + date_text = self._extract_rental_labeled_value(message, ("data_devolucao", "data de devolucao")) + if not date_text: + datetimes = self._extract_rental_datetimes_from_text(message) + if datetimes: + date_text = datetimes[-1] + if date_text: + arguments["data_devolucao"] = date_text + return self._merge_last_rental_reference(user_id=user_id, arguments=arguments) + + def _build_rental_payment_arguments_from_message(self, message: str, user_id: int | None) -> dict: + arguments: dict = {} + contract_number = self._extract_rental_contract_number_from_text(message) + if contract_number: + arguments["contrato_numero"] = contract_number + plate = self._extract_rental_plate_from_text(message) + if plate: + arguments["placa"] = plate + + amount_text = self._extract_rental_labeled_value(message, ("valor_pago", "valor")) + amount = self._normalize_positive_number(amount_text) + if amount is not None: + arguments["valor"] = float(amount) + + payment_date = self._extract_rental_labeled_value(message, ("data_pagamento", "data do pagamento")) + if not payment_date: + datetimes = self._extract_rental_datetimes_from_text(message) + if datetimes: + payment_date = datetimes[0] + if payment_date: + arguments["data_pagamento"] = payment_date + + favorecido = self._extract_rental_labeled_value(message, ("favorecido",)) + if favorecido: + arguments["favorecido"] = favorecido + + receipt_id = self._extract_rental_labeled_value( + message, + ("identificador_comprovante", "identificador", "nsu"), + ) + if receipt_id: + arguments["identificador_comprovante"] = receipt_id + + observations = self._extract_rental_labeled_value(message, ("observacoes", "observacao")) + if observations: + arguments["observacoes"] = observations + + return self._merge_last_rental_reference(user_id=user_id, arguments=arguments) + + def _build_rental_fine_arguments_from_message(self, message: str, user_id: int | None) -> dict: + arguments: dict = {} + contract_number = self._extract_rental_contract_number_from_text(message) + if contract_number: + arguments["contrato_numero"] = contract_number + plate = self._extract_rental_plate_from_text(message) + if plate: + arguments["placa"] = plate + + notice_number = self._extract_rental_labeled_value( + message, + ("auto_infracao", "auto de infracao", "auto da infracao"), + ) + if notice_number: + arguments["auto_infracao"] = notice_number + + issuing_agency = self._extract_rental_labeled_value( + message, + ("orgao_emissor", "orgao emissor"), + ) + if issuing_agency: + arguments["orgao_emissor"] = issuing_agency + + amount_text = self._extract_rental_labeled_value(message, ("valor",)) + amount = self._normalize_positive_number(amount_text) + if amount is not None: + arguments["valor"] = float(amount) + + violation_date = self._extract_rental_labeled_value(message, ("data_infracao", "data da infracao")) + due_date = self._extract_rental_labeled_value(message, ("vencimento", "data_vencimento", "data de vencimento")) + datetimes = self._extract_rental_datetimes_from_text(message) + if not violation_date and datetimes: + violation_date = datetimes[0] + if not due_date and len(datetimes) >= 2: + due_date = datetimes[1] + if violation_date: + arguments["data_infracao"] = violation_date + if due_date: + arguments["vencimento"] = due_date + + observations = self._extract_rental_labeled_value(message, ("observacoes", "observacao")) + if observations: + arguments["observacoes"] = observations + + return self._merge_last_rental_reference(user_id=user_id, arguments=arguments) + + async def _try_handle_deterministic_rental_management( + self, + message: str, + user_id: int | None, + queue_notice: str | None, + finish, + ) -> str | None: + if user_id is None or not self._is_deterministic_rental_management_candidate(message, user_id=user_id): + return None + + if self._has_rental_return_management_request(message, user_id=user_id): + tool_name = "registrar_devolucao_aluguel" + arguments = self._build_rental_return_arguments_from_message(message=message, user_id=user_id) + missing_response = None + elif self._has_rental_fine_request(message, user_id=user_id): + tool_name = "registrar_multa_aluguel" + arguments = self._build_rental_fine_arguments_from_message(message=message, user_id=user_id) + missing_response = None + if "valor" not in arguments: + missing_response = "Para registrar a multa de aluguel, preciso do valor informado no documento." + elif self._has_rental_payment_request(message, user_id=user_id): + tool_name = "registrar_pagamento_aluguel" + arguments = self._build_rental_payment_arguments_from_message(message=message, user_id=user_id) + missing_response = None + if "valor" not in arguments: + missing_response = "Para registrar o pagamento do aluguel, preciso do valor informado no comprovante." + else: + return None + + if missing_response: + return await finish(missing_response, queue_notice=queue_notice) + + try: + tool_result = await self._execute_tool_with_trace( + tool_name, + arguments, + user_id=user_id, + ) + except HTTPException as exc: + return await finish(self._http_exception_detail(exc), queue_notice=queue_notice) + + self._capture_successful_tool_side_effects( + tool_name=tool_name, + arguments=arguments, + tool_result=tool_result, + user_id=user_id, + ) + return await finish( + self._fallback_format_tool_result(tool_name, tool_result), + queue_notice=queue_notice, + ) + async def _try_handle_active_sales_follow_up( self, message: str, @@ -1271,6 +1560,13 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin): ) -> None: if tool_name == "agendar_revisao" and isinstance(arguments, dict): self._store_last_review_package(user_id=user_id, payload=arguments) + if tool_name in { + "abrir_locacao_aluguel", + "registrar_devolucao_aluguel", + "registrar_pagamento_aluguel", + "registrar_multa_aluguel", + } and isinstance(tool_result, dict): + self._store_last_rental_contract(user_id=user_id, payload=tool_result) self._capture_tool_result_context( tool_name=tool_name, tool_result=tool_result, diff --git a/app/services/orchestration/prompt_builders.py b/app/services/orchestration/prompt_builders.py index 26f7635..2098a94 100644 --- a/app/services/orchestration/prompt_builders.py +++ b/app/services/orchestration/prompt_builders.py @@ -15,7 +15,8 @@ def build_router_prompt( return ( "Voce e um assistente de concessionaria. " "Sempre que a solicitacao depender de dados operacionais (estoque, validacao de cliente, " - "avaliacao de troca, agendamento de revisao, realizacao ou cancelamento de pedido), use a tool correta. " + "avaliacao de troca, agendamento de revisao, realizacao ou cancelamento de pedido, consulta de frota de aluguel, " + "abertura de locacao, devolucao de aluguel, registro de pagamento de aluguel ou registro de multa de aluguel), use a tool correta. " "Se o usuario pedir para recomecar, esquecer contexto, cancelar fluxo atual, descartar fila pendente " "ou continuar o proximo pedido, use a tool de orquestracao apropriada. " "Mensagens de controle da conversa tem prioridade sobre qualquer fluxo em aberto. " @@ -36,6 +37,7 @@ def build_force_tool_prompt( user_context = _build_user_context_line(user_id) return ( "Reavalie a mensagem e priorize chamar tool se houver intencao operacional. " + "Considere tambem as operacoes de aluguel (consultar frota, abrir locacao, registrar devolucao, pagamento ou multa). " "Considere tambem tools de orquestracao para limpar contexto, cancelar fluxo, descartar fila ou continuar o proximo pedido. " "Mesmo com fluxo incremental ativo, se a mensagem for de controle global da conversa, a tool de orquestracao deve vencer o rascunho atual. " "Use texto apenas quando faltar dado obrigatorio.\n\n" diff --git a/app/services/orchestration/response_formatter.py b/app/services/orchestration/response_formatter.py index b5a119d..201c05d 100644 --- a/app/services/orchestration/response_formatter.py +++ b/app/services/orchestration/response_formatter.py @@ -197,7 +197,9 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str: f"Inicio: {data_inicio}\n" f"Devolucao prevista: {data_fim}\n" f"Diaria: {valor_diaria}\n" - f"Valor previsto: {valor_previsto}" + f"Valor previsto: {valor_previsto}\n" + "Pagamento: em aberto\n" + "Quando quiser testar o comprovante, envie a imagem com os dados do pagamento." ) if tool_name == "registrar_devolucao_aluguel" and isinstance(tool_result, dict): diff --git a/tests/test_conversation_adjustments.py b/tests/test_conversation_adjustments.py index 74f239d..95c6ce3 100644 --- a/tests/test_conversation_adjustments.py +++ b/tests/test_conversation_adjustments.py @@ -440,6 +440,24 @@ class ConversationAdjustmentsTests(unittest.TestCase): self.assertIn("2. REV-2 | XYZ9999 |", response) self.assertNotIn("\n\n", response) + + def test_rental_open_formatter_marks_payment_as_pending(self): + response = fallback_format_tool_result( + "abrir_locacao_aluguel", + { + "contrato_numero": "LOC-20260318-FE69BCF0", + "placa": "RAA1A12", + "modelo_veiculo": "Peugeot 208", + "data_inicio": "2026-03-20T10:00:00", + "data_fim_prevista": "2026-03-23T10:00:00", + "valor_diaria": 149.9, + "valor_previsto": 449.7, + }, + ) + + self.assertIn("Pagamento: em aberto", response) + self.assertIn("testar o comprovante", response) + def test_defer_flow_cancel_when_order_cancel_draft_waits_for_reason(self): state = FakeState( entries={ diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index 5e3d765..a4d7a1f 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -57,3 +57,17 @@ class LLMServiceResponseParsingTests(unittest.TestCase): payload = service._extract_response_payload(response) self.assertEqual(payload, {"response": "Resposta simples", "tool_call": None}) + + +class LLMServiceImageWorkflowPromptTests(unittest.TestCase): + def test_build_image_workflow_prompt_preserves_visible_payment_time(self): + service = LLMService.__new__(LLMService) + + prompt = service._build_image_workflow_prompt(caption="Segue o comprovante") + + self.assertIn( + "preserve a data e a hora no campo data_pagamento no formato DD/MM/AAAA HH:MM", + prompt, + ) + self.assertIn("Nao reduza para somente a data quando a hora estiver visivel.", prompt) + self.assertIn("Legenda do usuario: Segue o comprovante", prompt) diff --git a/tests/test_turn_decision_contract.py b/tests/test_turn_decision_contract.py index b5ecea0..6a034ab 100644 --- a/tests/test_turn_decision_contract.py +++ b/tests/test_turn_decision_contract.py @@ -2289,6 +2289,318 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertIn("inicio da locacao", response) + async def test_handle_message_short_circuits_for_rental_return_using_last_contract(self): + state = FakeState( + contexts={ + 1: { + "active_domain": "general", + "generic_memory": {}, + "shared_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + "last_rental_results": [], + "selected_rental_vehicle": None, + "last_rental_contract": { + "contrato_numero": "LOC-20260318-FE69BCF0", + "placa": "RAA1A12", + }, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service.policy = ConversationPolicy(service=service) + service.tool_executor = FakeToolExecutor( + result={ + "contrato_numero": "LOC-20260318-FE69BCF0", + "placa": "RAA1A12", + "modelo_veiculo": "Peugeot 208", + "data_devolucao": "2026-03-18T15:46:00", + "valor_final": 449.7, + } + ) + service._empty_extraction_payload = service.normalizer.empty_extraction_payload + service._log_turn_event = lambda *args, **kwargs: None + service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response + service._fallback_format_tool_result = lambda tool_name, tool_result: "devolucao ok" + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + + async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None): + return base_response + + service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order + service._upsert_user_context = lambda user_id: None + + async def fake_extract_turn_decision(message: str, user_id: int | None): + return { + "intent": "general", + "domain": "general", + "action": "answer_user", + "entities": service.normalizer.empty_extraction_payload(), + "missing_fields": [], + "selection_index": None, + "tool_name": None, + "tool_arguments": {}, + "response_to_user": None, + } + + service._extract_turn_decision_with_llm = fake_extract_turn_decision + + async def fake_try_handle_immediate_context_reset(**kwargs): + return None + + service._try_handle_immediate_context_reset = fake_try_handle_immediate_context_reset + + async def fake_try_resolve_pending_order_selection(**kwargs): + return None + + service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection + + async def fake_try_continue_queued_order(**kwargs): + return None + + service._try_continue_queued_order = fake_try_continue_queued_order + + async def fake_extract_message_plan(message: str, user_id: int | None): + raise AssertionError("nao deveria consultar o planner para devolucao deterministica de aluguel") + + service._extract_message_plan_with_llm = fake_extract_message_plan + + response = await service.handle_message( + "devolver a placa RAA1A12", + user_id=1, + ) + + self.assertEqual(response, "devolucao ok") + self.assertEqual( + service.tool_executor.calls, + [ + ( + "registrar_devolucao_aluguel", + {"placa": "RAA1A12", "contrato_numero": "LOC-20260318-FE69BCF0"}, + 1, + ) + ], + ) + + async def test_handle_message_short_circuits_for_rental_payment_receipt_text(self): + state = FakeState( + contexts={ + 1: { + "active_domain": "general", + "generic_memory": {}, + "shared_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + "last_rental_results": [], + "selected_rental_vehicle": None, + "last_rental_contract": { + "contrato_numero": "LOC-20260318-FE69BCF0", + "placa": "RAA1A12", + }, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service.policy = ConversationPolicy(service=service) + service.tool_executor = FakeToolExecutor( + result={ + "protocolo": "ALP-20260318-ABCD1234", + "contrato_numero": "LOC-20260318-FE69BCF0", + "placa": "RAA1A12", + "valor": 449.7, + "data_pagamento": "2026-03-18T15:47:00", + } + ) + service._empty_extraction_payload = service.normalizer.empty_extraction_payload + service._log_turn_event = lambda *args, **kwargs: None + service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response + service._fallback_format_tool_result = lambda tool_name, tool_result: "pagamento ok" + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + + async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None): + return base_response + + service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order + service._upsert_user_context = lambda user_id: None + + async def fake_extract_turn_decision(message: str, user_id: int | None): + return { + "intent": "general", + "domain": "general", + "action": "answer_user", + "entities": service.normalizer.empty_extraction_payload(), + "missing_fields": [], + "selection_index": None, + "tool_name": None, + "tool_arguments": {}, + "response_to_user": None, + } + + service._extract_turn_decision_with_llm = fake_extract_turn_decision + + async def fake_try_handle_immediate_context_reset(**kwargs): + return None + + service._try_handle_immediate_context_reset = fake_try_handle_immediate_context_reset + + async def fake_try_resolve_pending_order_selection(**kwargs): + return None + + service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection + + async def fake_try_continue_queued_order(**kwargs): + return None + + service._try_continue_queued_order = fake_try_continue_queued_order + + async def fake_extract_message_plan(message: str, user_id: int | None): + raise AssertionError("nao deveria consultar o planner para pagamento deterministico de aluguel") + + service._extract_message_plan_with_llm = fake_extract_message_plan + + response = await service.handle_message( + "Registrar pagamento de aluguel: valor 449,70; data_pagamento 18/03/2026 15:47; favorecido Locadora XPTO; identificador_comprovante NSU123.", + user_id=1, + ) + + self.assertEqual(response, "pagamento ok") + self.assertEqual( + service.tool_executor.calls, + [ + ( + "registrar_pagamento_aluguel", + { + "valor": 449.7, + "data_pagamento": "18/03/2026 15:47", + "favorecido": "Locadora XPTO", + "identificador_comprovante": "NSU123", + "contrato_numero": "LOC-20260318-FE69BCF0", + "placa": "RAA1A12", + }, + 1, + ) + ], + ) + + async def test_handle_message_short_circuits_for_rental_payment_receipt_text_with_angle_brackets(self): + state = FakeState( + contexts={ + 1: { + "active_domain": "general", + "generic_memory": {}, + "shared_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + "last_rental_results": [], + "selected_rental_vehicle": None, + "last_rental_contract": { + "contrato_numero": "LOC-20260318-4B85490F", + "placa": "RAA1A22", + }, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service.policy = ConversationPolicy(service=service) + service.tool_executor = FakeToolExecutor( + result={ + "protocolo": "ALP-20260318-ABCD1234", + "contrato_numero": "LOC-20260318-4B85490F", + "placa": "RAA1A22", + "valor": 479.7, + "data_pagamento": "2026-03-18T16:10:00", + } + ) + service._empty_extraction_payload = service.normalizer.empty_extraction_payload + service._log_turn_event = lambda *args, **kwargs: None + service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response + service._fallback_format_tool_result = lambda tool_name, tool_result: "pagamento ok" + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + + async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None): + return base_response + + service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order + service._upsert_user_context = lambda user_id: None + + async def fake_extract_turn_decision(message: str, user_id: int | None): + return { + "intent": "general", + "domain": "general", + "action": "answer_user", + "entities": service.normalizer.empty_extraction_payload(), + "missing_fields": [], + "selection_index": None, + "tool_name": None, + "tool_arguments": {}, + "response_to_user": None, + } + + service._extract_turn_decision_with_llm = fake_extract_turn_decision + + async def fake_try_handle_immediate_context_reset(**kwargs): + return None + + service._try_handle_immediate_context_reset = fake_try_handle_immediate_context_reset + + async def fake_try_resolve_pending_order_selection(**kwargs): + return None + + service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection + + async def fake_try_continue_queued_order(**kwargs): + return None + + service._try_continue_queued_order = fake_try_continue_queued_order + + async def fake_extract_message_plan(message: str, user_id: int | None): + raise AssertionError("nao deveria consultar o planner para pagamento deterministico de aluguel") + + service._extract_message_plan_with_llm = fake_extract_message_plan + + response = await service.handle_message( + "[imagem recebida no telegram]\nDados extraidos da imagem: Registrar pagamento de aluguel: contrato ; placa ; valor ; data_pagamento <18/03/2026 16:10>; favorecido ; identificador_comprovante ; observacoes .", + user_id=1, + ) + + self.assertEqual(response, "pagamento ok") + self.assertEqual( + service.tool_executor.calls, + [ + ( + "registrar_pagamento_aluguel", + { + "contrato_numero": "LOC-20260318-4B85490F", + "valor": 479.7, + "data_pagamento": "18/03/2026 16:10", + "favorecido": "Locadora XPTO", + "identificador_comprovante": "NSU123456", + "observacoes": "pagamento da locacao", + "placa": "RAA1A22", + }, + 1, + ) + ], + ) + async def test_handle_message_keeps_sales_flow_when_cpf_follow_up_is_misclassified_as_review(self): state = FakeState( entries={