From d4271aec917e258e9e24d3ffd8f0a08ec015f64e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vitor=20Hugo=20Belorio=20Sim=C3=A3o?= Date: Thu, 12 Mar 2026 16:59:16 -0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(review):=20corrigir=20reapro?= =?UTF-8?q?veitamento=20de=20revisao=20com=20data=20parcial=20e=20resposta?= =?UTF-8?q?=20negativa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - preserva data sem horario no reuso do ultimo veiculo e pede apenas o horario faltante - impede que a resposta 'nao' na confirmacao de reuso cancele o fluxo global de revisao - abre um novo draft de agendamento quando o usuario recusa reutilizar os dados do ultimo veiculo - adiciona testes para data parcial no reuso e para resposta negativa sem contaminar o fluxo --- app/services/flows/review_flow.py | 54 ++++++++++ .../orchestration/conversation_policy.py | 6 ++ tests/test_conversation_adjustments.py | 102 ++++++++++++++++++ 3 files changed, 162 insertions(+) diff --git a/app/services/flows/review_flow.py b/app/services/flows/review_flow.py index 234be9c..c2636e0 100644 --- a/app/services/flows/review_flow.py +++ b/app/services/flows/review_flow.py @@ -88,6 +88,31 @@ class ReviewFlowMixin: if raw_model: payload["modelo"] = raw_model.title() + def _extract_review_date_only_text(self, message: str) -> str | None: + text = self.normalizer.normalize_datetime_connector(message) + patterns = ( + r"(?\d{1,2})[/-](?P\d{1,2})[/-](?P\d{4})(?!\s+\d{1,2}:\d{2})(?!\d)", + r"(?\d{4})[/-](?P\d{1,2})[/-](?P\d{1,2})(?!\s+\d{1,2}:\d{2})(?!\d)", + ) + for pattern in patterns: + match = re.search(pattern, str(text or "")) + if not match: + continue + parts = match.groupdict() + return f"{int(parts['day']):02d}/{int(parts['month']):02d}/{int(parts['year']):04d}" + return None + + def _merge_review_base_date_with_time(self, message: str, payload: dict) -> None: + if not isinstance(payload, dict): + return + if payload.get("data_hora") or not payload.get("data_hora_base"): + return + time_text = self.normalizer.extract_hhmm_from_text(message) + if not time_text: + return + payload["data_hora"] = f"{payload['data_hora_base']} {time_text}" + payload.pop("data_hora_base", None) + def _infer_review_management_action( self, message: str, @@ -384,11 +409,29 @@ class ReviewFlowMixin: if pending_reuse: should_reuse = False + date_only = self._extract_review_date_only_text(message) + has_explicit_time = bool(self.normalizer.extract_hhmm_from_text(message)) + if date_only and not has_explicit_time: + extracted.pop("data_hora", None) if self._is_negative_message(message): self.state.pop_entry("pending_review_reuse_confirmations", user_id) pending_reuse = None + if not extracted: + draft = { + "payload": {}, + "expires_at": datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES), + } + self.state.set_entry("pending_review_drafts", user_id, draft) + self._log_review_flow_source( + source="last_review_package", + payload=draft["payload"], + missing_fields=list(REVIEW_REQUIRED_FIELDS), + ) + return self._render_missing_review_fields_prompt(list(REVIEW_REQUIRED_FIELDS)) elif self._is_affirmative_message(message) or "data_hora" in extracted: should_reuse = True + elif date_only: + should_reuse = True else: self._log_review_flow_source(source="last_review_package", payload=pending_reuse.get("payload")) return self._render_review_reuse_question(pending_reuse.get("payload")) @@ -405,6 +448,15 @@ class ReviewFlowMixin: draft["payload"].setdefault(key, value) self.state.pop_entry("pending_review_reuse_confirmations", user_id) review_flow_source = "last_review_package" + if date_only and not extracted.get("data_hora"): + draft["payload"]["data_hora_base"] = date_only + self.state.set_entry("pending_review_drafts", user_id, draft) + self._log_review_flow_source( + source=review_flow_source, + payload=draft["payload"], + missing_fields=["data_hora"], + ) + return f"Perfeito. Tenho a data {date_only}. Agora me informe o horario desejado para a revisao." if "data_hora" not in extracted: self.state.set_entry("pending_review_drafts", user_id, draft) self._log_review_flow_source(source=review_flow_source, payload=draft["payload"], missing_fields=["data_hora"]) @@ -458,7 +510,9 @@ class ReviewFlowMixin: } draft["payload"].update(extracted) + self._merge_review_base_date_with_time(message=message, payload=draft["payload"]) self._supplement_review_fields_from_message(message=message, payload=draft["payload"]) + self._merge_review_base_date_with_time(message=message, payload=draft["payload"]) self._try_prefill_review_fields_from_memory(user_id=user_id, payload=draft["payload"]) if ( "revisao_previa_concessionaria" not in draft["payload"] diff --git a/app/services/orchestration/conversation_policy.py b/app/services/orchestration/conversation_policy.py index 37d2a78..22d5205 100644 --- a/app/services/orchestration/conversation_policy.py +++ b/app/services/orchestration/conversation_policy.py @@ -401,6 +401,12 @@ class ConversationPolicy: if len(free_text) >= 4 and not self.service._is_affirmative_message(free_text): return True + pending_review_reuse = self.service.state.get_entry("pending_review_reuse_confirmations", user_id, expire=True) + if pending_review_reuse: + free_text = str(message or "").strip() + if free_text: + return True + return False diff --git a/tests/test_conversation_adjustments.py b/tests/test_conversation_adjustments.py index 31b9b47..c3d3d6a 100644 --- a/tests/test_conversation_adjustments.py +++ b/tests/test_conversation_adjustments.py @@ -320,6 +320,28 @@ class ConversationAdjustmentsTests(unittest.TestCase): self.assertTrue(policy.should_defer_flow_cancellation_control("desisti", user_id=7)) self.assertFalse(policy.should_defer_flow_cancellation_control("cancelar fluxo atual", user_id=7)) + def test_defer_flow_cancel_when_review_reuse_confirmation_is_pending(self): + state = FakeState( + entries={ + "pending_review_reuse_confirmations": { + 7: { + "payload": { + "placa": "ABC1234", + "modelo": "Corolla", + "ano": 2020, + "km": 30000, + "revisao_previa_concessionaria": True, + }, + "expires_at": datetime.utcnow() + timedelta(minutes=30), + } + } + } + ) + policy = ConversationPolicy(service=FakeService(state)) + + self.assertTrue(policy.should_defer_flow_cancellation_control("nao", user_id=7)) + self.assertFalse(policy.should_defer_flow_cancellation_control("cancelar fluxo atual", user_id=7)) + def test_normalize_datetime_connector_accepts_as_com_acento(self): normalizer = EntityNormalizer() @@ -1209,6 +1231,86 @@ 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_rejects_reuse_without_new_vehicle_and_opens_fresh_draft(self): + state = FakeState( + entries={ + "pending_review_reuse_confirmations": { + 21: { + "payload": { + "placa": "ABC1234", + "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="nao", + user_id=21, + extracted_fields={}, + intents={}, + turn_decision={"intent": "review_schedule", "domain": "review", "action": "collect_review_schedule"}, + ) + + draft = state.get_entry("pending_review_drafts", 21) + self.assertIsNone(state.get_entry("pending_review_reuse_confirmations", 21)) + self.assertIsNotNone(draft) + self.assertEqual(draft["payload"], {}) + self.assertIn("a placa do veiculo", response) + + async def test_review_flow_reuses_vehicle_with_date_only_and_requests_missing_time(self): + state = FakeState( + entries={ + "pending_review_reuse_confirmations": { + 21: { + "payload": { + "placa": "ABC1234", + "modelo": "Onix", + "ano": 2024, + "km": 50000, + "revisao_previa_concessionaria": False, + }, + "expires_at": datetime.utcnow() + timedelta(minutes=30), + } + } + } + ) + registry = FakeRegistry() + flow = ReviewFlowHarness(state=state, registry=registry) + + response = await flow._try_collect_and_schedule_review( + message="Sim, quero marcar uma para o dia 18/08/2026", + user_id=21, + extracted_fields={"data_hora": "18/08/2026 00:00"}, + intents={}, + turn_decision={"intent": "review_schedule", "domain": "review", "action": "collect_review_schedule"}, + ) + + draft = state.get_entry("pending_review_drafts", 21) + self.assertIsNotNone(draft) + self.assertNotIn("data_hora", draft["payload"]) + self.assertEqual(draft["payload"].get("data_hora_base"), "18/08/2026") + self.assertIn("Agora me informe o horario desejado", response) + + final_response = await flow._try_collect_and_schedule_review( + message="as 10 horas", + user_id=21, + extracted_fields={}, + intents={}, + turn_decision={"intent": "review_schedule", "domain": "review", "action": "collect_review_schedule"}, + ) + + self.assertEqual(registry.calls[0][0], "agendar_revisao") + self.assertEqual(registry.calls[0][1]["data_hora"], "18/08/2026 10:00") + self.assertIn("REV-TESTE-123", final_response) + async def test_review_flow_clears_stale_pending_confirmation_when_user_starts_new_schedule(self): state = FakeState( entries={