🐛 fix(review): corrigir reaproveitamento de revisao com data parcial e resposta negativa

- 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
main
parent 135718bc43
commit d4271aec91

@ -88,6 +88,31 @@ class ReviewFlowMixin:
if raw_model: if raw_model:
payload["modelo"] = raw_model.title() 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)(?P<day>\d{1,2})[/-](?P<month>\d{1,2})[/-](?P<year>\d{4})(?!\s+\d{1,2}:\d{2})(?!\d)",
r"(?<!\d)(?P<year>\d{4})[/-](?P<month>\d{1,2})[/-](?P<day>\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( def _infer_review_management_action(
self, self,
message: str, message: str,
@ -384,11 +409,29 @@ class ReviewFlowMixin:
if pending_reuse: if pending_reuse:
should_reuse = False 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): if self._is_negative_message(message):
self.state.pop_entry("pending_review_reuse_confirmations", user_id) self.state.pop_entry("pending_review_reuse_confirmations", user_id)
pending_reuse = None 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: elif self._is_affirmative_message(message) or "data_hora" in extracted:
should_reuse = True should_reuse = True
elif date_only:
should_reuse = True
else: else:
self._log_review_flow_source(source="last_review_package", payload=pending_reuse.get("payload")) self._log_review_flow_source(source="last_review_package", payload=pending_reuse.get("payload"))
return self._render_review_reuse_question(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) draft["payload"].setdefault(key, value)
self.state.pop_entry("pending_review_reuse_confirmations", user_id) self.state.pop_entry("pending_review_reuse_confirmations", user_id)
review_flow_source = "last_review_package" 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: if "data_hora" not in extracted:
self.state.set_entry("pending_review_drafts", user_id, draft) 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"]) 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) 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._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"]) self._try_prefill_review_fields_from_memory(user_id=user_id, payload=draft["payload"])
if ( if (
"revisao_previa_concessionaria" not in draft["payload"] "revisao_previa_concessionaria" not in draft["payload"]

@ -401,6 +401,12 @@ class ConversationPolicy:
if len(free_text) >= 4 and not self.service._is_affirmative_message(free_text): if len(free_text) >= 4 and not self.service._is_affirmative_message(free_text):
return True 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 return False

@ -320,6 +320,28 @@ class ConversationAdjustmentsTests(unittest.TestCase):
self.assertTrue(policy.should_defer_flow_cancellation_control("desisti", user_id=7)) 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)) 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): def test_normalize_datetime_connector_accepts_as_com_acento(self):
normalizer = EntityNormalizer() normalizer = EntityNormalizer()
@ -1209,6 +1231,86 @@ 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_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): async def test_review_flow_clears_stale_pending_confirmation_when_user_starts_new_schedule(self):
state = FakeState( state = FakeState(
entries={ entries={

Loading…
Cancel
Save