You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
orquestrador/app/services/flows/review_flow.py

911 lines
39 KiB
Python

import re
from datetime import datetime, timedelta
from app.core.time_utils import utc_now
from fastapi import HTTPException
from app.services.orchestration.orchestrator_config import (
PENDING_REVIEW_DRAFT_TTL_MINUTES,
REVIEW_REQUIRED_FIELDS,
)
from app.services.flows.review_flow_support import ReviewFlowStateSupport
# Esse mixin concentra os fluxos incrementais de revisao e pos-venda.
class ReviewFlowMixin:
@property
def _review_flow_state_support(self) -> ReviewFlowStateSupport:
support = getattr(self, "__review_flow_state_support", None)
if support is None:
support = ReviewFlowStateSupport(self)
setattr(self, "__review_flow_state_support", support)
return support
def _review_now(self) -> datetime:
return self._review_flow_state_support.review_now()
def _get_review_flow_snapshot(self, user_id: int | None, snapshot_key: str) -> dict | None:
return self._review_flow_state_support.get_flow_snapshot(
user_id=user_id,
snapshot_key=snapshot_key,
)
def _set_review_flow_snapshot(
self,
user_id: int | None,
snapshot_key: str,
value: dict | None,
*,
active_task: str | None = None,
) -> None:
self._review_flow_state_support.set_flow_snapshot(
user_id=user_id,
snapshot_key=snapshot_key,
value=value,
active_task=active_task,
)
def _get_review_flow_entry(self, bucket: str, user_id: int | None, snapshot_key: str) -> dict | None:
return self._review_flow_state_support.get_flow_entry(
bucket=bucket,
user_id=user_id,
snapshot_key=snapshot_key,
)
def _set_review_flow_entry(
self,
bucket: str,
user_id: int | None,
snapshot_key: str,
value: dict,
*,
active_task: str | None = None,
) -> None:
self._review_flow_state_support.set_flow_entry(
bucket=bucket,
user_id=user_id,
snapshot_key=snapshot_key,
value=value,
active_task=active_task,
)
def _pop_review_flow_entry(
self,
bucket: str,
user_id: int | None,
snapshot_key: str,
*,
active_task: str | None = None,
) -> dict | None:
return self._review_flow_state_support.pop_flow_entry(
bucket=bucket,
user_id=user_id,
snapshot_key=snapshot_key,
active_task=active_task,
)
def _decision_intent(self, turn_decision: dict | None) -> str:
return str((turn_decision or {}).get("intent") or "").strip().lower()
def _log_review_flow_source(
self,
source: str,
payload: dict | None = None,
missing_fields: list[str] | None = None,
) -> None:
self._review_flow_state_support.log_review_flow_source(
source=source,
payload=payload,
missing_fields=missing_fields,
)
def _active_domain(self, user_id: int | None) -> str:
return self._review_flow_state_support.active_domain(user_id=user_id)
def _clean_review_model_candidate(self, raw_model: str | None) -> str | None:
text = str(raw_model or "").strip(" ,.;:-")
if not text:
return None
text = re.sub(r"\s+", " ", text)
text = re.sub(r"\be\b$", "", text).strip(" ,.;:-")
if not text:
return None
stop_terms = {
"amanha",
"hoje",
"revisao",
"agendar",
"marcar",
"cancelar",
"pedido",
"sim",
"nao",
"ok",
"pode",
}
lowered = text.lower()
if lowered in stop_terms:
return None
if any(term in lowered for term in {"agendar revisao", "marcar revisao", "cancelar revisao"}):
return None
if not re.search(r"[a-z]", lowered):
return None
if len(text.split()) > 4:
return None
return text.title()
def _extract_review_model_from_message(self, normalized_message: str) -> str | None:
explicit_match = re.search(
r"(?:modelo do meu carro (?:e|eh)?|meu carro (?:e|eh)?|carro (?:e|eh)?|veiculo (?:e|eh)?)\s+([a-z0-9][a-z0-9\s-]{1,30})",
normalized_message,
flags=re.IGNORECASE,
)
if explicit_match:
raw_model = explicit_match.group(1)
raw_model = re.split(r"\b(?:ele e|ele eh|ano|placa|km|quilometragem|data|amanha|hoje)\b", raw_model, maxsplit=1)[0]
return self._clean_review_model_candidate(raw_model)
has_year = bool(re.search(r"(?<!\d)(19\d{2}|20\d{2}|2100)(?!\d)", normalized_message))
has_km = bool(re.search(r"(?<!\d)(\d{1,3}(?:[.\s]\d{3})+|\d{2,6})\s*km\b", normalized_message, flags=re.IGNORECASE))
has_review_history = any(
term in normalized_message
for term in {
"nunca fiz revisao",
"nao fiz revisao",
"nunca revisei",
"ja fiz revisao",
"fiz revisao",
"ja revisei",
}
)
token_count = len([token for token in normalized_message.split() if token])
if not (has_year or has_km or has_review_history or token_count <= 3):
return None
raw_model = normalized_message
raw_model = re.split(r"(?<!\d)(?:19\d{2}|20\d{2}|2100)(?!\d)", raw_model, maxsplit=1)[0]
raw_model = re.split(r"(?<!\d)(?:\d{1,3}(?:[.\s]\d{3})+|\d{2,6})\s*km\b", raw_model, maxsplit=1, flags=re.IGNORECASE)[0]
raw_model = re.split(
r"\b(?:placa|quilometragem|data|amanha|hoje|nunca fiz revisao|nao fiz revisao|nunca revisei|ja fiz revisao|fiz revisao|ja revisei|na concessionaria|concessionaria)\b",
raw_model,
maxsplit=1,
)[0]
raw_model = re.sub(r"^(?:modelo(?: do meu carro)?|meu carro|carro|veiculo)\s+(?:e|eh)?\s+", "", raw_model).strip()
return self._clean_review_model_candidate(raw_model)
def _supplement_review_fields_from_message(self, message: str, payload: dict) -> None:
if not isinstance(payload, dict):
return
normalized_message = self._normalize_text(message).strip()
if "placa" not in payload:
for token in str(message or "").split():
normalized_plate = self.normalizer.normalize_plate(token)
if normalized_plate:
payload["placa"] = normalized_plate
break
if "data_hora" not in payload:
normalized_datetime = self.normalizer.normalize_review_datetime_text(
message,
now_provider=self._review_now,
)
if normalized_datetime and normalized_datetime != str(message or "").strip():
payload["data_hora"] = normalized_datetime
if "km" not in payload:
km_match = re.search(r"(?<!\d)(\d{1,3}(?:[.\s]\d{3})+|\d{2,6})\s*km\b", normalized_message, flags=re.IGNORECASE)
if km_match:
km_value = self.normalizer.normalize_positive_number(km_match.group(1))
if km_value:
payload["km"] = int(round(km_value))
if "revisao_previa_concessionaria" not in payload:
if any(term in normalized_message for term in {"nunca fiz revisao", "nao fiz revisao", "nunca revisei"}):
payload["revisao_previa_concessionaria"] = False
elif any(term in normalized_message for term in {"ja fiz revisao", "fiz revisao", "ja revisei"}):
payload["revisao_previa_concessionaria"] = True
if "ano" not in payload:
year_match = re.search(r"(?<!\d)(19\d{2}|20\d{2}|2100)(?!\d)", normalized_message)
if year_match:
payload["ano"] = int(year_match.group(1))
if "modelo" not in payload:
extracted_model = self._extract_review_model_from_message(normalized_message)
if extracted_model:
payload["modelo"] = extracted_model
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}"
normalized_text = self._normalize_text(message).strip()
if self.normalizer.extract_hhmm_from_text(message):
return None
if "hoje" in normalized_text:
return self._review_now().strftime("%d/%m/%Y")
if "amanha" in normalized_text:
return (self._review_now() + timedelta(days=1)).strftime("%d/%m/%Y")
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 _store_review_base_date_from_message(self, message: str, payload: dict) -> None:
if not isinstance(payload, dict):
return
if payload.get("data_hora") or payload.get("data_hora_base"):
return
date_only = self._extract_review_date_only_text(message)
if not date_only:
return
payload["data_hora_base"] = date_only
def _extract_review_management_datetime_from_message(self, message: str) -> str | None:
return self.normalizer.normalize_review_datetime_text(
message,
now_provider=self._review_now,
)
def _merge_review_management_base_date_with_time(self, message: str, payload: dict) -> None:
if not isinstance(payload, dict):
return
if payload.get("nova_data_hora") or not payload.get("nova_data_hora_base"):
return
time_text = self.normalizer.extract_hhmm_from_text(message)
if not time_text:
return
payload["nova_data_hora"] = f"{payload['nova_data_hora_base']} {time_text}"
payload.pop("nova_data_hora_base", None)
def _store_review_management_base_date_from_message(self, message: str, payload: dict) -> None:
if not isinstance(payload, dict):
return
if payload.get("nova_data_hora") or payload.get("nova_data_hora_base"):
return
date_only = self._extract_review_date_only_text(message)
if not date_only:
return
payload["nova_data_hora_base"] = date_only
def _is_review_temporal_follow_up(self, message: str, payload: dict | None) -> bool:
if not isinstance(payload, dict):
return False
if payload.get("data_hora"):
return False
has_time = bool(self.normalizer.extract_hhmm_from_text(message))
if has_time and payload.get("data_hora_base"):
return True
has_date_only = bool(self._extract_review_date_only_text(message))
if has_date_only and not payload.get("data_hora"):
return True
return False
def _infer_review_management_action(
self,
message: str,
extracted_fields: dict | None = None,
) -> str | None:
normalized_message = self._normalize_text(message).strip()
management_fields = self._normalize_review_management_fields(extracted_fields)
has_protocol = bool(management_fields.get("protocolo") or self._extract_review_protocol_from_text(message))
if any(term in normalized_message for term in {"agendamento", "agendamentos"}) and any(
term in normalized_message for term in {"listar", "liste", "mostrar", "mostre", "ver", "consultar"}
):
return "list"
if not has_protocol:
return None
if any(term in normalized_message for term in {"remarcar", "reagendar", "alterar data", "mudar data", "trocar data"}):
return "reschedule"
if any(term in normalized_message for term in {"cancelar", "cancelamento", "desmarcar"}):
return "cancel"
return None
def _extract_review_cancel_reason_from_message(self, message: str) -> str | None:
raw_message = str(message or "").strip()
if len(raw_message) < 4:
return None
patterns = (
r"\bporque\b",
r"\bpois\b",
r"\bpor conta de\b",
r"\bmotivo(?: do cancelamento)?\b\s*[:\-]?",
r"\bja que\b",
)
for pattern in patterns:
match = re.search(pattern, raw_message, flags=re.IGNORECASE)
if not match:
continue
reason = raw_message[match.end():].strip(" ,.;:-")
if len(reason) >= 4:
return reason
return None
def _should_bootstrap_review_from_active_context(self, message: str, payload: dict | None = None) -> bool:
normalized_message = self._normalize_text(message).strip()
normalized_payload = payload if isinstance(payload, dict) else {}
if normalized_payload:
return True
explicit_review_terms = {
"agendar revisao",
"marcar revisao",
"nova revisao",
"revisao agora",
"revisao",
}
return any(term in normalized_message for term in explicit_review_terms)
def _is_explicit_review_reuse_request(self, message: str) -> bool:
normalized_message = self._normalize_text(message).strip()
reuse_terms = {
"reutilizar",
"reaproveitar",
"usar de novo",
"usar novamente",
"mesmo carro",
"ultimo carro",
"ultimo veiculo",
"ultimo veículo",
}
if not any(term in normalized_message for term in reuse_terms):
return False
return any(term in normalized_message for term in {"carro", "veiculo", "veículo", "informacoes", "dados"})
async def _try_handle_review_management(
self,
message: str,
user_id: int | None,
extracted_fields: dict | None = None,
intents: dict | None = None,
turn_decision: dict | None = None,
) -> str | None:
if user_id is None:
return None
normalized_intents = self._normalize_intents(intents)
draft = self._get_review_flow_entry("pending_review_management_drafts", user_id, "review_management")
schedule_draft = self._get_review_flow_entry("pending_review_drafts", user_id, "review_schedule")
pending_reuse = self._get_review_flow_entry(
"pending_review_reuse_confirmations",
user_id,
"review_reuse_confirmation",
)
decision_intent = self._decision_intent(turn_decision)
inferred_action = self._infer_review_management_action(message=message, extracted_fields=extracted_fields)
normalized_fields = self._normalize_review_management_fields(extracted_fields)
protocol_in_message = normalized_fields.get("protocolo") or self._extract_review_protocol_from_text(message)
open_schedule_context = bool(schedule_draft or pending_reuse)
has_list_intent = (
decision_intent == "review_list"
or normalized_intents.get("review_list", False)
or inferred_action == "list"
)
has_cancel_intent = (
decision_intent == "review_cancel"
or normalized_intents.get("review_cancel", False)
or inferred_action == "cancel"
)
has_reschedule_intent = (
decision_intent == "review_reschedule"
or normalized_intents.get("review_reschedule", False)
or inferred_action == "reschedule"
)
if open_schedule_context and not protocol_in_message and inferred_action is None:
return None
if (decision_intent == "review_schedule" or normalized_intents.get("review_schedule", False)) and inferred_action is None:
if draft is not None:
self._pop_review_flow_entry(
"pending_review_management_drafts",
user_id,
"review_management",
active_task="review_management",
)
draft = None
return None
if has_list_intent:
self._reset_pending_review_states(user_id=user_id)
try:
tool_result = await self.tool_executor.execute(
"listar_agendamentos_revisao",
{"limite": 100},
user_id=user_id,
)
except HTTPException as exc:
return self._http_exception_detail(exc)
return self._fallback_format_tool_result("listar_agendamentos_revisao", tool_result)
if not has_cancel_intent and not has_reschedule_intent and draft is None:
return None
if draft is None:
action = "reschedule" if has_reschedule_intent else "cancel"
draft = {
"action": action,
"payload": {},
"expires_at": utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES),
}
else:
if has_reschedule_intent:
draft["action"] = "reschedule"
elif has_cancel_intent:
draft["action"] = "cancel"
extracted = self._normalize_review_management_fields(extracted_fields)
if "protocolo" not in extracted:
inferred_protocol = self._extract_review_protocol_from_text(message)
if inferred_protocol:
extracted["protocolo"] = inferred_protocol
action = draft.get("action", "cancel")
current_protocol = extracted.get("protocolo") or draft["payload"].get("protocolo")
if action == "reschedule" and "nova_data_hora" not in extracted:
normalized_new_datetime = self._extract_review_management_datetime_from_message(message)
if normalized_new_datetime:
extracted["nova_data_hora"] = normalized_new_datetime
if action == "cancel" and "motivo" not in extracted and current_protocol:
inferred_reason = self._extract_review_cancel_reason_from_message(message)
if inferred_reason:
extracted["motivo"] = inferred_reason
elif not has_cancel_intent:
free_text = str(message or "").strip()
if free_text and len(free_text) >= 4 and not self._is_affirmative_message(free_text):
extracted["motivo"] = free_text
draft["payload"].update(extracted)
if action == "reschedule":
self._merge_review_management_base_date_with_time(message=message, payload=draft["payload"])
if "nova_data_hora" not in draft["payload"]:
self._store_review_management_base_date_from_message(message=message, payload=draft["payload"])
draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES)
self._set_review_flow_entry(
"pending_review_management_drafts",
user_id,
"review_management",
draft,
active_task="review_management",
)
if action == "reschedule":
missing = [field for field in ("protocolo", "nova_data_hora") if field not in draft["payload"]]
if missing:
if missing == ["nova_data_hora"] and draft["payload"].get("nova_data_hora_base"):
return (
f"Perfeito. Tenho a data {draft['payload']['nova_data_hora_base']}. "
"Agora me informe o horario desejado para a revisao."
)
return self._render_missing_review_reschedule_fields_prompt(missing)
try:
tool_result = await self.tool_executor.execute(
"editar_data_revisao",
{
"protocolo": draft["payload"]["protocolo"],
"nova_data_hora": draft["payload"]["nova_data_hora"],
},
user_id=user_id,
)
except HTTPException as exc:
error = self.tool_executor.coerce_http_error(exc)
if error.get("retryable") and error.get("field"):
draft["payload"].pop(str(error["field"]), None)
draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES)
self._set_review_flow_entry(
"pending_review_management_drafts",
user_id,
"review_management",
draft,
active_task="review_management",
)
return self._http_exception_detail(exc)
self._pop_review_flow_entry(
"pending_review_management_drafts",
user_id,
"review_management",
active_task="review_management",
)
return self._fallback_format_tool_result("editar_data_revisao", tool_result)
missing = [field for field in ("protocolo",) if field not in draft["payload"]]
if missing:
return self._render_missing_review_cancel_fields_prompt(missing)
try:
tool_result = await self.tool_executor.execute(
"cancelar_agendamento_revisao",
{
"protocolo": draft["payload"]["protocolo"],
"motivo": draft["payload"].get("motivo"),
},
user_id=user_id,
)
except HTTPException as exc:
error = self.tool_executor.coerce_http_error(exc)
if error.get("retryable") and error.get("field"):
draft["payload"].pop(str(error["field"]), None)
draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES)
self._set_review_flow_entry(
"pending_review_management_drafts",
user_id,
"review_management",
draft,
active_task="review_management",
)
return self._http_exception_detail(exc)
self._pop_review_flow_entry(
"pending_review_management_drafts",
user_id,
"review_management",
active_task="review_management",
)
return self._fallback_format_tool_result("cancelar_agendamento_revisao", tool_result)
def _render_missing_review_fields_prompt(self, missing_fields: list[str], payload: dict | None = None) -> str:
labels = {
"placa": "a placa do veiculo",
"data_hora": "a data e hora desejada para a revisao",
"modelo": "o modelo do veiculo",
"ano": "o ano do veiculo",
"km": "a quilometragem atual (km)",
"revisao_previa_concessionaria": "se ja fez revisao na concessionaria (sim/nao)",
}
if isinstance(payload, dict) and payload.get("data_hora_base") and "data_hora" in missing_fields:
itens = ["- o horario desejado para a revisao"]
itens.extend(f"- {labels[field]}" for field in missing_fields if field != "data_hora")
return (
f"Perfeito. Tenho a data {payload['data_hora_base']}. "
"Para agendar sua revisao, ainda preciso dos dados abaixo:\n"
+ "\n".join(itens)
)
itens = [f"- {labels[field]}" for field in missing_fields]
return "Para agendar sua revisao, preciso dos dados abaixo:\n" + "\n".join(itens)
def _render_missing_review_cancel_fields_prompt(self, missing_fields: list[str]) -> str:
labels = {
"protocolo": "o protocolo da revisao (ex.: REV-20260310-ABC12345)",
}
itens = [f"- {labels[field]}" for field in missing_fields]
return "Para cancelar o agendamento de revisao, preciso dos dados abaixo:\n" + "\n".join(itens)
def _render_missing_review_reschedule_fields_prompt(self, missing_fields: list[str]) -> str:
labels = {
"protocolo": "o protocolo da revisao (ex.: REV-20260310-ABC12345)",
"nova_data_hora": "a nova data e hora desejada para a revisao",
}
itens = [f"- {labels[field]}" for field in missing_fields]
return "Para remarcar sua revisao, preciso dos dados abaixo:\n" + "\n".join(itens)
def _render_review_reuse_question(self, payload: dict | None = None) -> str:
package = payload if isinstance(payload, dict) else {}
plate = str(package.get("placa") or "").strip()
model = str(package.get("modelo") or "").strip()
vehicle_label = ""
if plate and model:
vehicle_label = f" do ultimo veiculo ({model}, placa {plate})"
elif plate:
vehicle_label = f" do ultimo veiculo (placa {plate})"
elif model:
vehicle_label = f" do ultimo veiculo ({model})"
return (
f"Posso reutilizar os dados{vehicle_label} e voce me passa so a nova data/hora da revisao? "
"(sim/nao)"
)
def _store_last_review_package(self, user_id: int | None, payload: dict | None) -> None:
self._review_flow_state_support.store_last_review_package(
user_id=user_id,
payload=payload,
)
def _get_last_review_package(self, user_id: int | None) -> dict | None:
return self._review_flow_state_support.get_last_review_package(user_id=user_id)
async def _try_collect_and_schedule_review(
self,
message: str,
user_id: int | None,
extracted_fields: dict | None = None,
intents: dict | None = None,
turn_decision: dict | None = None,
) -> str | None:
if user_id is None:
return None
normalized_intents = self._normalize_intents(intents)
decision_intent = self._decision_intent(turn_decision)
has_intent = decision_intent == "review_schedule" or normalized_intents.get("review_schedule", False)
has_management_intent = (
decision_intent in {"review_list", "review_cancel", "review_reschedule"}
or normalized_intents.get("review_list", False)
or normalized_intents.get("review_cancel", False)
or normalized_intents.get("review_reschedule", False)
)
if self._infer_review_management_action(message=message, extracted_fields=extracted_fields):
return None
if has_management_intent:
self._pop_review_flow_entry(
"pending_review_drafts",
user_id,
"review_schedule",
active_task="review_schedule",
)
self._pop_review_flow_entry(
"pending_review_reuse_confirmations",
user_id,
"review_reuse_confirmation",
)
return None
draft = self._get_review_flow_entry("pending_review_drafts", user_id, "review_schedule")
extracted = self._normalize_review_fields(extracted_fields)
pending_reuse = self._get_review_flow_entry(
"pending_review_reuse_confirmations",
user_id,
"review_reuse_confirmation",
)
pending_confirmation = self._get_review_flow_entry(
"pending_review_confirmations",
user_id,
"review_confirmation",
)
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._pop_review_flow_entry(
"pending_review_confirmations",
user_id,
"review_confirmation",
)
pending_confirmation = None
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._pop_review_flow_entry(
"pending_review_reuse_confirmations",
user_id,
"review_reuse_confirmation",
)
pending_reuse = None
if not extracted:
draft = {
"payload": {},
"expires_at": utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES),
}
self._set_review_flow_entry(
"pending_review_drafts",
user_id,
"review_schedule",
draft,
active_task="review_schedule",
)
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"))
if should_reuse:
seed_payload = dict(pending_reuse.get("payload") or {})
if draft is None:
draft = {
"payload": seed_payload,
"expires_at": utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES),
}
else:
for key, value in seed_payload.items():
draft["payload"].setdefault(key, value)
self._pop_review_flow_entry(
"pending_review_reuse_confirmations",
user_id,
"review_reuse_confirmation",
)
review_flow_source = "last_review_package"
if date_only and not extracted.get("data_hora"):
draft["payload"]["data_hora_base"] = date_only
self._set_review_flow_entry(
"pending_review_drafts",
user_id,
"review_schedule",
draft,
active_task="review_schedule",
)
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._set_review_flow_entry(
"pending_review_drafts",
user_id,
"review_schedule",
draft,
active_task="review_schedule",
)
self._log_review_flow_source(source=review_flow_source, payload=draft["payload"], missing_fields=["data_hora"])
return "Perfeito. Me informe apenas a data e hora desejada para a revisao."
last_package = self._get_last_review_package(user_id=user_id)
explicit_reuse_request = self._is_explicit_review_reuse_request(message)
active_context_reuse_request = (
active_review_context
and draft is None
and self._should_bootstrap_review_from_active_context(message=message, payload=extracted)
)
should_offer_reuse = bool(last_package) and not pending_reuse and (
(has_intent and draft is None)
or explicit_reuse_request
or active_context_reuse_request
)
if should_offer_reuse:
self._set_review_flow_entry(
"pending_review_reuse_confirmations",
user_id,
"review_reuse_confirmation",
{
"payload": last_package,
"expires_at": utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES),
},
)
self._log_review_flow_source(source="last_review_package", payload=last_package)
return self._render_review_reuse_question(last_package)
if (
draft
and not has_intent
and (
decision_intent in {"order_create", "order_cancel"}
or normalized_intents.get("order_create", False)
or normalized_intents.get("order_cancel", False)
)
and not extracted
):
if not self._is_review_temporal_follow_up(message=message, payload=draft.get("payload")):
self._pop_review_flow_entry(
"pending_review_drafts",
user_id,
"review_schedule",
active_task="review_schedule",
)
return None
bootstrap_payload = dict(extracted)
self._supplement_review_fields_from_message(message=message, payload=bootstrap_payload)
self._try_prefill_review_fields_from_memory(user_id=user_id, payload=bootstrap_payload)
should_bootstrap_from_context = (
active_review_context
and self._should_bootstrap_review_from_active_context(message=message, payload=bootstrap_payload)
)
if not has_intent and draft is None and not should_bootstrap_from_context:
return None
if draft is None:
# Cria um draft com TTL para permitir coleta do agendamento
# em varias mensagens sem perder o progresso.
review_flow_source = "active_domain_fallback" if should_bootstrap_from_context and not has_intent else "intent_bootstrap"
draft = {
"payload": dict(bootstrap_payload),
"expires_at": utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES),
}
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._store_review_base_date_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"]
and draft["payload"]
and not extracted
):
if self._is_affirmative_message(message):
draft["payload"]["revisao_previa_concessionaria"] = True
elif self._is_negative_message(message):
draft["payload"]["revisao_previa_concessionaria"] = False
draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES)
self._set_review_flow_entry(
"pending_review_drafts",
user_id,
"review_schedule",
draft,
active_task="review_schedule",
)
missing = [field for field in REVIEW_REQUIRED_FIELDS if field not in draft["payload"]]
if missing:
self._log_review_flow_source(source=review_flow_source or "draft", payload=draft["payload"], missing_fields=missing)
if missing == ["data_hora"] and draft["payload"].get("data_hora_base"):
return (
f"Perfeito. Tenho a data {draft['payload']['data_hora_base']}. "
"Agora me informe o horario desejado para a revisao."
)
return self._render_missing_review_fields_prompt(missing, payload=draft["payload"])
try:
tool_result = await self.tool_executor.execute(
"agendar_revisao",
draft["payload"],
user_id=user_id,
)
except HTTPException as exc:
error = self.tool_executor.coerce_http_error(exc)
self._capture_review_confirmation_suggestion(
tool_name="agendar_revisao",
arguments=draft["payload"],
exc=exc,
user_id=user_id,
)
if error.get("retryable") and error.get("field"):
draft["payload"].pop(str(error["field"]), None)
draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES)
self._set_review_flow_entry(
"pending_review_drafts",
user_id,
"review_schedule",
draft,
active_task="review_schedule",
)
self._log_review_flow_source(source=review_flow_source or "draft", payload=draft["payload"])
return self._http_exception_detail(exc)
self._pop_review_flow_entry(
"pending_review_drafts",
user_id,
"review_schedule",
active_task="review_schedule",
)
self._store_last_review_package(user_id=user_id, payload=draft["payload"])
self._log_review_flow_source(source=review_flow_source or "draft", payload=draft["payload"])
if hasattr(self, "_capture_successful_tool_side_effects"):
self._capture_successful_tool_side_effects(
tool_name="agendar_revisao",
arguments=draft["payload"],
tool_result=tool_result,
user_id=user_id,
)
return self._fallback_format_tool_result("agendar_revisao", tool_result)