🐛 fix(orchestration): blindar decisoes estruturadas e continuidade do fluxo de revisao

- torna o normalizador de decisoes mais tolerante a saidas quase validas do modelo, aceitando pseudo-JSON, aliases de topo e degradando call_tool ou ask_missing_fields incompletos para coleta estruturada\n- normaliza aliases de tools de revisao, reaproveita argumentos de drafts abertos antes de executar tool direta e aceita today/tomorrow no parsing tecnico de data relativa\n- prioriza confirmacoes pendentes de troca de contexto antes dos follow-ups de venda para evitar que respostas como sim reabram listas de estoque por engano\n- melhora o fluxo de revisao para manter drafts diante de respostas temporais como 14h ou 16h e para pedir apenas horario e campos realmente faltantes quando a data ja foi capturada\n- amplia a cobertura de regressao para confirmacao de context switch, normalizacao de tool calls legadas, continuidade de revisao incremental e confirmacao de horarios sugeridos
main
parent cdb36ab964
commit 31cd7cdb69

@ -1,5 +1,6 @@
import re import re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from app.core.time_utils import utc_now
from fastapi import HTTPException from fastapi import HTTPException
@ -77,7 +78,7 @@ class ReviewFlowMixin:
snapshot = self._get_review_flow_snapshot(user_id=user_id, snapshot_key=snapshot_key) snapshot = self._get_review_flow_snapshot(user_id=user_id, snapshot_key=snapshot_key)
if not snapshot: if not snapshot:
return None return None
if snapshot.get("expires_at") and snapshot["expires_at"] < datetime.utcnow(): if snapshot.get("expires_at") and snapshot["expires_at"] < utc_now():
self._set_review_flow_snapshot(user_id=user_id, snapshot_key=snapshot_key, value=None) self._set_review_flow_snapshot(user_id=user_id, snapshot_key=snapshot_key, value=None)
return None return None
@ -240,6 +241,19 @@ class ReviewFlowMixin:
return return
payload["data_hora_base"] = date_only payload["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( def _infer_review_management_action(
self, self,
message: str, message: str,
@ -367,7 +381,7 @@ class ReviewFlowMixin:
draft = { draft = {
"action": action, "action": action,
"payload": {}, "payload": {},
"expires_at": datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES), "expires_at": utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES),
} }
else: else:
if has_reschedule_intent: if has_reschedule_intent:
@ -393,7 +407,7 @@ class ReviewFlowMixin:
extracted["motivo"] = free_text extracted["motivo"] = free_text
draft["payload"].update(extracted) draft["payload"].update(extracted)
draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES) draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES)
self._set_review_flow_entry( self._set_review_flow_entry(
"pending_review_management_drafts", "pending_review_management_drafts",
user_id, user_id,
@ -419,7 +433,7 @@ class ReviewFlowMixin:
error = self.tool_executor.coerce_http_error(exc) error = self.tool_executor.coerce_http_error(exc)
if error.get("retryable") and error.get("field"): if error.get("retryable") and error.get("field"):
draft["payload"].pop(str(error["field"]), None) draft["payload"].pop(str(error["field"]), None)
draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES) draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES)
self._set_review_flow_entry( self._set_review_flow_entry(
"pending_review_management_drafts", "pending_review_management_drafts",
user_id, user_id,
@ -452,7 +466,7 @@ class ReviewFlowMixin:
error = self.tool_executor.coerce_http_error(exc) error = self.tool_executor.coerce_http_error(exc)
if error.get("retryable") and error.get("field"): if error.get("retryable") and error.get("field"):
draft["payload"].pop(str(error["field"]), None) draft["payload"].pop(str(error["field"]), None)
draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES) draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES)
self._set_review_flow_entry( self._set_review_flow_entry(
"pending_review_management_drafts", "pending_review_management_drafts",
user_id, user_id,
@ -469,7 +483,7 @@ class ReviewFlowMixin:
) )
return self._fallback_format_tool_result("cancelar_agendamento_revisao", tool_result) return self._fallback_format_tool_result("cancelar_agendamento_revisao", tool_result)
def _render_missing_review_fields_prompt(self, missing_fields: list[str]) -> str: def _render_missing_review_fields_prompt(self, missing_fields: list[str], payload: dict | None = None) -> str:
labels = { labels = {
"placa": "a placa do veiculo", "placa": "a placa do veiculo",
"data_hora": "a data e hora desejada para a revisao", "data_hora": "a data e hora desejada para a revisao",
@ -478,6 +492,14 @@ class ReviewFlowMixin:
"km": "a quilometragem atual (km)", "km": "a quilometragem atual (km)",
"revisao_previa_concessionaria": "se ja fez revisao na concessionaria (sim/nao)", "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] itens = [f"- {labels[field]}" for field in missing_fields]
return "Para agendar sua revisao, preciso dos dados abaixo:\n" + "\n".join(itens) return "Para agendar sua revisao, preciso dos dados abaixo:\n" + "\n".join(itens)
@ -535,7 +557,7 @@ class ReviewFlowMixin:
user_id, user_id,
{ {
"payload": sanitized, "payload": sanitized,
"expires_at": datetime.utcnow() + timedelta(minutes=LAST_REVIEW_PACKAGE_TTL_MINUTES), "expires_at": utc_now() + timedelta(minutes=LAST_REVIEW_PACKAGE_TTL_MINUTES),
}, },
) )
@ -624,7 +646,7 @@ class ReviewFlowMixin:
if not extracted: if not extracted:
draft = { draft = {
"payload": {}, "payload": {},
"expires_at": datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES), "expires_at": utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES),
} }
self._set_review_flow_entry( self._set_review_flow_entry(
"pending_review_drafts", "pending_review_drafts",
@ -652,7 +674,7 @@ class ReviewFlowMixin:
if draft is None: if draft is None:
draft = { draft = {
"payload": seed_payload, "payload": seed_payload,
"expires_at": datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES), "expires_at": utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES),
} }
else: else:
for key, value in seed_payload.items(): for key, value in seed_payload.items():
@ -708,7 +730,7 @@ class ReviewFlowMixin:
"review_reuse_confirmation", "review_reuse_confirmation",
{ {
"payload": last_package, "payload": last_package,
"expires_at": datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES), "expires_at": utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES),
}, },
) )
self._log_review_flow_source(source="last_review_package", payload=last_package) self._log_review_flow_source(source="last_review_package", payload=last_package)
@ -724,13 +746,14 @@ class ReviewFlowMixin:
) )
and not extracted and not extracted
): ):
self._pop_review_flow_entry( if not self._is_review_temporal_follow_up(message=message, payload=draft.get("payload")):
"pending_review_drafts", self._pop_review_flow_entry(
user_id, "pending_review_drafts",
"review_schedule", user_id,
active_task="review_schedule", "review_schedule",
) active_task="review_schedule",
return None )
return None
bootstrap_payload = dict(extracted) bootstrap_payload = dict(extracted)
self._supplement_review_fields_from_message(message=message, payload=bootstrap_payload) self._supplement_review_fields_from_message(message=message, payload=bootstrap_payload)
@ -749,7 +772,7 @@ class ReviewFlowMixin:
review_flow_source = "active_domain_fallback" if should_bootstrap_from_context and not has_intent else "intent_bootstrap" review_flow_source = "active_domain_fallback" if should_bootstrap_from_context and not has_intent else "intent_bootstrap"
draft = { draft = {
"payload": dict(bootstrap_payload), "payload": dict(bootstrap_payload),
"expires_at": datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES), "expires_at": utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES),
} }
draft["payload"].update(extracted) draft["payload"].update(extracted)
@ -768,7 +791,7 @@ class ReviewFlowMixin:
draft["payload"]["revisao_previa_concessionaria"] = True draft["payload"]["revisao_previa_concessionaria"] = True
elif self._is_negative_message(message): elif self._is_negative_message(message):
draft["payload"]["revisao_previa_concessionaria"] = False draft["payload"]["revisao_previa_concessionaria"] = False
draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES) draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES)
self._set_review_flow_entry( self._set_review_flow_entry(
"pending_review_drafts", "pending_review_drafts",
user_id, user_id,
@ -785,7 +808,7 @@ class ReviewFlowMixin:
f"Perfeito. Tenho a data {draft['payload']['data_hora_base']}. " f"Perfeito. Tenho a data {draft['payload']['data_hora_base']}. "
"Agora me informe o horario desejado para a revisao." "Agora me informe o horario desejado para a revisao."
) )
return self._render_missing_review_fields_prompt(missing) return self._render_missing_review_fields_prompt(missing, payload=draft["payload"])
try: try:
tool_result = await self.tool_executor.execute( tool_result = await self.tool_executor.execute(
@ -803,7 +826,7 @@ class ReviewFlowMixin:
) )
if error.get("retryable") and error.get("field"): if error.get("retryable") and error.get("field"):
draft["payload"].pop(str(error["field"]), None) draft["payload"].pop(str(error["field"]), None)
draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES) draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES)
self._set_review_flow_entry( self._set_review_flow_entry(
"pending_review_drafts", "pending_review_drafts",
user_id, user_id,

@ -1,3 +1,4 @@
import ast
import json import json
import logging import logging
import re import re
@ -24,6 +25,7 @@ class EntityNormalizer:
"marcar_revisao": "agendar_revisao", "marcar_revisao": "agendar_revisao",
"agendar revisao": "agendar_revisao", "agendar revisao": "agendar_revisao",
"schedule_review": "agendar_revisao", "schedule_review": "agendar_revisao",
"agendar_revisao_veiculo": "agendar_revisao",
"list_reviews": "listar_agendamentos_revisao", "list_reviews": "listar_agendamentos_revisao",
"listar_revisoes": "listar_agendamentos_revisao", "listar_revisoes": "listar_agendamentos_revisao",
"listar_agendamentos": "listar_agendamentos_revisao", "listar_agendamentos": "listar_agendamentos_revisao",
@ -57,6 +59,66 @@ class EntityNormalizer:
"cancel_flow": "cancel_active_flow", "cancel_flow": "cancel_active_flow",
"reset_context": "clear_context", "reset_context": "clear_context",
} }
_TURN_DOMAIN_ALIASES = {
"service": "review",
"services": "review",
"post_sales": "review",
"after_sales": "review",
"purchase": "sales",
"buy": "sales",
"order": "sales",
"orders": "sales",
"conversation": "general",
"chat": "general",
}
_TURN_VALID_DOMAINS = {"review", "sales", "general"}
_TURN_VALID_INTENTS = {
"review_schedule",
"review_list",
"review_cancel",
"review_reschedule",
"order_create",
"order_list",
"order_cancel",
"inventory_search",
"conversation_reset",
"queue_continue",
"discard_queue",
"cancel_active_flow",
"general",
}
_TURN_VALID_ACTIONS = {
"collect_review_schedule",
"collect_review_management",
"collect_order_create",
"collect_order_cancel",
"ask_missing_fields",
"answer_user",
"call_tool",
"clear_context",
"continue_queue",
"discard_queue",
"cancel_active_flow",
}
_TURN_TOP_LEVEL_FIELD_ALIASES = {
"response": "response_to_user",
"answer": "response_to_user",
"reply": "response_to_user",
"message": "response_to_user",
"tool": "tool_name",
"function_name": "tool_name",
"function": "tool_name",
"arguments": "tool_arguments",
"args": "tool_arguments",
"tool_args": "tool_arguments",
"tool_parameters": "tool_arguments",
"missing": "missing_fields",
"required_fields": "missing_fields",
"missing_data": "missing_fields",
"selected_index": "selection_index",
"choice_index": "selection_index",
"selected_option_index": "selection_index",
}
_ORDER_MISSING_FIELD_ALIASES = { _ORDER_MISSING_FIELD_ALIASES = {
"modelo_carro": "vehicle_id", "modelo_carro": "vehicle_id",
"modelo_do_carro": "vehicle_id", "modelo_do_carro": "vehicle_id",
@ -64,6 +126,35 @@ class EntityNormalizer:
"veiculo": "vehicle_id", "veiculo": "vehicle_id",
"carro": "vehicle_id", "carro": "vehicle_id",
} }
_TURN_MISSING_FIELD_ALIASES = {
**_ORDER_MISSING_FIELD_ALIASES,
"date": "data_hora",
"date_time": "data_hora",
"datetime": "data_hora",
"data": "data_hora",
"data_e_hora": "data_hora",
"data_hora": "data_hora",
"data_agendamento": "data_hora",
"horario": "data_hora",
"hora": "data_hora",
"time": "data_hora",
"modelo": "modelo",
"modelo_veiculo": "modelo",
"vehicle_model": "modelo",
"ano_veiculo": "ano",
"vehicle_year": "ano",
"quilometragem": "km",
"quilometragem_atual": "km",
"vehicle_km": "km",
"revisao_previa": "revisao_previa_concessionaria",
"reviewed_before": "revisao_previa_concessionaria",
"numero": "numero_pedido",
"order_number": "numero_pedido",
"order_id": "numero_pedido",
"review_id": "protocolo",
"schedule_id": "protocolo",
"new_datetime": "nova_data_hora",
}
_TOOL_ARGUMENT_ALIASES = { _TOOL_ARGUMENT_ALIASES = {
"cancelar_pedido": { "cancelar_pedido": {
"order_id": "numero_pedido", "order_id": "numero_pedido",
@ -115,6 +206,10 @@ class EntityNormalizer:
"vehicle_km": "km", "vehicle_km": "km",
"data": "data_hora", "data": "data_hora",
"datetime": "data_hora", "datetime": "data_hora",
"data_agendamento": "data_agendamento",
"appointment_date": "data_agendamento",
"horario_agendamento": "horario_agendamento",
"appointment_time": "horario_agendamento",
"reviewed_before": "revisao_previa_concessionaria", "reviewed_before": "revisao_previa_concessionaria",
"revisao_previa": "revisao_previa_concessionaria", "revisao_previa": "revisao_previa_concessionaria",
}, },
@ -197,21 +292,66 @@ class EntityNormalizer:
candidate = (text or "").strip() candidate = (text or "").strip()
if not candidate: if not candidate:
return None return None
if candidate.startswith("```"):
candidate = re.sub(r"^```(?:json)?\s*", "", candidate, flags=re.IGNORECASE) candidates: list[str] = []
candidate = re.sub(r"\s*```$", "", candidate) stripped_candidate = self._strip_json_fence(candidate)
try: for option in (stripped_candidate, candidate):
return json.loads(candidate) option = str(option or "").strip()
except json.JSONDecodeError: if option and option not in candidates:
match = re.search(r"\{.*\}", candidate, flags=re.DOTALL) candidates.append(option)
if not match:
logger.warning("Extracao sem JSON valido no texto retornado.") match = re.search(r"\{.*\}", stripped_candidate or candidate, flags=re.DOTALL)
return None if match:
clipped = match.group(0).strip()
if clipped and clipped not in candidates:
candidates.append(clipped)
for option in candidates:
parsed = self._try_parse_json_candidate(option)
if isinstance(parsed, dict):
return parsed
logger.warning("Extracao sem JSON valido no texto retornado.")
return None
def _strip_json_fence(self, candidate: str) -> str:
stripped = str(candidate or "").strip()
if stripped.startswith("```"):
stripped = re.sub(r"^```(?:json)?\s*", "", stripped, flags=re.IGNORECASE)
stripped = re.sub(r"\s*```$", "", stripped)
return stripped.strip()
def _try_parse_json_candidate(self, candidate: str):
normalized = str(candidate or "").strip()
if not normalized:
return None
normalized = (
normalized.replace("\u201c", '"')
.replace("\u201d", '"')
.replace("\u2018", "'")
.replace("\u2019", "'")
.replace(chr(0x201C), '"')
.replace(chr(0x201D), '"')
.replace(chr(0x2018), "'")
.replace(chr(0x2019), "'")
)
variants = [normalized]
without_trailing_commas = re.sub(r",(\s*[}\]])", r"\1", normalized)
if without_trailing_commas != normalized:
variants.append(without_trailing_commas)
for variant in variants:
try: try:
return json.loads(match.group(0)) return json.loads(variant)
except json.JSONDecodeError: except json.JSONDecodeError:
logger.warning("Extracao com JSON invalido apos recorte.") pass
return None try:
parsed = ast.literal_eval(variant)
except (ValueError, SyntaxError):
continue
if isinstance(parsed, dict):
return parsed
return None
def coerce_turn_decision(self, payload) -> dict: def coerce_turn_decision(self, payload) -> dict:
if not isinstance(payload, dict): if not isinstance(payload, dict):
@ -219,8 +359,12 @@ class EntityNormalizer:
payload = self._normalize_turn_decision_payload(payload) payload = self._normalize_turn_decision_payload(payload)
try: try:
model = TurnDecision.model_validate(payload) model = TurnDecision.model_validate(payload)
except ValidationError: except ValidationError as exc:
logger.warning("Decisao de turno invalida; usando fallback estruturado.") details = "; ".join(
f"{'.'.join(str(part) for part in error.get('loc', []))}: {error.get('msg', 'erro')}"
for error in exc.errors()[:3]
)
logger.warning("Decisao de turno invalida; usando fallback estruturado. detalhes=%s", details or "n/a")
return self.empty_turn_decision() return self.empty_turn_decision()
normalized_entities = { normalized_entities = {
@ -237,30 +381,44 @@ class EntityNormalizer:
return dumped return dumped
def _normalize_turn_decision_payload(self, payload: dict) -> dict: def _normalize_turn_decision_payload(self, payload: dict) -> dict:
normalized = dict(payload) normalized = self._unwrap_turn_decision_payload(payload)
raw_intent = self.normalize_text(str(normalized.get("intent") or "")).replace("-", "_").replace(" ", "_") tool_call = normalized.get("tool_call")
if raw_intent in self._TURN_INTENT_ALIASES: if isinstance(tool_call, dict):
normalized["intent"] = self._TURN_INTENT_ALIASES[raw_intent] if "tool_name" not in normalized:
normalized["tool_name"] = tool_call.get("tool_name") or tool_call.get("name")
raw_action = self.normalize_text(str(normalized.get("action") or "")).replace("-", "_").replace(" ", "_") if "tool_arguments" not in normalized:
if raw_action in self._TURN_ACTION_ALIASES: normalized["tool_arguments"] = tool_call.get("tool_arguments") or tool_call.get("arguments") or {}
normalized["action"] = self._TURN_ACTION_ALIASES[raw_action]
for alias, canonical in self._TURN_TOP_LEVEL_FIELD_ALIASES.items():
missing_fields = normalized.get("missing_fields") if canonical not in normalized and alias in normalized:
if isinstance(missing_fields, list): normalized[canonical] = normalized.get(alias)
normalized["missing_fields"] = self._normalize_turn_missing_fields(missing_fields)
normalized["domain"] = self._normalize_turn_domain(normalized.get("domain"))
entities = normalized.get("entities") normalized["intent"] = self._normalize_turn_intent(normalized.get("intent"))
if isinstance(entities, dict): normalized["action"] = self._normalize_turn_action(normalized.get("action"))
normalized["entities"] = dict(entities) normalized["response_to_user"] = self._normalize_turn_response(normalized.get("response_to_user"))
normalized["selection_index"] = self._normalize_turn_selection_index(normalized.get("selection_index"))
normalized["missing_fields"] = self._normalize_turn_missing_fields(normalized.get("missing_fields"))
embedded_intents = self._extract_turn_intents(normalized)
normalized["entities"] = self._normalize_turn_entities(normalized)
if normalized["intent"] == "general":
inferred_intent = self._infer_primary_turn_intent(embedded_intents)
if inferred_intent:
normalized["intent"] = inferred_intent
if normalized["domain"] == "general":
normalized["domain"] = self._domain_from_turn_intent(normalized.get("intent"))
tool_name = self.normalize_tool_name(normalized.get("tool_name")) tool_name = self.normalize_tool_name(normalized.get("tool_name"))
if tool_name: normalized["tool_name"] = tool_name or None
normalized["tool_name"] = tool_name
tool_arguments = normalized.get("tool_arguments") tool_arguments = normalized.get("tool_arguments")
if tool_name and isinstance(tool_arguments, dict): if tool_name and isinstance(tool_arguments, dict):
normalized["tool_arguments"] = self.normalize_tool_arguments(tool_name, tool_arguments) normalized["tool_arguments"] = self.normalize_tool_arguments(tool_name, tool_arguments)
else:
normalized["tool_arguments"] = tool_arguments if isinstance(tool_arguments, dict) else {}
normalized = self._coerce_incomplete_action_to_collection(normalized)
if self._should_route_order_alias_to_collection(normalized): if self._should_route_order_alias_to_collection(normalized):
normalized["action"] = "collect_order_create" normalized["action"] = "collect_order_create"
@ -269,15 +427,296 @@ class EntityNormalizer:
normalized = self._coerce_incomplete_tool_call_to_collection(normalized) normalized = self._coerce_incomplete_tool_call_to_collection(normalized)
if (
normalized.get("intent") == "general"
and not normalized.get("tool_name")
and not any((normalized.get("entities") or {}).values())
):
normalized["domain"] = "general"
return {
"intent": normalized.get("intent") or "general",
"domain": normalized.get("domain") or "general",
"action": normalized.get("action") or "answer_user",
"entities": normalized.get("entities") if isinstance(normalized.get("entities"), dict) else self.empty_turn_decision()["entities"],
"missing_fields": normalized.get("missing_fields") if isinstance(normalized.get("missing_fields"), list) else [],
"selection_index": normalized.get("selection_index"),
"tool_name": normalized.get("tool_name"),
"tool_arguments": normalized.get("tool_arguments") if isinstance(normalized.get("tool_arguments"), dict) else {},
"response_to_user": normalized.get("response_to_user"),
}
def _unwrap_turn_decision_payload(self, payload: dict) -> dict:
normalized = dict(payload)
if any(key in normalized for key in ("intent", "domain", "action", "entities", "tool_name", "tool_arguments")):
return normalized
for key in ("turn_decision", "decision", "payload", "data"):
nested = normalized.get(key)
if isinstance(nested, dict):
return dict(nested)
return normalized return normalized
def _normalize_turn_missing_fields(self, missing_fields: list) -> list[str]: def _normalize_turn_domain(self, value) -> str:
candidate = self.normalize_text(str(value or "")).replace("-", "_").replace(" ", "_")
candidate = self._TURN_DOMAIN_ALIASES.get(candidate, candidate)
return candidate if candidate in self._TURN_VALID_DOMAINS else "general"
def _normalize_turn_intent(self, value) -> str:
candidate = self.normalize_text(str(value or "")).replace("-", "_").replace(" ", "_")
candidate = self._TURN_INTENT_ALIASES.get(candidate, candidate)
extra_aliases = {
"schedule_review": "review_schedule",
"review_schedule_follow_up": "review_schedule",
"review_schedule_followup": "review_schedule",
"review_management": "review_reschedule",
"reset": "conversation_reset",
"restart_conversation": "conversation_reset",
"continue_queue": "queue_continue",
"next_order": "queue_continue",
"cancel_current_flow": "cancel_active_flow",
}
candidate = extra_aliases.get(candidate, candidate)
return candidate if candidate in self._TURN_VALID_INTENTS else "general"
def _normalize_turn_action(self, value) -> str:
candidate = self.normalize_text(str(value or "")).replace("-", "_").replace(" ", "_")
candidate = self._TURN_ACTION_ALIASES.get(candidate, candidate)
extra_aliases = {
"answer": "answer_user",
"reply": "answer_user",
"respond": "answer_user",
"tool_call": "call_tool",
"execute_tool": "call_tool",
"use_tool": "call_tool",
"ask_for_missing_fields": "ask_missing_fields",
"request_missing_fields": "ask_missing_fields",
"collect_missing_fields": "ask_missing_fields",
"continue": "continue_queue",
"next_order": "continue_queue",
"discard": "discard_queue",
"reset": "clear_context",
"clear_conversation": "clear_context",
}
candidate = extra_aliases.get(candidate, candidate)
return candidate if candidate in self._TURN_VALID_ACTIONS else "answer_user"
def _normalize_turn_response(self, value) -> str | None:
if value is None:
return None
if isinstance(value, str):
stripped = value.strip()
return stripped or None
if isinstance(value, (int, float)) and not isinstance(value, bool):
return str(value)
return None
def _normalize_turn_selection_index(self, value) -> int | None:
if value in (None, "") or isinstance(value, bool):
return None
if isinstance(value, (int, float)):
candidate = int(value)
return candidate if candidate >= 0 else None
text = self.normalize_text(str(value or "")).strip()
ordinal_aliases = {
"primeiro": 0,
"primeira": 0,
"segundo": 1,
"segunda": 1,
"terceiro": 2,
"terceira": 2,
}
if text in ordinal_aliases:
return ordinal_aliases[text]
match = re.search(r"\d+", text)
if not match:
return None
candidate = int(match.group(0))
return candidate if candidate >= 0 else None
def _normalize_turn_entities(self, payload: dict) -> dict:
container = payload.get("entities") if isinstance(payload.get("entities"), dict) else {}
normalized_entities: dict[str, dict] = {}
for key in (
"generic_memory",
"review_fields",
"review_management_fields",
"order_fields",
"cancel_order_fields",
):
merged: dict = {}
top_level_value = payload.get(key)
if isinstance(top_level_value, dict):
merged.update(top_level_value)
nested_value = container.get(key)
if isinstance(nested_value, dict):
merged.update(nested_value)
normalized_entities[key] = merged
return normalized_entities
def _extract_turn_intents(self, payload: dict) -> dict:
candidates: list = []
if isinstance(payload.get("intents"), dict):
candidates.append(payload.get("intents"))
entities = payload.get("entities") if isinstance(payload.get("entities"), dict) else {}
if isinstance(entities.get("intents"), dict):
candidates.append(entities.get("intents"))
for candidate in candidates:
normalized = self.normalize_intents(candidate)
if any(normalized.values()):
return normalized
return {}
def _infer_primary_turn_intent(self, intents: dict) -> str | None:
if not isinstance(intents, dict):
return None
priority = (
"review_schedule",
"review_reschedule",
"review_cancel",
"review_list",
"order_create",
"order_cancel",
"order_list",
)
for key in priority:
if intents.get(key):
return key
return None
def _domain_from_turn_intent(self, intent: str | None) -> str:
if intent in {"review_schedule", "review_list", "review_cancel", "review_reschedule"}:
return "review"
if intent in {"order_create", "order_list", "order_cancel", "inventory_search", "queue_continue", "discard_queue", "cancel_active_flow"}:
return "sales"
return "general"
def _coerce_incomplete_action_to_collection(self, payload: dict) -> dict:
action = payload.get("action")
collection_action = self._infer_collection_action(payload)
if action == "ask_missing_fields" and not payload.get("response_to_user"):
if collection_action:
payload["action"] = collection_action
payload["response_to_user"] = None
else:
payload["action"] = "answer_user"
payload["missing_fields"] = []
return payload
if action == "call_tool" and not str(payload.get("tool_name") or "").strip():
if collection_action:
payload["action"] = collection_action
payload = self._merge_tool_arguments_into_collection_entities(payload, collection_action)
else:
payload["action"] = "answer_user"
payload["tool_name"] = None
payload["tool_arguments"] = {}
payload["response_to_user"] = payload.get("response_to_user")
return payload
return payload
def _infer_collection_action(self, payload: dict) -> str | None:
intent = str(payload.get("intent") or "").strip()
if intent == "review_schedule":
return "collect_review_schedule"
if intent in {"review_cancel", "review_reschedule"}:
return "collect_review_management"
if intent == "order_create":
return "collect_order_create"
if intent == "order_cancel":
return "collect_order_cancel"
entities = payload.get("entities") if isinstance(payload.get("entities"), dict) else {}
domain = str(payload.get("domain") or "").strip()
if domain == "review":
if entities.get("review_management_fields"):
return "collect_review_management"
if entities.get("review_fields"):
return "collect_review_schedule"
if domain == "sales":
if entities.get("cancel_order_fields"):
return "collect_order_cancel"
if entities.get("order_fields") or entities.get("generic_memory"):
return "collect_order_create"
return None
def _merge_tool_arguments_into_collection_entities(self, payload: dict, collection_action: str) -> dict:
entities = payload.get("entities") if isinstance(payload.get("entities"), dict) else {}
payload["entities"] = entities
raw_arguments = payload.get("tool_arguments") if isinstance(payload.get("tool_arguments"), dict) else {}
intent = str(payload.get("intent") or "").strip()
if collection_action == "collect_order_cancel":
normalized_arguments = self.normalize_tool_arguments("cancelar_pedido", raw_arguments)
entities["cancel_order_fields"] = self.normalize_cancel_order_fields(
{
**(entities.get("cancel_order_fields") or {}),
**normalized_arguments,
}
)
return payload
if collection_action == "collect_order_create":
normalized_arguments = self.normalize_tool_arguments("realizar_pedido", raw_arguments)
entities["order_fields"] = self.normalize_order_fields(
{
**(entities.get("order_fields") or {}),
**normalized_arguments,
}
)
return payload
if collection_action == "collect_review_schedule":
normalized_arguments = self.normalize_tool_arguments("agendar_revisao", raw_arguments)
entities["review_fields"] = self.normalize_review_fields(
{
**(entities.get("review_fields") or {}),
**normalized_arguments,
}
)
return payload
if collection_action == "collect_review_management":
tool_name = "editar_data_revisao" if intent == "review_reschedule" else "cancelar_agendamento_revisao"
normalized_arguments = self.normalize_tool_arguments(tool_name, raw_arguments)
entities["review_management_fields"] = self.normalize_review_management_fields(
{
**(entities.get("review_management_fields") or {}),
**normalized_arguments,
}
)
return payload
return payload
def _normalize_turn_missing_fields(self, missing_fields) -> list[str]:
if missing_fields is None:
return []
raw_fields = missing_fields if isinstance(missing_fields, list) else [missing_fields]
normalized_fields: list[str] = [] normalized_fields: list[str] = []
for field in missing_fields: for field in raw_fields:
candidate = self.normalize_text(str(field or "")).replace("-", "_").replace(" ", "_") if field in (None, ""):
canonical = self._ORDER_MISSING_FIELD_ALIASES.get(candidate, candidate) continue
if canonical and canonical not in normalized_fields: text_value = str(field or "").strip()
normalized_fields.append(canonical) if not text_value:
continue
normalized_value = self.normalize_text(text_value).replace("-", "_").replace(" ", "_")
segments = [normalized_value]
if normalized_value not in self._TURN_MISSING_FIELD_ALIASES:
split_segments = [segment for segment in re.split(r"[,;/]", normalized_value) if segment]
if len(split_segments) > 1:
segments = split_segments
for segment in segments:
if segment not in self._TURN_MISSING_FIELD_ALIASES and "_e_" in segment:
for part in (item for item in segment.split("_e_") if item):
canonical = self._TURN_MISSING_FIELD_ALIASES.get(part, part)
if canonical and canonical not in normalized_fields:
normalized_fields.append(canonical)
continue
canonical = self._TURN_MISSING_FIELD_ALIASES.get(segment, segment)
if canonical and canonical not in normalized_fields:
normalized_fields.append(canonical)
return normalized_fields return normalized_fields
def _should_route_order_alias_to_collection(self, payload: dict) -> bool: def _should_route_order_alias_to_collection(self, payload: dict) -> bool:
@ -454,6 +893,14 @@ class EntityNormalizer:
return self.normalize_review_management_fields(normalized_arguments) return self.normalize_review_management_fields(normalized_arguments)
if normalized_tool_name == "agendar_revisao": if normalized_tool_name == "agendar_revisao":
schedule_date = str(normalized_arguments.pop("data_agendamento", "") or "").strip()
schedule_time = str(normalized_arguments.pop("horario_agendamento", "") or "").strip()
if "data_hora" not in normalized_arguments:
combined_datetime = self.normalize_review_datetime_text(
" ".join(part for part in (schedule_date, schedule_time) if part)
)
if combined_datetime:
normalized_arguments["data_hora"] = combined_datetime
return self.normalize_review_fields(normalized_arguments) return self.normalize_review_fields(normalized_arguments)
return normalized_arguments return normalized_arguments

@ -1,5 +1,6 @@
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from app.core.time_utils import utc_now
from time import perf_counter from time import perf_counter
from uuid import uuid4 from uuid import uuid4
@ -88,6 +89,15 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
) )
if reset_override: if reset_override:
return reset_override return reset_override
if hasattr(self, "policy"):
pending_switch_override = self._handle_context_switch(
message=message,
user_id=user_id,
target_domain_hint="general",
turn_decision=None,
)
if pending_switch_override:
return await finish(pending_switch_override)
pending_stock_selection_follow_up = await self._try_handle_pending_stock_selection_follow_up( pending_stock_selection_follow_up = await self._try_handle_pending_stock_selection_follow_up(
message=message, message=message,
user_id=user_id, user_id=user_id,
@ -361,8 +371,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
) )
if llm_result["tool_call"]: if llm_result["tool_call"]:
tool_name = llm_result["tool_call"]["name"] tool_name, arguments = self._normalize_tool_invocation(
arguments = llm_result["tool_call"]["arguments"] tool_name=llm_result["tool_call"]["name"],
arguments=llm_result["tool_call"]["arguments"],
user_id=user_id,
)
try: try:
tool_result = await self._execute_tool_with_trace( tool_result = await self._execute_tool_with_trace(
@ -645,7 +658,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
if not tool_name or tool_name in ORCHESTRATION_CONTROL_TOOLS: if not tool_name or tool_name in ORCHESTRATION_CONTROL_TOOLS:
return None return None
arguments = decision.get("tool_arguments") if isinstance(decision.get("tool_arguments"), dict) else {} tool_name, arguments = self._normalize_tool_invocation(
tool_name=tool_name,
arguments=decision.get("tool_arguments") if isinstance(decision.get("tool_arguments"), dict) else {},
user_id=user_id,
)
try: try:
tool_result = await self._execute_tool_with_trace( tool_result = await self._execute_tool_with_trace(
tool_name, tool_name,
@ -1620,7 +1637,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
payload["data_hora"] = suggested_iso payload["data_hora"] = suggested_iso
self.state.set_entry("pending_review_confirmations", user_id, { self.state.set_entry("pending_review_confirmations", user_id, {
"payload": payload, "payload": payload,
"expires_at": datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_TTL_MINUTES), "expires_at": utc_now() + timedelta(minutes=PENDING_REVIEW_TTL_MINUTES),
}) })
async def _try_confirm_pending_review( async def _try_confirm_pending_review(
@ -1665,7 +1682,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
) )
return self._http_exception_detail(exc) return self._http_exception_detail(exc)
self.state.pop_entry("pending_review_confirmations", user_id) self._reset_pending_review_states(user_id=user_id)
self._store_last_review_package(user_id=user_id, payload=payload) self._store_last_review_package(user_id=user_id, payload=payload)
return self._fallback_format_tool_result("agendar_revisao", tool_result) return self._fallback_format_tool_result("agendar_revisao", tool_result)
@ -1681,7 +1698,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
self.state.pop_entry("pending_review_confirmations", user_id) self.state.pop_entry("pending_review_confirmations", user_id)
return self._http_exception_detail(exc) return self._http_exception_detail(exc)
self.state.pop_entry("pending_review_confirmations", user_id) self._reset_pending_review_states(user_id=user_id)
self._store_last_review_package(user_id=user_id, payload=pending.get("payload")) self._store_last_review_package(user_id=user_id, payload=pending.get("payload"))
return self._fallback_format_tool_result("agendar_revisao", tool_result) return self._fallback_format_tool_result("agendar_revisao", tool_result)
@ -1750,7 +1767,64 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
) )
raise raise
def _merge_pending_draft_tool_arguments(
self,
tool_name: str,
arguments: dict,
user_id: int | None,
) -> dict:
if user_id is None or not isinstance(arguments, dict):
return dict(arguments or {})
if not hasattr(self, "state") or self.state is None:
return dict(arguments)
bucket_map = {
"agendar_revisao": "pending_review_drafts",
"realizar_pedido": "pending_order_drafts",
"cancelar_pedido": "pending_cancel_order_drafts",
"cancelar_agendamento_revisao": "pending_review_management_drafts",
"editar_data_revisao": "pending_review_management_drafts",
}
bucket = bucket_map.get(tool_name)
if not bucket:
return dict(arguments)
draft = self.state.get_entry(bucket, user_id, expire=True)
if not isinstance(draft, dict):
return dict(arguments)
payload = draft.get("payload")
if not isinstance(payload, dict):
return dict(arguments)
merged_arguments = dict(payload)
merged_arguments.update(arguments)
return merged_arguments
def _normalize_tool_invocation(
self,
tool_name: str,
arguments: dict | None,
user_id: int | None,
) -> tuple[str, dict]:
normalizer = getattr(self, "normalizer", None)
if normalizer is None:
normalizer = EntityNormalizer()
self.normalizer = normalizer
normalized_tool_name = normalizer.normalize_tool_name(tool_name) or str(tool_name or "").strip()
normalized_arguments = normalizer.normalize_tool_arguments(normalized_tool_name, arguments or {})
normalized_arguments = self._merge_pending_draft_tool_arguments(
tool_name=normalized_tool_name,
arguments=normalized_arguments,
user_id=user_id,
)
return normalized_tool_name, normalized_arguments
async def _execute_tool_with_trace(self, tool_name: str, arguments: dict, user_id: int | None): async def _execute_tool_with_trace(self, tool_name: str, arguments: dict, user_id: int | None):
tool_name, arguments = self._normalize_tool_invocation(
tool_name=tool_name,
arguments=arguments,
user_id=user_id,
)
started_at = perf_counter() started_at = perf_counter()
try: try:
result = await self.tool_executor.execute(tool_name, arguments, user_id=user_id) result = await self.tool_executor.execute(tool_name, arguments, user_id=user_id)

@ -255,9 +255,9 @@ def normalize_review_datetime_text(value, now_provider=None) -> str | None:
normalized = normalize_text(text) normalized = normalize_text(text)
day_offset = None day_offset = None
if "amanha" in normalized: if "amanha" in normalized or "tomorrow" in normalized:
day_offset = 1 day_offset = 1
elif "hoje" in normalized: elif "hoje" in normalized or "today" in normalized:
day_offset = 0 day_offset = 0
if day_offset is None: if day_offset is None:
return None return None

@ -1,6 +1,7 @@
import os import os
import unittest import unittest
from datetime import datetime, timedelta from datetime import datetime, timedelta
from app.core.time_utils import utc_now
from unittest.mock import patch from unittest.mock import patch
os.environ.setdefault("DEBUG", "false") os.environ.setdefault("DEBUG", "false")
@ -325,7 +326,7 @@ class ConversationAdjustmentsTests(unittest.TestCase):
"pending_cancel_order_drafts": { "pending_cancel_order_drafts": {
7: { 7: {
"payload": {"numero_pedido": "PED-20260305120000-ABC123"}, "payload": {"numero_pedido": "PED-20260305120000-ABC123"},
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
} }
@ -347,7 +348,7 @@ class ConversationAdjustmentsTests(unittest.TestCase):
"km": 30000, "km": 30000,
"revisao_previa_concessionaria": True, "revisao_previa_concessionaria": True,
}, },
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
} }
@ -438,7 +439,7 @@ class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase):
"pending_cancel_order_drafts": { "pending_cancel_order_drafts": {
42: { 42: {
"payload": {"numero_pedido": "PED-20260305120000-ABC123"}, "payload": {"numero_pedido": "PED-20260305120000-ABC123"},
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
} }
@ -468,7 +469,7 @@ class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase):
"pending_cancel_order_drafts": { "pending_cancel_order_drafts": {
42: { 42: {
"payload": {"numero_pedido": "PED-20260305120000-ABC123"}, "payload": {"numero_pedido": "PED-20260305120000-ABC123"},
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
} }
@ -500,7 +501,7 @@ class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase):
"pending_cancel_order_drafts": { "pending_cancel_order_drafts": {
42: { 42: {
"payload": {"numero_pedido": "PED-20260305120000-ABC123"}, "payload": {"numero_pedido": "PED-20260305120000-ABC123"},
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
} }
@ -525,7 +526,7 @@ class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase):
"pending_order_drafts": { "pending_order_drafts": {
42: { 42: {
"payload": {}, "payload": {},
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
} }
@ -557,7 +558,7 @@ class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase):
"flow_snapshots": { "flow_snapshots": {
"order_cancel": { "order_cancel": {
"payload": {"numero_pedido": "PED-20260305120000-ABC123"}, "payload": {"numero_pedido": "PED-20260305120000-ABC123"},
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
}, },
"last_stock_results": [], "last_stock_results": [],
@ -591,7 +592,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
"pending_order_drafts": { "pending_order_drafts": {
10: { 10: {
"payload": {"cpf": "12345678909"}, "payload": {"cpf": "12345678909"},
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
} }
@ -694,7 +695,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
"pending_order_drafts": { "pending_order_drafts": {
10: { 10: {
"payload": {}, "payload": {},
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
}, },
@ -733,7 +734,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
"pending_order_drafts": { "pending_order_drafts": {
10: { 10: {
"payload": {"cpf": "12345678909"}, "payload": {"cpf": "12345678909"},
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
}, },
@ -779,7 +780,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
"modelo_veiculo": "Volkswagen T-Cross 2022", "modelo_veiculo": "Volkswagen T-Cross 2022",
"valor_veiculo": 73224.0, "valor_veiculo": 73224.0,
}, },
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
}, },
@ -926,7 +927,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
"pending_order_drafts": { "pending_order_drafts": {
10: { 10: {
"payload": {"cpf": "12345678909"}, "payload": {"cpf": "12345678909"},
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
}, },
@ -1006,7 +1007,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
"pending_order_drafts": { "pending_order_drafts": {
10: { 10: {
"payload": {"cpf": "12345678909"}, "payload": {"cpf": "12345678909"},
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
}, },
@ -1156,7 +1157,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
{"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0}, {"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0},
{"id": 3, "modelo": "Chevrolet Onix 2022", "categoria": "suv", "preco": 51809.0}, {"id": 3, "modelo": "Chevrolet Onix 2022", "categoria": "suv", "preco": 51809.0},
], ],
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
}, },
@ -1193,7 +1194,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
{"id": 9, "modelo": "Hyundai HB20S 2022", "categoria": "suv", "preco": 76000.0, "budget_relaxed": True}, {"id": 9, "modelo": "Hyundai HB20S 2022", "categoria": "suv", "preco": 76000.0, "budget_relaxed": True},
{"id": 2, "modelo": "Toyota Corolla 2020", "categoria": "suv", "preco": 58476.0, "budget_relaxed": True}, {"id": 2, "modelo": "Toyota Corolla 2020", "categoria": "suv", "preco": 58476.0, "budget_relaxed": True},
], ],
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
}, },
@ -1254,7 +1255,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
"flow_snapshots": { "flow_snapshots": {
"order_create": { "order_create": {
"payload": {"vehicle_id": 2, "modelo_veiculo": "Toyota Corolla 2020"}, "payload": {"vehicle_id": 2, "modelo_veiculo": "Toyota Corolla 2020"},
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
}, },
"last_stock_results": [ "last_stock_results": [
@ -1295,7 +1296,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
"pending_order_drafts": { "pending_order_drafts": {
10: { 10: {
"payload": {"cpf": "12345678909"}, "payload": {"cpf": "12345678909"},
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
}, },
@ -1338,7 +1339,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
"pending_order_drafts": { "pending_order_drafts": {
10: { 10: {
"payload": {"cpf": "12345678909", "vehicle_id": 99}, "payload": {"cpf": "12345678909", "vehicle_id": 99},
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
}, },
@ -1463,7 +1464,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
"pending_review_drafts": { "pending_review_drafts": {
21: { 21: {
"payload": {"placa": "ABC1269"}, "payload": {"placa": "ABC1269"},
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
} }
@ -1486,13 +1487,78 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("o modelo do veiculo", response) self.assertIn("o modelo do veiculo", response)
self.assertTrue(any(payload.get("review_flow_source") == "draft" for _, payload in flow.logged_events)) self.assertTrue(any(payload.get("review_flow_source") == "draft" for _, payload in flow.logged_events))
async def test_review_flow_date_only_with_other_missing_fields_mentions_captured_date_and_requested_time(self):
fixed_now = lambda: datetime(2026, 3, 12, 9, 0)
state = FakeState(
entries={
"pending_review_drafts": {
21: {
"payload": {"placa": "ABC1269"},
"expires_at": utc_now() + timedelta(minutes=30),
}
}
}
)
registry = FakeRegistry()
flow = ReviewFlowHarness(state=state, registry=registry, review_now_provider=fixed_now)
response = await flow._try_collect_and_schedule_review(
message="amanha",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "review_schedule", "domain": "review", "action": "answer_user"},
)
draft = state.get_entry("pending_review_drafts", 21)
self.assertIsNotNone(draft)
self.assertEqual(draft["payload"].get("data_hora_base"), "13/03/2026")
self.assertIn("Perfeito. Tenho a data 13/03/2026.", response)
self.assertIn("- o horario desejado para a revisao", response)
self.assertIn("- o modelo do veiculo", response)
self.assertNotIn("- a data e hora desejada para a revisao", response)
async def test_review_flow_keeps_review_draft_when_time_follow_up_is_misclassified_as_sales(self):
state = FakeState(
entries={
"pending_review_drafts": {
21: {
"payload": {
"placa": "ABC1269",
"modelo": "Onix",
"ano": 2024,
"km": 12000,
"revisao_previa_concessionaria": False,
"data_hora_base": "13/03/2026",
},
"expires_at": utc_now() + timedelta(minutes=30),
}
}
}
)
registry = FakeRegistry()
flow = ReviewFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_schedule_review(
message="16h",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "order_create", "domain": "sales", "action": "answer_user"},
)
self.assertEqual(registry.calls[0][0], "agendar_revisao")
self.assertEqual(registry.calls[0][1]["data_hora"], "13/03/2026 16:00")
self.assertIsNone(state.get_entry("pending_review_drafts", 21))
self.assertIn("REV-TESTE-123", response)
async def test_review_flow_extracts_model_year_km_and_review_history_from_free_text(self): async def test_review_flow_extracts_model_year_km_and_review_history_from_free_text(self):
state = FakeState( state = FakeState(
entries={ entries={
"pending_review_drafts": { "pending_review_drafts": {
21: { 21: {
"payload": {"placa": "ABC1269", "data_hora": "13/03/2026 16:00"}, "payload": {"placa": "ABC1269", "data_hora": "13/03/2026 16:00"},
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
} }
@ -1620,7 +1686,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
"flow_snapshots": { "flow_snapshots": {
"review_schedule": { "review_schedule": {
"payload": {"placa": "A0T1C23", "data_hora": "14/03/2026 18:00"}, "payload": {"placa": "A0T1C23", "data_hora": "14/03/2026 18:00"},
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
}, },
"order_queue": [], "order_queue": [],
@ -1672,7 +1738,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
"km": 30000, "km": 30000,
"revisao_previa_concessionaria": True, "revisao_previa_concessionaria": True,
}, },
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
} }
@ -1708,7 +1774,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
"km": 30000, "km": 30000,
"revisao_previa_concessionaria": True, "revisao_previa_concessionaria": True,
}, },
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
} }
@ -1742,7 +1808,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
"km": 30000, "km": 30000,
"revisao_previa_concessionaria": True, "revisao_previa_concessionaria": True,
}, },
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
} }
@ -1776,7 +1842,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
"km": 20000, "km": 20000,
"revisao_previa_concessionaria": False, "revisao_previa_concessionaria": False,
}, },
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
} }
@ -1830,7 +1896,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
"km": 20000, "km": 20000,
"revisao_previa_concessionaria": False, "revisao_previa_concessionaria": False,
}, },
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
}, },
@ -1870,7 +1936,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
"km": 20000, "km": 20000,
"revisao_previa_concessionaria": False, "revisao_previa_concessionaria": False,
}, },
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
} }
@ -1901,7 +1967,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
"km": 50000, "km": 50000,
"revisao_previa_concessionaria": False, "revisao_previa_concessionaria": False,
}, },
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
} }
@ -1947,7 +2013,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
"km": 50000, "km": 50000,
"revisao_previa_concessionaria": False, "revisao_previa_concessionaria": False,
}, },
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
} }
@ -1993,7 +2059,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
"km": 50000, "km": 50000,
"revisao_previa_concessionaria": False, "revisao_previa_concessionaria": False,
}, },
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
} }
@ -2050,7 +2116,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
"km": 30000, "km": 30000,
"revisao_previa_concessionaria": True, "revisao_previa_concessionaria": True,
}, },
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
} }
@ -2083,7 +2149,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
"km": 15000, "km": 15000,
"revisao_previa_concessionaria": True, "revisao_previa_concessionaria": True,
}, },
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
} }
@ -2156,7 +2222,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
21: { 21: {
"action": "reschedule", "action": "reschedule",
"payload": {"protocolo": "REV-20260313-F754AF27"}, "payload": {"protocolo": "REV-20260313-F754AF27"},
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
} }
@ -2187,7 +2253,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
"km": 30000, "km": 30000,
"revisao_previa_concessionaria": True, "revisao_previa_concessionaria": True,
}, },
"expires_at": datetime.utcnow() + timedelta(minutes=30), "expires_at": utc_now() + timedelta(minutes=30),
} }
} }
} }
@ -2278,7 +2344,7 @@ class ContextSwitchPolicyTests(unittest.TestCase):
"km": 30000, "km": 30000,
"revisao_previa_concessionaria": False, "revisao_previa_concessionaria": False,
}, },
"expires_at": datetime.utcnow() + timedelta(minutes=15), "expires_at": utc_now() + timedelta(minutes=15),
} }
} }
}, },
@ -2316,7 +2382,7 @@ class ContextSwitchPolicyTests(unittest.TestCase):
"km": 30000, "km": 30000,
"revisao_previa_concessionaria": False, "revisao_previa_concessionaria": False,
}, },
"expires_at": datetime.utcnow() + timedelta(minutes=15), "expires_at": utc_now() + timedelta(minutes=15),
} }
} }
}, },
@ -2364,7 +2430,7 @@ class ContextSwitchPolicyTests(unittest.TestCase):
9: { 9: {
"pending_switch": { "pending_switch": {
"target_domain": "sales", "target_domain": "sales",
"expires_at": datetime.utcnow() + timedelta(minutes=15), "expires_at": utc_now() + timedelta(minutes=15),
}, },
"active_domain": "general", "active_domain": "general",
"generic_memory": {}, "generic_memory": {},

@ -4,6 +4,7 @@ import unittest
os.environ.setdefault("DEBUG", "false") os.environ.setdefault("DEBUG", "false")
from datetime import datetime, timedelta from datetime import datetime, timedelta
from app.core.time_utils import utc_now
from app.services.orchestration.conversation_policy import ConversationPolicy from app.services.orchestration.conversation_policy import ConversationPolicy
from app.services.orchestration.entity_normalizer import EntityNormalizer from app.services.orchestration.entity_normalizer import EntityNormalizer
@ -174,6 +175,53 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(decision["entities"]["review_fields"]["data_hora"], "10/03/2026 às 09:00") self.assertEqual(decision["entities"]["review_fields"]["data_hora"], "10/03/2026 às 09:00")
self.assertEqual(decision["missing_fields"], ["modelo", "ano", "km"]) self.assertEqual(decision["missing_fields"], ["modelo", "ano", "km"])
def test_parse_json_object_accepts_python_style_dict_with_trailing_commas(self):
normalizer = EntityNormalizer()
payload = normalizer.parse_json_object(
"""
```json
{
'intent': 'review_schedule',
'domain': 'review',
'action': 'answer_user',
}
```
"""
)
self.assertEqual(payload["intent"], "review_schedule")
self.assertEqual(payload["domain"], "review")
self.assertEqual(payload["action"], "answer_user")
def test_coerce_turn_decision_maps_top_level_aliases_and_embedded_intents(self):
normalizer = EntityNormalizer()
decision = normalizer.coerce_turn_decision(
{
"domain": "service",
"action": "answer",
"response": "Certo, vou seguir com a revisao.",
"selected_index": "2",
"entities": {
"generic_memory": {"cpf": "12345678909"},
"review_fields": {"placa": "abc1234"},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {},
"intents": {"review_schedule": True},
},
}
)
self.assertEqual(decision["intent"], "review_schedule")
self.assertEqual(decision["domain"], "review")
self.assertEqual(decision["action"], "answer_user")
self.assertEqual(decision["response_to_user"], "Certo, vou seguir com a revisao.")
self.assertEqual(decision["selection_index"], 2)
self.assertEqual(decision["entities"]["review_fields"]["placa"], "ABC1234")
self.assertEqual(decision["entities"]["generic_memory"]["cpf"], "12345678909")
def test_coerce_turn_decision_rejects_invalid_shape_with_fallback(self): def test_coerce_turn_decision_rejects_invalid_shape_with_fallback(self):
normalizer = EntityNormalizer() normalizer = EntityNormalizer()
@ -337,6 +385,44 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(decision["tool_name"], "agendar_revisao") self.assertEqual(decision["tool_name"], "agendar_revisao")
self.assertEqual(decision["tool_arguments"]["placa"], "ABC1234") self.assertEqual(decision["tool_arguments"]["placa"], "ABC1234")
def test_coerce_turn_decision_normalizes_legacy_review_vehicle_tool_alias(self):
normalizer = EntityNormalizer()
decision = normalizer.coerce_turn_decision(
{
"intent": "review_schedule",
"domain": "review",
"action": "call_tool",
"tool_name": "agendar_revisao_veiculo",
"tool_arguments": {
"placa_veiculo": "ABC1234",
"data_agendamento": "tomorrow",
"horario_agendamento": "14:00",
"modelo_veiculo": "Onix",
"ano_veiculo": 2024,
"quilometragem": 12000,
"revisao_previa": False,
},
"entities": {
"generic_memory": {},
"review_fields": {},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {},
},
"missing_fields": [],
"response_to_user": None,
}
)
self.assertEqual(decision["tool_name"], "agendar_revisao")
self.assertEqual(decision["tool_arguments"]["placa"], "ABC1234")
self.assertEqual(decision["tool_arguments"]["modelo"], "Onix")
self.assertEqual(decision["tool_arguments"]["ano"], 2024)
self.assertEqual(decision["tool_arguments"]["km"], 12000)
self.assertFalse(decision["tool_arguments"]["revisao_previa_concessionaria"])
self.assertTrue(decision["tool_arguments"]["data_hora"].endswith("14:00"))
def test_coerce_turn_decision_normalizes_review_schedule_tool_argument_aliases(self): def test_coerce_turn_decision_normalizes_review_schedule_tool_argument_aliases(self):
normalizer = EntityNormalizer() normalizer = EntityNormalizer()
@ -525,7 +611,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(decision["tool_arguments"], {}) self.assertEqual(decision["tool_arguments"], {})
self.assertEqual(decision["entities"]["cancel_order_fields"]["numero_pedido"], "PED-20260310124202-5EF4E9") self.assertEqual(decision["entities"]["cancel_order_fields"]["numero_pedido"], "PED-20260310124202-5EF4E9")
def test_coerce_turn_decision_rejects_missing_fields_without_response_payload(self): def test_coerce_turn_decision_downgrades_missing_response_ask_missing_fields_to_collection(self):
normalizer = EntityNormalizer() normalizer = EntityNormalizer()
decision = normalizer.coerce_turn_decision( decision = normalizer.coerce_turn_decision(
@ -535,20 +621,51 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
"action": "ask_missing_fields", "action": "ask_missing_fields",
"entities": { "entities": {
"generic_memory": {}, "generic_memory": {},
"review_fields": {}, "review_fields": {"placa": "ABC1234"},
"review_management_fields": {}, "review_management_fields": {},
"order_fields": {}, "order_fields": {},
"cancel_order_fields": {}, "cancel_order_fields": {},
}, },
"missing_fields": [], "missing_fields": ["data e hora", "modelo"],
"tool_name": None, "tool_name": None,
"tool_arguments": {}, "tool_arguments": {},
"response_to_user": "", "response_to_user": "",
} }
) )
self.assertEqual(decision["intent"], "general") self.assertEqual(decision["intent"], "review_schedule")
self.assertEqual(decision["action"], "answer_user") self.assertEqual(decision["action"], "collect_review_schedule")
self.assertEqual(decision["missing_fields"], ["data_hora", "modelo"])
self.assertEqual(decision["entities"]["review_fields"]["placa"], "ABC1234")
def test_coerce_turn_decision_downgrades_call_tool_without_tool_name_to_cancel_order_collection(self):
normalizer = EntityNormalizer()
decision = normalizer.coerce_turn_decision(
{
"intent": "order_cancel",
"domain": "sales",
"action": "call_tool",
"arguments": {
"order_id": "PED-20260310124202-5EF4E9",
"reason": "desisti da compra",
},
"entities": {
"generic_memory": {},
"review_fields": {},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {},
},
}
)
self.assertEqual(decision["intent"], "order_cancel")
self.assertEqual(decision["action"], "collect_order_cancel")
self.assertIsNone(decision["tool_name"])
self.assertEqual(decision["tool_arguments"], {})
self.assertEqual(decision["entities"]["cancel_order_fields"]["numero_pedido"], "PED-20260310124202-5EF4E9")
self.assertEqual(decision["entities"]["cancel_order_fields"]["motivo"], "desisti da compra")
def test_turn_decision_entities_do_not_rebuild_legacy_intents(self): def test_turn_decision_entities_do_not_rebuild_legacy_intents(self):
service = OrquestradorService.__new__(OrquestradorService) service = OrquestradorService.__new__(OrquestradorService)
@ -644,6 +761,60 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(cached["payload"]["modelo"], "Civic") self.assertEqual(cached["payload"]["modelo"], "Civic")
self.assertTrue(cached["payload"]["revisao_previa_concessionaria"]) self.assertTrue(cached["payload"]["revisao_previa_concessionaria"])
async def test_execute_tool_with_trace_normalizes_direct_review_tool_alias_and_merges_open_draft(self):
state = FakeState(
entries={
"pending_review_drafts": {
7: {
"payload": {
"placa": "ABC1463",
"modelo": "Civic",
"ano": 2024,
"km": 30000,
"revisao_previa_concessionaria": False,
},
"expires_at": utc_now() + timedelta(minutes=15),
}
}
},
contexts={
7: {
"active_domain": "review",
"generic_memory": {},
"shared_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
}
}
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service.tool_executor = FakeToolExecutor(result={"protocolo": "REV-TESTE-123"})
service._log_turn_event = lambda *args, **kwargs: None
result = await service._execute_tool_with_trace(
tool_name="agendar_revisao_veiculo",
arguments={
"data_agendamento": "tomorrow",
"horario_agendamento": "14:00",
},
user_id=7,
)
self.assertEqual(result["protocolo"], "REV-TESTE-123")
self.assertEqual(service.tool_executor.calls[0][0], "agendar_revisao")
self.assertEqual(service.tool_executor.calls[0][2], 7)
self.assertEqual(service.tool_executor.calls[0][1]["placa"], "ABC1463")
self.assertEqual(service.tool_executor.calls[0][1]["modelo"], "Civic")
self.assertEqual(service.tool_executor.calls[0][1]["ano"], 2024)
self.assertEqual(service.tool_executor.calls[0][1]["km"], 30000)
self.assertFalse(service.tool_executor.calls[0][1]["revisao_previa_concessionaria"])
self.assertTrue(service.tool_executor.calls[0][1]["data_hora"].endswith("14:00"))
def test_capture_tool_result_context_stores_pending_stock_selection_entry(self): def test_capture_tool_result_context_stores_pending_stock_selection_entry(self):
state = FakeState( state = FakeState(
contexts={ contexts={
@ -805,6 +976,106 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response, "Pedido PED-1 atualizado.\nStatus: Cancelado") self.assertEqual(response, "Pedido PED-1 atualizado.\nStatus: Cancelado")
self.assertEqual(service.llm.calls, 0) self.assertEqual(service.llm.calls, 0)
async def test_confirm_pending_review_clears_open_review_draft_after_suggested_time_success(self):
state = FakeState(
entries={
"pending_review_drafts": {
7: {
"payload": {
"placa": "ABC1C23",
"modelo": "Onix",
"ano": 2024,
"km": 20000,
"revisao_previa_concessionaria": False,
},
"expires_at": utc_now() + timedelta(minutes=15),
}
},
"pending_review_confirmations": {
7: {
"payload": {
"placa": "ABC1C23",
"data_hora": "14/03/2026 16:30",
"modelo": "Onix",
"ano": 2024,
"km": 20000,
"revisao_previa_concessionaria": False,
},
"expires_at": utc_now() + timedelta(minutes=15),
}
},
},
contexts={
7: {
"active_domain": "review",
"active_task": "review_schedule",
"generic_memory": {"placa": "ABC1C23"},
"shared_memory": {"placa": "ABC1C23"},
"collected_slots": {
"review_schedule": {
"placa": "ABC1C23",
"modelo": "Onix",
"ano": 2024,
}
},
"flow_snapshots": {
"review_schedule": {
"payload": {
"placa": "ABC1C23",
"modelo": "Onix",
"ano": 2024,
},
"expires_at": utc_now() + timedelta(minutes=15),
},
"review_confirmation": {
"payload": {
"placa": "ABC1C23",
"data_hora": "14/03/2026 16:30",
},
"expires_at": utc_now() + timedelta(minutes=15),
},
},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
}
},
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service.tool_executor = FakeToolExecutor(
result={
"protocolo": "REV-TESTE-999",
"placa": "ABC1C23",
"data_hora": "14/03/2026 16:30",
"valor_revisao": 728.0,
}
)
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)
service._http_exception_detail = lambda exc: str(exc)
service._fallback_format_tool_result = lambda tool_name, tool_result: (
f"Revisao agendada com sucesso.\nProtocolo: {tool_result['protocolo']}"
)
response = await service._try_confirm_pending_review(
message="sim",
user_id=7,
extracted_review_fields={},
)
self.assertIn("REV-TESTE-999", response)
self.assertIsNone(state.get_entry("pending_review_confirmations", 7))
self.assertIsNone(state.get_entry("pending_review_drafts", 7))
self.assertIsNotNone(state.get_entry("last_review_packages", 7))
context = state.get_user_context(7)
self.assertEqual(context["active_task"], None)
self.assertEqual(context["collected_slots"], {})
self.assertEqual(context["flow_snapshots"], {})
async def test_empty_stock_search_suggests_nearby_options(self): async def test_empty_stock_search_suggests_nearby_options(self):
service = OrquestradorService.__new__(OrquestradorService) service = OrquestradorService.__new__(OrquestradorService)
service.normalizer = EntityNormalizer() service.normalizer = EntityNormalizer()
@ -982,7 +1253,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
"pending_review_drafts": { "pending_review_drafts": {
1: { 1: {
"payload": {"placa": "ABC1269"}, "payload": {"placa": "ABC1269"},
"expires_at": datetime.utcnow() + timedelta(minutes=15), "expires_at": utc_now() + timedelta(minutes=15),
} }
} }
} }
@ -1018,7 +1289,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
"pending_switch": None, "pending_switch": None,
"last_stock_results": [], "last_stock_results": [],
"selected_vehicle": None, "selected_vehicle": None,
"expires_at": datetime.utcnow() + timedelta(minutes=15), "expires_at": utc_now() + timedelta(minutes=15),
} }
} }
) )
@ -1047,7 +1318,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
"pending_review_drafts": { "pending_review_drafts": {
1: { 1: {
"payload": {"placa": "ABC1269"}, "payload": {"placa": "ABC1269"},
"expires_at": datetime.utcnow() + timedelta(minutes=15), "expires_at": utc_now() + timedelta(minutes=15),
} }
} }
}, },
@ -1354,7 +1625,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
{"id": 15, "modelo": "Volkswagen T-Cross 2022", "categoria": "suv", "preco": 73224.0}, {"id": 15, "modelo": "Volkswagen T-Cross 2022", "categoria": "suv", "preco": 73224.0},
{"id": 11, "modelo": "Toyota Corolla 2024", "categoria": "suv", "preco": 76087.0}, {"id": 11, "modelo": "Toyota Corolla 2024", "categoria": "suv", "preco": 76087.0},
], ],
"expires_at": datetime.utcnow() + timedelta(minutes=15), "expires_at": utc_now() + timedelta(minutes=15),
} }
} }
}, },
@ -1425,7 +1696,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
"pending_order_drafts": { "pending_order_drafts": {
1: { 1: {
"payload": {"vehicle_id": 15, "modelo_veiculo": "Volkswagen T-Cross 2022", "valor_veiculo": 73224.0}, "payload": {"vehicle_id": 15, "modelo_veiculo": "Volkswagen T-Cross 2022", "valor_veiculo": 73224.0},
"expires_at": datetime.utcnow() + timedelta(minutes=15), "expires_at": utc_now() + timedelta(minutes=15),
} }
} }
}, },
@ -1561,6 +1832,78 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response, "Pedido criado com sucesso.") self.assertEqual(response, "Pedido criado com sucesso.")
async def test_handle_message_prioritizes_pending_switch_confirmation_before_sales_follow_up(self):
state = FakeState(
entries={
"pending_stock_selections": {
1: {
"payload": [
{"id": 15, "modelo": "Volkswagen T-Cross 2022", "categoria": "suv", "preco": 73224.0},
],
"expires_at": utc_now() + timedelta(minutes=15),
}
}
},
contexts={
1: {
"active_domain": "sales",
"generic_memory": {},
"shared_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": {
"source_domain": "sales",
"target_domain": "review",
"expires_at": utc_now() + timedelta(minutes=15),
},
"last_stock_results": [
{"id": 15, "modelo": "Volkswagen T-Cross 2022", "categoria": "suv", "preco": 73224.0},
],
"selected_vehicle": {"id": 15, "modelo": "Volkswagen T-Cross 2022", "categoria": "suv", "preco": 73224.0},
}
}
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service.policy = ConversationPolicy(service=service)
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._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
service._is_order_selection_reset_message = lambda message: False
async def fake_try_handle_pending_stock_selection_follow_up(**kwargs):
raise AssertionError("nao deveria entrar no follow-up de estoque antes de confirmar a troca de contexto")
service._try_handle_pending_stock_selection_follow_up = fake_try_handle_pending_stock_selection_follow_up
async def fake_try_handle_active_sales_follow_up(**kwargs):
raise AssertionError("nao deveria entrar no follow-up de vendas antes de confirmar a troca de contexto")
service._try_handle_active_sales_follow_up = fake_try_handle_active_sales_follow_up
response = await service.handle_message(
"sim",
user_id=1,
)
self.assertEqual(
response,
"Certo, contexto anterior encerrado. Vamos seguir com agendamento de revisao.\n"
"Pode me informar a placa ou, se preferir, ja mandar placa, data/hora, modelo, ano, km e se ja fez revisao.",
)
self.assertEqual(state.get_user_context(1)["active_domain"], "review")
self.assertIsNone(state.get_user_context(1).get("pending_switch"))
self.assertIsNone(state.get_entry("pending_stock_selections", 1))
async def test_handle_message_prioritizes_immediate_reset_before_active_sales_follow_up(self): async def test_handle_message_prioritizes_immediate_reset_before_active_sales_follow_up(self):
state = FakeState( state = FakeState(
contexts={ contexts={
@ -1665,7 +2008,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
"modelo_veiculo": "Volkswagen T-Cross 2022", "modelo_veiculo": "Volkswagen T-Cross 2022",
"valor_veiculo": 73224.0, "valor_veiculo": 73224.0,
}, },
"expires_at": datetime.utcnow() + timedelta(minutes=15), "expires_at": utc_now() + timedelta(minutes=15),
} }
} }
}, },
@ -1772,7 +2115,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
"pending_cancel_order_drafts": { "pending_cancel_order_drafts": {
1: { 1: {
"payload": {"numero_pedido": "PED-202603101204814-6ED33A"}, "payload": {"numero_pedido": "PED-202603101204814-6ED33A"},
"expires_at": datetime.utcnow() + timedelta(minutes=15), "expires_at": utc_now() + timedelta(minutes=15),
} }
} }
} }
@ -1841,7 +2184,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
{"domain": "review", "message": "agendar revisao", "memory_seed": {}}, {"domain": "review", "message": "agendar revisao", "memory_seed": {}},
{"domain": "sales", "message": "fazer pedido", "memory_seed": {}}, {"domain": "sales", "message": "fazer pedido", "memory_seed": {}},
], ],
"expires_at": datetime.utcnow() + timedelta(minutes=15), "expires_at": utc_now() + timedelta(minutes=15),
}, },
"order_queue": [], "order_queue": [],
"active_domain": "general", "active_domain": "general",
@ -1868,7 +2211,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
{"domain": "review", "message": "agendar revisao", "memory_seed": {}}, {"domain": "review", "message": "agendar revisao", "memory_seed": {}},
{"domain": "sales", "message": "fazer pedido", "memory_seed": {}}, {"domain": "sales", "message": "fazer pedido", "memory_seed": {}},
], ],
"expires_at": datetime.utcnow() + timedelta(minutes=15), "expires_at": utc_now() + timedelta(minutes=15),
}, },
"order_queue": [], "order_queue": [],
"active_domain": "general", "active_domain": "general",
@ -1894,7 +2237,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
"target_domain": "sales", "target_domain": "sales",
"queued_message": "fazer pedido", "queued_message": "fazer pedido",
"memory_seed": {"cpf": "12345678909"}, "memory_seed": {"cpf": "12345678909"},
"expires_at": datetime.utcnow() + timedelta(minutes=15), "expires_at": utc_now() + timedelta(minutes=15),
}, },
"active_domain": "general", "active_domain": "general",
"generic_memory": {}, "generic_memory": {},
@ -1922,7 +2265,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
9: { 9: {
"pending_switch": { "pending_switch": {
"target_domain": "review", "target_domain": "review",
"expires_at": datetime.utcnow() + timedelta(minutes=15), "expires_at": utc_now() + timedelta(minutes=15),
}, },
"active_domain": "sales", "active_domain": "sales",
"generic_memory": {}, "generic_memory": {},
@ -1955,7 +2298,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
"pending_review_drafts": { "pending_review_drafts": {
9: { 9: {
"payload": {"placa": "ABC1234"}, "payload": {"placa": "ABC1234"},
"expires_at": datetime.utcnow() + timedelta(minutes=15), "expires_at": utc_now() + timedelta(minutes=15),
} }
} }
}, },

Loading…
Cancel
Save