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.
923 lines
39 KiB
Python
923 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 = {
|
|
"depois de amanha",
|
|
"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|depois de amanha|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|depois de amanha|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 "depois de amanha" in normalized_text:
|
|
return (self._review_now() + timedelta(days=2)).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)
|
|
self._capture_review_confirmation_suggestion(
|
|
tool_name="editar_data_revisao",
|
|
arguments={
|
|
"protocolo": draft["payload"]["protocolo"],
|
|
"nova_data_hora": draft["payload"]["nova_data_hora"],
|
|
},
|
|
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_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)
|
|
|