🛡️ fix(orchestration): corrigir fluxos transacionais e acelerar atalhos determinísticos

chore/observability-latency-markers
parent 9a97345f62
commit 146475a278

@ -182,7 +182,7 @@ class OrderFlowMixin:
return
cpf = extract_cpf_from_text(message)
if cpf and self._is_valid_cpf(cpf):
payload["cpf"] = cpf
self._set_order_cpf(payload=payload, cpf=cpf, confirmed=True)
def _try_capture_order_budget_from_message(self, user_id: int | None, message: str, payload: dict) -> None:
if not self._has_explicit_order_request(message) and self.state.get_entry("pending_order_drafts", user_id, expire=True) is None:
@ -211,7 +211,7 @@ class OrderFlowMixin:
memory = context.get("generic_memory", {})
cpf = memory.get("cpf")
if isinstance(cpf, str) and self._is_valid_cpf(cpf):
payload["cpf"] = cpf
self._set_order_cpf(payload=payload, cpf=cpf, confirmed=False, source="memory")
def _try_prefill_order_cpf_from_user_profile(self, user_id: int | None, payload: dict) -> None:
if user_id is None or payload.get("cpf"):
@ -221,10 +221,134 @@ class OrderFlowMixin:
try:
user = db.query(User).filter(User.id == user_id).first()
if user and isinstance(user.cpf, str) and self._is_valid_cpf(user.cpf):
payload["cpf"] = user.cpf
self._set_order_cpf(payload=payload, cpf=user.cpf, confirmed=False, source="user_profile")
finally:
db.close()
def _set_order_cpf(
self,
payload: dict,
cpf: str,
*,
confirmed: bool,
source: str | None = None,
) -> None:
if not isinstance(payload, dict):
return
payload["cpf"] = str(cpf)
payload["cpf_confirmed"] = bool(confirmed)
if confirmed:
payload.pop("cpf_confirmation_source", None)
return
if source:
payload["cpf_confirmation_source"] = str(source)
def _clear_order_cpf_confirmation_state(self, payload: dict) -> None:
if not isinstance(payload, dict):
return
payload.pop("cpf_confirmed", None)
payload.pop("cpf_confirmation_source", None)
def _mask_order_cpf(self, cpf: str) -> str:
digits = re.sub(r"\D", "", str(cpf or ""))
if len(digits) != 11:
return str(cpf or "").strip()
return f"{digits[:3]}.***.***-{digits[-2:]}"
def _render_known_order_cpf_confirmation_prompt(self, cpf: str) -> str:
return (
f"Encontrei um CPF informado anteriormente: {self._mask_order_cpf(cpf)}.\n"
"Ele continua correto para concluir o pedido? Responda com sim ou nao."
)
def _has_order_vehicle_search_criteria(self, message: str, payload: dict | None = None) -> bool:
if extract_budget_from_text(message) is not None:
return True
normalized_payload = payload if isinstance(payload, dict) else {}
if normalized_payload.get("vehicle_id") or normalized_payload.get("modelo_veiculo"):
return True
normalized_message = self._normalize_text(message).strip()
category_terms = {
"suv",
"sedan",
"hatch",
"pickup",
"picape",
"utilitario",
"utilitario esportivo",
}
if any(term in normalized_message for term in category_terms):
return True
return bool(self._extract_vehicle_reference_tokens(message))
def _clear_order_search_memory(self, user_id: int | None) -> None:
if user_id is None:
return
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return
generic_memory = context.get("generic_memory")
if isinstance(generic_memory, dict):
generic_memory.pop("orcamento_max", None)
generic_memory.pop("perfil_veiculo", None)
shared_memory = context.get("shared_memory")
if isinstance(shared_memory, dict):
shared_memory.pop("orcamento_max", None)
shared_memory.pop("perfil_veiculo", None)
self._save_user_context(user_id=user_id, context=context)
def _remember_order_cpf_in_context(self, user_id: int | None, cpf: str) -> None:
if user_id is None or not self._is_valid_cpf(cpf):
return
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return
generic_memory = context.get("generic_memory")
if not isinstance(generic_memory, dict):
generic_memory = {}
context["generic_memory"] = generic_memory
generic_memory["cpf"] = str(cpf)
context.setdefault("shared_memory", {})["cpf"] = str(cpf)
self._save_user_context(user_id=user_id, context=context)
def _try_handle_known_order_cpf_confirmation(
self,
message: str,
payload: dict,
) -> str | None:
if not isinstance(payload, dict):
return None
cpf_value = payload.get("cpf")
if not cpf_value or payload.get("cpf_confirmed", True) is not False:
return None
if not payload.get("vehicle_id"):
return None
cpf_attempt = self._extract_order_cpf_attempt(message)
if cpf_attempt and not self._is_valid_cpf(cpf_attempt):
return "Para seguir com o pedido, preciso de um CPF valido. Pode me informar novamente?"
cpf_from_message = extract_cpf_from_text(message)
if cpf_from_message and self._is_valid_cpf(cpf_from_message):
self._set_order_cpf(payload=payload, cpf=cpf_from_message, confirmed=True)
return None
if self._is_affirmative_message(message):
payload["cpf_confirmed"] = True
payload.pop("cpf_confirmation_source", None)
return None
if self._is_negative_message(message):
payload.pop("cpf", None)
self._clear_order_cpf_confirmation_state(payload)
return "Sem problema. Me informe o CPF correto para eu concluir o pedido."
return self._render_known_order_cpf_confirmation_prompt(str(cpf_value))
def _get_last_stock_results(self, user_id: int | None) -> list[dict]:
return self._order_flow_state_support.get_last_stock_results(user_id=user_id)
@ -629,10 +753,7 @@ class OrderFlowMixin:
def _render_missing_order_fields_prompt(self, missing_fields: list[str]) -> str:
if missing_fields == ["vehicle_id"]:
return (
"Para seguir com o pedido, me diga qual carro voce procura.\n"
"Se preferir, posso listar opcoes por faixa de preco, modelo ou tipo de carro."
)
return "Pode me dizer a faixa de preco, o modelo ou o tipo de carro que voce procura."
labels = {
"cpf": "o CPF do cliente",
"vehicle_id": "qual veiculo do estoque voce quer comprar",
@ -815,6 +936,24 @@ class OrderFlowMixin:
"expires_at": utc_now() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES),
}
if (
draft.get("payload") == {}
and explicit_order_request
and not should_bootstrap_from_context
and not self._has_order_vehicle_search_criteria(message=message, payload=extracted)
):
self._reset_order_stock_context(user_id=user_id)
self._clear_order_search_memory(user_id=user_id)
draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES)
self._set_order_flow_entry(
"pending_order_drafts",
user_id,
"order_create",
draft,
active_task="order_create",
)
return self._render_missing_order_fields_prompt(["vehicle_id"])
draft["payload"].update(extracted)
self._try_capture_order_cpf_from_message(message=message, payload=draft["payload"])
cpf_attempt = self._extract_order_cpf_attempt(message)
@ -877,9 +1016,25 @@ class OrderFlowMixin:
self._store_selected_vehicle(user_id=user_id, vehicle=resolved_vehicle)
draft["payload"].update(self._vehicle_to_payload(resolved_vehicle))
cpf_confirmation_response = self._try_handle_known_order_cpf_confirmation(
message=message,
payload=draft["payload"],
)
if cpf_confirmation_response:
draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES)
self._set_order_flow_entry(
"pending_order_drafts",
user_id,
"order_create",
draft,
active_task="order_create",
)
return cpf_confirmation_response
cpf_value = draft["payload"].get("cpf")
if cpf_value and not self._is_valid_cpf(str(cpf_value)):
draft["payload"].pop("cpf", None)
self._clear_order_cpf_confirmation_state(draft["payload"])
self._set_order_flow_entry(
"pending_order_drafts",
user_id,
@ -894,8 +1049,10 @@ class OrderFlowMixin:
cpf=str(cpf_value),
user_id=user_id,
)
self._remember_order_cpf_in_context(user_id=user_id, cpf=str(cpf_value))
except ValueError as exc:
draft["payload"].pop("cpf", None)
self._clear_order_cpf_confirmation_state(draft["payload"])
self._set_order_flow_entry(
"pending_order_drafts",
user_id,

@ -21,6 +21,24 @@ class RentalFlowMixin:
setattr(self, "__rental_flow_state_support", support)
return support
def _rental_now(self) -> datetime:
provider = getattr(self, "_rental_now_provider", None)
if callable(provider):
return provider()
return datetime.now()
# Corrige variacoes corrompidas comuns de datas relativas vindas de canais externos.
def _normalize_rental_relative_text(self, text: str) -> str:
normalized = technical_normalizer.normalize_text(text)
replacements = (
(r"depois\s+de\s+amanh\?", "depois de amanha"),
(r"amanh\?", "amanha"),
(r"hoj\?", "hoje"),
(r"\bat\?\b", "ate"),
)
for pattern, replacement in replacements:
normalized = re.sub(pattern, replacement, normalized)
return normalized
# Sanitiza resultados da frota antes de guardar no contexto.
def _sanitize_rental_results(self, rental_results: list[dict] | None) -> list[dict]:
return self._rental_flow_state_support.sanitize_rental_results(rental_results)
@ -37,12 +55,50 @@ class RentalFlowMixin:
return self._rental_flow_state_support.get_last_rental_results(user_id=user_id)
# Guarda a lista atual para permitir selecao do veiculo em mensagens seguintes.
def _store_pending_rental_selection(self, user_id: int | None, rental_results: list[dict] | None) -> None:
def _store_pending_rental_selection(
self,
user_id: int | None,
rental_results: list[dict] | None,
search_payload: dict | None = None,
) -> None:
self._rental_flow_state_support.store_pending_rental_selection(
user_id=user_id,
rental_results=rental_results,
search_payload=search_payload,
)
# Recupera o ultimo snapshot de busca de locacao salvo no contexto.
def _get_last_rental_search_payload(self, user_id: int | None) -> dict:
if user_id is None:
return {}
pending_selection = self.state.get_entry("pending_rental_selections", user_id, expire=True)
if isinstance(pending_selection, dict):
pending_search_payload = self._sanitize_rental_search_payload(pending_selection.get("search_payload"))
if pending_search_payload:
return pending_search_payload
if not hasattr(self, "_get_user_context"):
return {}
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return {}
return self._sanitize_rental_search_payload(context.get("last_rental_search_payload"))
# Persiste no contexto os campos reutilizaveis da busca de locacao.
def _store_last_rental_search_payload(self, user_id: int | None, payload) -> None:
if user_id is None or not hasattr(self, "_get_user_context") or not hasattr(self, "_save_user_context"):
return
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return
sanitized = self._sanitize_rental_search_payload(payload)
if sanitized:
context["last_rental_search_payload"] = sanitized
else:
context.pop("last_rental_search_payload", None)
self._save_user_context(user_id=user_id, context=context)
# Le o veiculo de locacao escolhido que ficou salvo no contexto.
def _get_selected_rental_vehicle(self, user_id: int | None) -> dict | None:
return self._rental_flow_state_support.get_selected_rental_vehicle(user_id=user_id)
@ -51,6 +107,39 @@ class RentalFlowMixin:
def _sanitize_rental_contract_snapshot(self, payload) -> dict | None:
return self._rental_flow_state_support.sanitize_rental_contract_snapshot(payload)
# Filtra apenas os campos da busca que podem ser reaproveitados antes da escolha do veiculo.
def _sanitize_rental_search_payload(self, payload) -> dict:
if not isinstance(payload, dict):
return {}
sanitized: dict = {}
category = self._extract_rental_category_from_text(str(payload.get("categoria") or ""))
if category:
sanitized["categoria"] = category
plate = technical_normalizer.normalize_plate(payload.get("placa"))
if plate:
sanitized["placa"] = plate
cpf = technical_normalizer.normalize_cpf(payload.get("cpf"))
if cpf:
sanitized["cpf"] = cpf
model_hint = str(payload.get("modelo") or "").strip(" ,.;")
if model_hint and not self._extract_rental_category_from_text(model_hint):
sanitized["modelo"] = model_hint.title()
budget = technical_normalizer.normalize_positive_number(payload.get("valor_diaria_max"))
if budget is not None:
sanitized["valor_diaria_max"] = float(budget)
for field_name in ("data_inicio", "data_fim_prevista"):
normalized = self._normalize_rental_datetime_text(payload.get(field_name))
if normalized:
sanitized[field_name] = normalized
return sanitized
# Recupera o ultimo contrato de locacao lembrado para o usuario.
def _get_last_rental_contract(self, user_id: int | None) -> dict | None:
return self._rental_flow_state_support.get_last_rental_contract(user_id=user_id)
@ -97,7 +186,7 @@ class RentalFlowMixin:
# Extrai um modelo ou marca/modelo quando o pedido for mais especifico.
def _extract_rental_model_from_text(self, text: str) -> str | None:
normalized = self._normalize_text(text).strip()
normalized = self._normalize_rental_relative_text(text).strip()
if not normalized:
return None
@ -105,6 +194,13 @@ class RentalFlowMixin:
normalized = re.sub(r"\b\d{4}[/-]\d{1,2}[/-]\d{1,2}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\b", " ", normalized)
normalized = re.sub(r"\b[a-z]{3}\d[a-z0-9]\d{2}\b", " ", normalized)
normalized = re.sub(r"\br\$\s*\d+[\d\.,]*\b", " ", normalized)
normalized = re.sub(
r"\b(?:depois\s+de\s+amanh(?:a)?|day\s+after\s+tomorrow|amanh(?:a)?|tomorrow|hoj(?:e)?|today)"
r"(?:\s+(?:as|a))?"
r"(?:\s+(?:\d{1,2}:\d{2}(?::\d{2})?|\d{1,2}\s*(?:h|hora|horas)))?\b",
" ",
normalized,
)
category = self._extract_rental_category_from_text(normalized)
if category:
@ -153,6 +249,8 @@ class RentalFlowMixin:
"barata",
"economico",
"economica",
"ate",
"at",
}
generic_tokens = {
"aluguel",
@ -200,6 +298,8 @@ class RentalFlowMixin:
continue
if re.fullmatch(r"(?:19|20)\d{2}", token):
continue
if re.fullmatch(r"\d{1,2}h", token):
continue
if len(token) < 2:
continue
tokens.append(token)
@ -213,19 +313,38 @@ class RentalFlowMixin:
# Coleta datas de locacao em texto livre mantendo a ordem encontrada.
def _extract_rental_datetimes_from_text(self, text: str) -> list[str]:
normalized = technical_normalizer.normalize_datetime_connector(text)
normalized = technical_normalizer.normalize_datetime_connector(
self._normalize_rental_relative_text(text)
)
patterns = (
r"\b\d{1,2}[/-]\d{1,2}[/-]\d{4}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\b",
r"\b\d{4}[/-]\d{1,2}[/-]\d{1,2}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\b",
)
results: list[str] = []
matches: list[tuple[int, str]] = []
for pattern in patterns:
for match in re.finditer(pattern, normalized):
candidate = self._normalize_rental_datetime_text(match.group(0))
if candidate and candidate not in results:
results.append(candidate)
return results
if candidate:
matches.append((match.start(), candidate))
relative_pattern = (
r"\b(?:depois\s+de\s+amanh(?:a)?|day\s+after\s+tomorrow|amanh(?:a)?|tomorrow|hoj(?:e)?|today)"
r"(?:\s+(?:as|a))?"
r"(?:\s+(?:\d{1,2}:\d{2}(?::\d{2})?|\d{1,2}\s*(?:h|hora|horas)))?"
)
for match in re.finditer(relative_pattern, normalized):
candidate = self._normalize_rental_datetime_text(match.group(0))
if candidate:
matches.append((match.start(), candidate))
results: list[str] = []
seen: set[str] = set()
for _, candidate in sorted(matches, key=lambda item: item[0]):
if candidate in seen:
continue
seen.add(candidate)
results.append(candidate)
return results
# Normaliza datas de locacao para um formato unico aceito pelo fluxo.
def _normalize_rental_datetime_text(self, value) -> str | None:
text = technical_normalizer.normalize_datetime_connector(str(value or "").strip())
@ -246,11 +365,28 @@ class RentalFlowMixin:
),
)
if parsed is None:
return None
normalized = self._normalize_rental_relative_text(text)
day_offset = None
if "depois de amanha" in normalized or "depois de amanh" in normalized or "day after tomorrow" in normalized:
day_offset = 2
elif "amanha" in normalized or "amanh" in normalized or "tomorrow" in normalized:
day_offset = 1
elif "hoje" in normalized or "hoj" in normalized or "today" in normalized:
day_offset = 0
if day_offset is None:
return None
time_text = technical_normalizer.extract_hhmm_from_text(normalized)
if not time_text:
return None
hour_text, minute_text = time_text.split(":")
current_datetime = self._rental_now()
target_date = current_datetime + timedelta(days=day_offset)
return f"{target_date.strftime('%d/%m/%Y')} {int(hour_text):02d}:{int(minute_text):02d}"
if ":" in text:
return parsed.strftime("%d/%m/%Y %H:%M")
return parsed.strftime("%d/%m/%Y")
# Normaliza campos estruturados de aluguel antes de montar o draft.
def _normalize_rental_fields(self, data) -> dict:
if not isinstance(data, dict):
@ -467,6 +603,39 @@ class RentalFlowMixin:
lines.append("Pode responder com o numero da lista, com a placa ou com o modelo.")
return "\n".join(lines)
# Semeia o draft da locacao quando a frota e listada pelo caminho generico.
def _seed_pending_rental_draft_from_message(self, message: str, user_id: int | None) -> None:
if user_id is None or not self._has_explicit_rental_request(message):
return
draft = self.state.get_entry("pending_rental_drafts", user_id, expire=True)
if not isinstance(draft, dict):
draft = {
"payload": {},
"expires_at": utc_now() + timedelta(minutes=PENDING_RENTAL_DRAFT_TTL_MINUTES),
}
payload = draft.get("payload")
if not isinstance(payload, dict):
payload = {}
draft["payload"] = payload
self._try_capture_rental_fields_from_message(message=message, payload=payload)
if not payload:
return
draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_RENTAL_DRAFT_TTL_MINUTES)
self.state.set_entry("pending_rental_drafts", user_id, draft)
self._mark_rental_flow_active(user_id=user_id, active_task="rental_create")
self._store_last_rental_search_payload(user_id=user_id, payload=payload)
rental_results = self._get_last_rental_results(user_id=user_id)
if rental_results:
self._store_pending_rental_selection(
user_id=user_id,
rental_results=rental_results,
search_payload=payload,
)
# Consulta a frota e guarda o resultado para a etapa de selecao.
async def _try_list_rental_fleet_for_selection(
self,
@ -511,6 +680,11 @@ class RentalFlowMixin:
rental_results = tool_result if isinstance(tool_result, list) else []
self._remember_rental_results(user_id=user_id, rental_results=rental_results)
self._store_pending_rental_selection(
user_id=user_id,
rental_results=rental_results,
search_payload=payload,
)
self._mark_rental_flow_active(user_id=user_id)
return self._fallback_format_tool_result("consultar_frota_aluguel", tool_result)
@ -547,6 +721,13 @@ class RentalFlowMixin:
):
return None
remembered_search_payload = self._get_last_rental_search_payload(user_id=user_id)
if draft is None and remembered_search_payload:
draft = {
"payload": dict(remembered_search_payload),
"expires_at": utc_now() + timedelta(minutes=PENDING_RENTAL_DRAFT_TTL_MINUTES),
}
if draft is None:
draft = {
"payload": {},
@ -577,6 +758,7 @@ class RentalFlowMixin:
draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_RENTAL_DRAFT_TTL_MINUTES)
self.state.set_entry("pending_rental_drafts", user_id, draft)
self._mark_rental_flow_active(user_id=user_id, active_task="rental_create")
self._store_last_rental_search_payload(user_id=user_id, payload=draft_payload)
missing = [field for field in RENTAL_REQUIRED_FIELDS if field not in draft_payload]
if missing:
@ -626,4 +808,3 @@ class RentalFlowMixin:
user_id=user_id,
)
return self._fallback_format_tool_result("abrir_locacao_aluguel", tool_result)

@ -155,20 +155,35 @@ class RentalFlowStateSupport(FlowStateSupport):
rental_results = context.get("last_rental_results") or []
return self.sanitize_rental_results(rental_results if isinstance(rental_results, list) else [])
def store_pending_rental_selection(self, user_id: int | None, rental_results: list[dict] | None) -> None:
def store_pending_rental_selection(
self,
user_id: int | None,
rental_results: list[dict] | None,
search_payload: dict | None = None,
) -> None:
if user_id is None:
return
sanitized = self.sanitize_rental_results(rental_results)
if not sanitized:
self.pop_state_entry("pending_rental_selections", user_id)
return
entry = {
"payload": sanitized,
"expires_at": utc_now() + timedelta(minutes=PENDING_RENTAL_SELECTION_TTL_MINUTES),
}
current = self.get_state_entry("pending_rental_selections", user_id, expire=True)
current_search_payload = current.get("search_payload") if isinstance(current, dict) else None
candidate_search_payload = search_payload if isinstance(search_payload, dict) else current_search_payload
sanitized_search_payload = self.service._sanitize_rental_search_payload(candidate_search_payload)
if sanitized_search_payload:
entry["search_payload"] = sanitized_search_payload
self.set_state_entry(
"pending_rental_selections",
user_id,
{
"payload": sanitized,
"expires_at": utc_now() + timedelta(minutes=PENDING_RENTAL_SELECTION_TTL_MINUTES),
},
entry,
)
def get_selected_rental_vehicle(self, user_id: int | None) -> dict | None:

@ -111,6 +111,7 @@ class ReviewFlowMixin:
if not text:
return None
stop_terms = {
"depois de amanha",
"amanha",
"hoje",
"revisao",
@ -142,7 +143,7 @@ class ReviewFlowMixin:
)
if explicit_match:
raw_model = explicit_match.group(1)
raw_model = re.split(r"\b(?:ele e|ele eh|ano|placa|km|quilometragem|data|amanha|hoje)\b", raw_model, maxsplit=1)[0]
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))
@ -166,7 +167,7 @@ class ReviewFlowMixin:
raw_model = re.split(r"(?<!\d)(?:19\d{2}|20\d{2}|2100)(?!\d)", raw_model, maxsplit=1)[0]
raw_model = re.split(r"(?<!\d)(?:\d{1,3}(?:[.\s]\d{3})+|\d{2,6})\s*km\b", raw_model, maxsplit=1, flags=re.IGNORECASE)[0]
raw_model = re.split(
r"\b(?:placa|quilometragem|data|amanha|hoje|nunca fiz revisao|nao fiz revisao|nunca revisei|ja fiz revisao|fiz revisao|ja revisei|na concessionaria|concessionaria)\b",
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]
@ -235,6 +236,8 @@ class ReviewFlowMixin:
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
@ -510,6 +513,15 @@ class ReviewFlowMixin:
)
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)

@ -58,6 +58,109 @@ class MessagePlanner:
except Exception:
logger.exception("Falha ao extrair plano da mensagem com LLM. user_id=%s", user_id)
return default
async def extract_turn_bundle(self, message: str, user_id: int | None) -> dict:
user_context = f"user_id={user_id}" if user_id is not None else "user_id=anonimo"
default_turn_decision = self.normalizer.empty_turn_decision()
default_message_plan = self.normalizer.empty_message_plan(message=message)
schema_example = json.dumps(
{
"turn_decision": TurnDecision().model_dump(),
"message_plan": {
"orders": [
{
"domain": "general",
"message": "trecho literal do pedido",
"entities": self.normalizer.empty_extraction_payload(),
}
]
},
},
ensure_ascii=True,
)
prompt = (
"Analise a mensagem do usuario e retorne APENAS JSON valido com duas secoes: turn_decision e message_plan.\n"
"Nao use markdown. Nao escreva texto fora do JSON. Nao invente dados ausentes.\n\n"
"Formato obrigatorio:\n"
f"{schema_example}\n\n"
"Regras para turn_decision:\n"
"- 'turn_decision' deve seguir o contrato de decisao por turno.\n"
"- 'domain' deve ser review, sales ou general.\n"
"- 'intent' deve refletir a intencao principal do turno completo.\n"
"- 'action' deve ser uma das acoes do contrato.\n"
"- Se faltar dado para continuar um fluxo, use action='ask_missing_fields' e preencha 'missing_fields' e 'response_to_user'.\n"
"- Se nao houver acao operacional, use action='answer_user'.\n"
"- Em pedidos de compra com faixa de preco ou orcamento (ex.: '70 mil', 'ate 50 mil', 'R$ 45000'), preencha entities.generic_memory.orcamento_max.\n"
"- Em pedidos com tipo de carro (ex.: suv, sedan, hatch, pickup), preencha entities.generic_memory.perfil_veiculo.\n"
"- Se o usuario quiser efetivar a compra de um veiculo, use intent='order_create', domain='sales' e prefira tool_name='realizar_pedido'.\n"
"- Se o usuario quiser listar pedidos, use intent='order_list', domain='sales', action='call_tool' e tool_name='listar_pedidos'.\n"
"- Se o usuario quiser listar revisoes, use intent='review_list', domain='review', action='call_tool' e tool_name='listar_agendamentos_revisao'.\n"
"- Se o usuario quiser cancelar revisao, use intent='review_cancel', domain='review' e prefira tool_name='cancelar_agendamento_revisao'.\n"
"- Se o usuario quiser remarcar revisao, use intent='review_reschedule', domain='review' e prefira tool_name='editar_data_revisao'.\n\n"
"Regras para message_plan:\n"
"- 'message_plan.orders' deve listar os pedidos operacionais em ordem de aparicao.\n"
"- Se houver mais de um pedido operacional, separe em itens distintos.\n"
"- Se nao houver pedido operacional, use domain='general' com a mensagem inteira.\n"
"- Cada item deve conter 'domain', 'message' e 'entities'.\n"
"- Mantenha cada 'message' curta e fiel ao texto do usuario.\n"
"- Em pedidos de compra com faixa de preco ou orcamento (ex.: '70 mil', 'ate 50 mil', 'R$ 45000'), preencha entities.generic_memory.orcamento_max.\n"
"- Em pedidos com tipo de carro (ex.: suv, sedan, hatch, pickup), preencha entities.generic_memory.perfil_veiculo.\n\n"
f"Contexto: {user_context}\n"
f"Mensagem do usuario: {message}"
)
for attempt in range(2):
try:
result = await self.llm.generate_response(message=prompt, tools=[])
text = (result.get("response") or "").strip()
payload = self.normalizer.parse_json_object(text)
if not isinstance(payload, dict):
if attempt == 0:
logger.warning("Bundle estruturado invalido (nao JSON objeto); repetindo uma vez. user_id=%s", user_id)
continue
raw_turn_decision = payload.get("turn_decision")
raw_message_plan = payload.get("message_plan")
has_turn_decision = isinstance(raw_turn_decision, dict) and any(
key in raw_turn_decision
for key in (
"intent",
"domain",
"action",
"entities",
"tool_name",
"tool_arguments",
"response_to_user",
"missing_fields",
"selection_index",
)
)
raw_orders = raw_message_plan.get("orders") if isinstance(raw_message_plan, dict) else None
has_message_plan = isinstance(raw_orders, list) and len(raw_orders) > 0
bundle = {
"turn_decision": self.normalizer.coerce_turn_decision(raw_turn_decision),
"message_plan": self.normalizer.coerce_message_plan(raw_message_plan, message=message),
"has_turn_decision": has_turn_decision,
"has_message_plan": has_message_plan,
}
if has_turn_decision and has_message_plan:
return bundle
if attempt == 0:
logger.warning(
"Bundle estruturado incompleto; repetindo uma vez. user_id=%s has_turn_decision=%s has_message_plan=%s",
user_id,
has_turn_decision,
has_message_plan,
)
except Exception:
logger.exception("Falha ao extrair bundle estruturado com LLM. user_id=%s", user_id)
break
return {
"turn_decision": default_turn_decision,
"message_plan": default_message_plan,
"has_turn_decision": False,
"has_message_plan": False,
}
async def extract_routing(self, message: str, user_id: int | None) -> dict:
plan = await self.extract_message_plan(message=message, user_id=user_id)

@ -227,6 +227,7 @@ class OrchestratorContextManager:
if isinstance(context, dict):
context["last_rental_results"] = []
context["selected_rental_vehicle"] = None
context.pop("last_rental_search_payload", None)
if context.get("active_task") == "rental_create":
context["active_task"] = None
if str(context.get("active_domain") or "").strip().lower() == "rental":

@ -220,12 +220,34 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
)
if current_rental_info:
return current_rental_info
deterministic_rental_bootstrap = await self._try_handle_deterministic_rental_bootstrap(
message=message,
user_id=user_id,
finish=finish,
)
if deterministic_rental_bootstrap:
return deterministic_rental_bootstrap
# Faz uma leitura inicial do turno para ajudar a policy
# com fila, troca de contexto e comandos globais.
early_turn_decision = await self._extract_turn_decision_with_llm(
turn_bundle = await self._extract_turn_bundle_with_llm(
message=message,
user_id=user_id,
)
can_use_turn_bundle = (
isinstance(turn_bundle, dict)
and bool(turn_bundle.get("has_turn_decision"))
and bool(turn_bundle.get("has_message_plan"))
and isinstance(turn_bundle.get("turn_decision"), dict)
and isinstance(turn_bundle.get("message_plan"), dict)
)
early_turn_decision = (
turn_bundle.get("turn_decision")
if can_use_turn_bundle
else await self._extract_turn_decision_with_llm(
message=message,
user_id=user_id,
)
)
reset_override = await self._try_handle_immediate_context_reset(
message=message,
user_id=user_id,
@ -259,9 +281,13 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
return deterministic_rental_management
message_plan = await self._extract_message_plan_with_llm(
message=message,
user_id=user_id,
message_plan = (
turn_bundle.get("message_plan")
if can_use_turn_bundle
else await self._extract_message_plan_with_llm(
message=message,
user_id=user_id,
)
)
routing_plan = {
"orders": [
@ -575,6 +601,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
tool_result=tool_result,
user_id=user_id,
)
if tool_name == "consultar_frota_aluguel":
self._seed_pending_rental_draft_from_message(
message=routing_message,
user_id=user_id,
)
if self._should_use_deterministic_response(tool_name):
return await finish(
@ -1421,6 +1452,48 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
queue_notice=queue_notice,
)
async def _try_handle_deterministic_rental_bootstrap(
self,
message: str,
user_id: int | None,
finish,
) -> str | None:
if user_id is None:
return None
if (
self._has_rental_return_management_request(message, user_id=user_id)
or self._has_rental_payment_or_fine_request(message)
):
return None
if (
self._has_explicit_order_request(message)
or self._has_stock_listing_request(message)
or self._has_order_listing_request(message)
or self._has_trade_in_evaluation_request(message)
):
return None
explicit_rental_request = self._has_explicit_rental_request(message)
rental_listing_request = self._has_rental_listing_request(message)
if not explicit_rental_request and not rental_listing_request:
return None
turn_decision = {
"intent": "rental_create" if explicit_rental_request else "rental_list",
"domain": "rental",
"action": "collect_rental_create" if explicit_rental_request else "collect_rental_list",
}
response = await self._try_collect_and_open_rental(
message=message,
user_id=user_id,
extracted_fields={},
intents={},
turn_decision=turn_decision,
)
if not response:
return None
return await finish(response)
async def _try_handle_active_sales_follow_up(
self,
message: str,
@ -1627,6 +1700,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
tool_result=tool_result,
user_id=user_id,
)
if tool_name == "consultar_frota_aluguel":
self._seed_pending_rental_draft_from_message(
message=message,
user_id=user_id,
)
if self._should_use_deterministic_response(tool_name):
return await finish(
@ -1975,6 +2053,20 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
def _coerce_extraction_contract(self, payload) -> dict:
return self.normalizer.coerce_extraction_contract(payload)
async def _extract_turn_bundle_with_llm(self, message: str, user_id: int | None) -> dict | None:
planner = getattr(self, "planner", None)
if planner is None or not hasattr(planner, "extract_turn_bundle"):
return None
started_at = perf_counter()
result = await planner.extract_turn_bundle(message=message, user_id=user_id)
self._emit_turn_stage_metric(
"extract_turn_bundle",
started_at,
has_turn_decision=bool((result or {}).get("has_turn_decision")),
has_message_plan=bool((result or {}).get("has_message_plan")),
order_count=len((((result.get("message_plan") if isinstance(result, dict) else {}) or {}).get("orders") or [])),
)
return result if isinstance(result, dict) else None
async def _extract_message_plan_with_llm(self, message: str, user_id: int | None) -> dict:
started_at = perf_counter()
result = await self.planner.extract_message_plan(message=message, user_id=user_id)
@ -2709,17 +2801,20 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
exc: HTTPException,
user_id: int | None,
) -> None:
if tool_name != "agendar_revisao" or user_id is None or exc.status_code != 409:
if tool_name not in {"agendar_revisao", "editar_data_revisao"} or user_id is None or exc.status_code != 409:
return
detail = exc.detail if isinstance(exc.detail, dict) else {}
suggested_iso = str(detail.get("suggested_iso") or "").strip()
if not suggested_iso:
return
payload = dict(arguments or {})
if not payload.get("placa"):
datetime_field = "nova_data_hora" if tool_name == "editar_data_revisao" else "data_hora"
required_field = "protocolo" if tool_name == "editar_data_revisao" else "placa"
if not payload.get(required_field):
return
payload["data_hora"] = suggested_iso
payload[datetime_field] = suggested_iso
self.state.set_entry("pending_review_confirmations", user_id, {
"tool_name": tool_name,
"payload": payload,
"expires_at": utc_now() + timedelta(minutes=PENDING_REVIEW_TTL_MINUTES),
})
@ -2944,17 +3039,20 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
exc: HTTPException,
user_id: int | None,
) -> None:
if tool_name != "agendar_revisao" or user_id is None or exc.status_code != 409:
if tool_name not in {"agendar_revisao", "editar_data_revisao"} or user_id is None or exc.status_code != 409:
return
detail = exc.detail if isinstance(exc.detail, dict) else {}
suggested_iso = str(detail.get("suggested_iso") or "").strip()
if not suggested_iso:
return
payload = dict(arguments or {})
if not payload.get("placa"):
datetime_field = "nova_data_hora" if tool_name == "editar_data_revisao" else "data_hora"
required_field = "protocolo" if tool_name == "editar_data_revisao" else "placa"
if not payload.get(required_field):
return
payload["data_hora"] = suggested_iso
payload[datetime_field] = suggested_iso
self.state.set_entry("pending_review_confirmations", user_id, {
"tool_name": tool_name,
"payload": payload,
"expires_at": utc_now() + timedelta(minutes=PENDING_REVIEW_TTL_MINUTES),
})
@ -2971,28 +3069,39 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
if not pending:
return None
pending_tool_name = str(pending.get("tool_name") or "agendar_revisao").strip() or "agendar_revisao"
datetime_field = "nova_data_hora" if pending_tool_name == "editar_data_revisao" else "data_hora"
normalized_schedule_fields = self._normalize_review_fields(extracted_review_fields)
normalized_management_fields = self._normalize_review_management_fields(extracted_review_fields)
normalized_message_datetime = None if self._is_affirmative_message(message) else self._normalize_review_datetime_text(message)
time_only = self._extract_time_only(message)
if self._is_negative_message(message) or time_only:
extracted = self._normalize_review_fields(extracted_review_fields)
new_data_hora = extracted.get("data_hora")
if not new_data_hora and time_only:
new_data_hora = self._merge_date_with_time(pending["payload"].get("data_hora", ""), time_only)
new_data_hora = (
normalized_management_fields.get("nova_data_hora")
if pending_tool_name == "editar_data_revisao"
else normalized_schedule_fields.get("data_hora")
)
if not new_data_hora and normalized_message_datetime:
new_data_hora = normalized_message_datetime
if not new_data_hora and time_only:
new_data_hora = self._merge_date_with_time(pending["payload"].get(datetime_field, ""), time_only)
if self._is_negative_message(message) or time_only or (new_data_hora and not self._is_affirmative_message(message)):
if not new_data_hora:
self.state.pop_entry("pending_review_confirmations", user_id)
return "Sem problema. Me informe a nova data e hora desejada para a revisao."
payload = dict(pending["payload"])
payload["data_hora"] = new_data_hora
payload[datetime_field] = new_data_hora
try:
tool_result = await self.tool_executor.execute(
"agendar_revisao",
pending_tool_name,
payload,
user_id=user_id,
)
except HTTPException as exc:
self.state.pop_entry("pending_review_confirmations", user_id)
self._capture_review_confirmation_suggestion(
tool_name="agendar_revisao",
tool_name=pending_tool_name,
arguments=payload,
exc=exc,
user_id=user_id,
@ -3000,24 +3109,32 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
return self._http_exception_detail(exc)
self._reset_pending_review_states(user_id=user_id)
self._store_last_review_package(user_id=user_id, payload=payload)
return self._fallback_format_tool_result("agendar_revisao", tool_result)
if pending_tool_name == "agendar_revisao":
self._store_last_review_package(user_id=user_id, payload=payload)
return self._fallback_format_tool_result(pending_tool_name, tool_result)
if not self._is_affirmative_message(message):
return None
try:
tool_result = await self.tool_executor.execute(
"agendar_revisao",
pending_tool_name,
pending["payload"],
user_id=user_id,
)
except HTTPException as exc:
self.state.pop_entry("pending_review_confirmations", user_id)
self._capture_review_confirmation_suggestion(
tool_name=pending_tool_name,
arguments=pending.get("payload") or {},
exc=exc,
user_id=user_id,
)
return self._http_exception_detail(exc)
self._reset_pending_review_states(user_id=user_id)
self._store_last_review_package(user_id=user_id, payload=pending.get("payload"))
return self._fallback_format_tool_result("agendar_revisao", tool_result)
if pending_tool_name == "agendar_revisao":
self._store_last_review_package(user_id=user_id, payload=pending.get("payload"))
return self._fallback_format_tool_result(pending_tool_name, tool_result)
def _http_exception_detail(self, exc: HTTPException) -> str:
return self._execution_manager.http_exception_detail(exc)

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

@ -261,12 +261,12 @@ class OrderFlowHarness(OrderFlowMixin):
class RentalFlowHarness(RentalFlowMixin):
def __init__(self, state, registry):
def __init__(self, state, registry, rental_now_provider=None):
self.state = state
self.registry = registry
self.tool_executor = registry
self.normalizer = EntityNormalizer()
self._rental_now_provider = rental_now_provider
def _get_user_context(self, user_id: int | None):
return self.state.get_user_context(user_id)
@ -1105,9 +1105,17 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
async def test_order_flow_accepts_turn_decision_without_legacy_intents(self):
state = FakeState(
entries={
"pending_order_drafts": {
10: {
"payload": {"cpf": "12345678909"},
"expires_at": utc_now() + timedelta(minutes=30),
}
}
},
contexts={
10: {
"generic_memory": {"cpf": "12345678909"},
"generic_memory": {},
"last_stock_results": [
{"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 51524.0},
],
@ -1143,9 +1151,17 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
async def test_order_flow_accepts_model_intent_without_keyword_trigger(self):
state = FakeState(
entries={
"pending_order_drafts": {
10: {
"payload": {"cpf": "12345678909"},
"expires_at": utc_now() + timedelta(minutes=30),
}
}
},
contexts={
10: {
"generic_memory": {"cpf": "12345678909"},
"generic_memory": {},
"last_stock_results": [
{"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 51524.0},
],
@ -1201,9 +1217,166 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
intents={"order_create": True},
)
self.assertIn("escolha primeiro qual veiculo", response.lower())
self.assertIn("Honda Civic 2021", response)
self.assertEqual(
response,
"Pode me dizer a faixa de preco, o modelo ou o tipo de carro que voce procura.",
)
self.assertEqual(registry.calls, [])
self.assertEqual(state.get_user_context(10)["last_stock_results"], [])
async def test_order_flow_generic_request_asks_for_price_range_even_with_previous_search_context(self):
state = FakeState(
contexts={
10: {
"generic_memory": {"cpf": "12345678909", "orcamento_max": 70000, "perfil_veiculo": ["suv"]},
"shared_memory": {"orcamento_max": 70000, "perfil_veiculo": ["suv"]},
"last_stock_results": [
{"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0},
{"id": 3, "modelo": "Chevrolet Onix 2022", "categoria": "suv", "preco": 51809.0},
],
"selected_vehicle": {"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0},
"pending_single_vehicle_confirmation": {"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0},
}
}
)
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_create_order(
message="Quero fazer um pedido de veiculo",
user_id=10,
extracted_fields={},
intents={"order_create": True},
turn_decision={"intent": "order_create", "domain": "sales", "action": "collect_order_create"},
)
draft = state.get_entry("pending_order_drafts", 10)
context = state.get_user_context(10)
self.assertEqual(
response,
"Pode me dizer a faixa de preco, o modelo ou o tipo de carro que voce procura.",
)
self.assertEqual(registry.calls, [])
self.assertIsNotNone(draft)
self.assertEqual(draft["payload"], {})
self.assertEqual(context["last_stock_results"], [])
self.assertIsNone(context["selected_vehicle"])
self.assertIsNone(context.get("pending_single_vehicle_confirmation"))
self.assertNotIn("orcamento_max", context["generic_memory"])
self.assertNotIn("perfil_veiculo", context["generic_memory"])
self.assertNotIn("orcamento_max", context["shared_memory"])
self.assertNotIn("perfil_veiculo", context["shared_memory"])
async def test_order_flow_requires_confirmation_before_using_known_cpf(self):
state = FakeState(
contexts={
10: {
"generic_memory": {"cpf": "12345678909"},
"last_stock_results": [
{"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 51524.0},
],
"selected_vehicle": None,
}
}
)
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
hydrated = []
async def fake_hydrate_mock_customer_from_cpf(cpf: str, user_id: int | None = None):
hydrated.append((cpf, user_id))
return {"cpf": cpf, "user_id": user_id}
with patch(
"app.services.flows.order_flow.hydrate_mock_customer_from_cpf",
new=fake_hydrate_mock_customer_from_cpf,
):
first_response = await flow._try_collect_and_create_order(
message="1",
user_id=10,
extracted_fields={},
intents={},
)
draft = state.get_entry("pending_order_drafts", 10)
self.assertIsNotNone(draft)
self.assertEqual(draft["payload"].get("vehicle_id"), 1)
self.assertEqual(draft["payload"].get("cpf"), "12345678909")
self.assertIs(draft["payload"].get("cpf_confirmed"), False)
self.assertIn("cpf informado anteriormente", first_response.lower())
self.assertIn("continua correto", first_response.lower())
self.assertEqual(registry.calls, [])
self.assertEqual(hydrated, [])
second_response = await flow._try_collect_and_create_order(
message="sim",
user_id=10,
extracted_fields={},
intents={},
)
self.assertEqual(len(registry.calls), 1)
self.assertEqual(registry.calls[0][0], "realizar_pedido")
self.assertEqual(registry.calls[0][1]["vehicle_id"], 1)
self.assertEqual(registry.calls[0][1]["cpf"], "12345678909")
self.assertEqual(hydrated, [("12345678909", 10)])
self.assertIn("Pedido criado com sucesso.", second_response)
async def test_order_flow_updates_known_cpf_after_negative_confirmation_and_new_value(self):
state = FakeState(
contexts={
10: {
"generic_memory": {"cpf": "12345678909"},
"last_stock_results": [
{"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 51524.0},
],
"selected_vehicle": None,
}
}
)
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
hydrated = []
async def fake_hydrate_mock_customer_from_cpf(cpf: str, user_id: int | None = None):
hydrated.append((cpf, user_id))
return {"cpf": cpf, "user_id": user_id}
with patch(
"app.services.flows.order_flow.hydrate_mock_customer_from_cpf",
new=fake_hydrate_mock_customer_from_cpf,
):
await flow._try_collect_and_create_order(
message="1",
user_id=10,
extracted_fields={},
intents={},
)
second_response = await flow._try_collect_and_create_order(
message="nao",
user_id=10,
extracted_fields={},
intents={},
)
third_response = await flow._try_collect_and_create_order(
message="52998224725",
user_id=10,
extracted_fields={},
intents={},
)
self.assertIn("me informe o cpf correto", second_response.lower())
self.assertEqual(len(registry.calls), 1)
self.assertEqual(registry.calls[0][0], "realizar_pedido")
self.assertEqual(registry.calls[0][1]["vehicle_id"], 1)
self.assertEqual(registry.calls[0][1]["cpf"], "52998224725")
self.assertEqual(hydrated, [("52998224725", 10)])
self.assertEqual(state.get_user_context(10)["generic_memory"]["cpf"], "52998224725")
self.assertEqual(state.get_user_context(10)["shared_memory"]["cpf"], "52998224725")
self.assertIn("Pedido criado com sucesso.", third_response)
async def test_order_flow_single_stock_result_requires_explicit_confirmation(self):
state = FakeState(
@ -2003,6 +2176,186 @@ class RentalFlowDraftTests(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(state.get_entry("pending_rental_drafts", 21))
self.assertIn("LOC-TESTE-123", response)
async def test_rental_flow_preserves_relative_dates_from_initial_request_until_vehicle_selection(self):
fixed_now = lambda: datetime(2026, 3, 19, 9, 0)
state = FakeState(contexts={21: self._base_context()})
registry = FakeRegistry()
flow = RentalFlowHarness(state=state, registry=registry, rental_now_provider=fixed_now)
list_response = await flow._try_collect_and_open_rental(
message="Quero alugar um hatch amanha 10h ate depois de amanha 10h",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "rental_create", "domain": "rental", "action": "answer_user"},
)
self.assertEqual(registry.calls[0][0], "consultar_frota_aluguel")
self.assertNotIn("modelo", registry.calls[0][1])
draft = state.get_entry("pending_rental_drafts", 21)
self.assertIsNotNone(draft)
self.assertEqual(draft["payload"]["data_inicio"], "20/03/2026 10:00")
self.assertEqual(draft["payload"]["data_fim_prevista"], "21/03/2026 10:00")
self.assertEqual(draft["payload"]["categoria"], "hatch")
self.assertIn("veiculo(s) para locacao", list_response)
open_response = await flow._try_collect_and_open_rental(
message="1",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "rental_create", "domain": "rental", "action": "answer_user"},
)
self.assertEqual(registry.calls[1][0], "abrir_locacao_aluguel")
self.assertEqual(registry.calls[1][1]["data_inicio"], "20/03/2026 10:00")
self.assertEqual(registry.calls[1][1]["data_fim_prevista"], "21/03/2026 10:00")
self.assertIn("LOC-TESTE-123", open_response)
async def test_rental_flow_preserves_relative_dates_even_when_day_words_arrive_truncated(self):
fixed_now = lambda: datetime(2026, 3, 19, 9, 0)
state = FakeState(contexts={21: self._base_context()})
registry = FakeRegistry()
flow = RentalFlowHarness(state=state, registry=registry, rental_now_provider=fixed_now)
list_response = await flow._try_collect_and_open_rental(
message="Quero alugar um hatch amanh 10h at depois de amanh 10h",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "rental_create", "domain": "rental", "action": "answer_user"},
)
self.assertEqual(registry.calls[0][0], "consultar_frota_aluguel")
draft = state.get_entry("pending_rental_drafts", 21)
self.assertIsNotNone(draft)
self.assertEqual(draft["payload"]["data_inicio"], "20/03/2026 10:00")
self.assertEqual(draft["payload"]["data_fim_prevista"], "21/03/2026 10:00")
self.assertNotIn("modelo", registry.calls[0][1])
self.assertIn("veiculo(s) para locacao", list_response)
open_response = await flow._try_collect_and_open_rental(
message="1",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "rental_create", "domain": "rental", "action": "answer_user"},
)
self.assertEqual(registry.calls[1][0], "abrir_locacao_aluguel")
self.assertEqual(registry.calls[1][1]["data_inicio"], "20/03/2026 10:00")
self.assertEqual(registry.calls[1][1]["data_fim_prevista"], "21/03/2026 10:00")
self.assertIn("LOC-TESTE-123", open_response)
async def test_rental_flow_preserves_relative_dates_even_when_day_words_arrive_with_question_marks(self):
fixed_now = lambda: datetime(2026, 3, 19, 9, 0)
state = FakeState(contexts={21: self._base_context()})
registry = FakeRegistry()
flow = RentalFlowHarness(state=state, registry=registry, rental_now_provider=fixed_now)
list_response = await flow._try_collect_and_open_rental(
message="Quero alugar um hatch amanh? 10h at? depois de amanh? 10h",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "rental_create", "domain": "rental", "action": "answer_user"},
)
self.assertEqual(registry.calls[0][0], "consultar_frota_aluguel")
draft = state.get_entry("pending_rental_drafts", 21)
self.assertIsNotNone(draft)
self.assertEqual(draft["payload"]["data_inicio"], "20/03/2026 10:00")
self.assertEqual(draft["payload"]["data_fim_prevista"], "21/03/2026 10:00")
self.assertNotIn("modelo", registry.calls[0][1])
self.assertIn("veiculo(s) para locacao", list_response)
open_response = await flow._try_collect_and_open_rental(
message="1",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "rental_create", "domain": "rental", "action": "answer_user"},
)
self.assertEqual(registry.calls[1][0], "abrir_locacao_aluguel")
self.assertEqual(registry.calls[1][1]["data_inicio"], "20/03/2026 10:00")
self.assertEqual(registry.calls[1][1]["data_fim_prevista"], "21/03/2026 10:00")
self.assertIn("LOC-TESTE-123", open_response)
async def test_rental_flow_rehydrates_search_payload_from_context_when_selection_survives_without_draft(self):
state = FakeState(
entries={
"pending_rental_selections": {
21: {
"payload": [
{"id": 1, "placa": "RAA1A01", "modelo": "Chevrolet Tracker", "categoria": "hatch", "ano": 2024, "valor_diaria": 219.9, "status": "disponivel"},
{"id": 2, "placa": "RAA1A02", "modelo": "Fiat Pulse", "categoria": "hatch", "ano": 2024, "valor_diaria": 189.9, "status": "disponivel"},
],
"expires_at": utc_now() + timedelta(minutes=15),
}
}
},
contexts={
21: self._base_context() | {
"active_domain": "rental",
}
},
)
state.get_entry("pending_rental_selections", 21)["search_payload"] = {
"categoria": "hatch",
"data_inicio": "20/03/2026 10:00",
"data_fim_prevista": "21/03/2026 10:00",
}
registry = FakeRegistry()
flow = RentalFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_open_rental(
message="2",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "rental_create", "domain": "rental", "action": "answer_user"},
)
self.assertEqual(registry.calls[0][0], "abrir_locacao_aluguel")
self.assertEqual(registry.calls[0][1]["rental_vehicle_id"], 2)
self.assertEqual(registry.calls[0][1]["data_inicio"], "20/03/2026 10:00")
self.assertEqual(registry.calls[0][1]["data_fim_prevista"], "21/03/2026 10:00")
self.assertIn("LOC-TESTE-123", response)
async def test_rental_flow_opens_contract_after_collecting_relative_dates_follow_up(self):
fixed_now = lambda: datetime(2026, 3, 19, 9, 0)
state = FakeState(
entries={
"pending_rental_drafts": {
21: {
"payload": {
"rental_vehicle_id": 1,
"placa": "RAA1A01",
"modelo_veiculo": "Chevrolet Tracker",
},
"expires_at": utc_now() + timedelta(minutes=15),
}
}
},
contexts={21: self._base_context() | {"active_domain": "rental", "selected_rental_vehicle": {"id": 1, "placa": "RAA1A01", "modelo": "Chevrolet Tracker", "categoria": "suv", "ano": 2024, "valor_diaria": 219.9, "status": "disponivel"}}},
)
registry = FakeRegistry()
flow = RentalFlowHarness(state=state, registry=registry, rental_now_provider=fixed_now)
response = await flow._try_collect_and_open_rental(
message="amanha 10h ate depois de amanha 10h",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "rental_create", "domain": "rental", "action": "answer_user"},
)
self.assertEqual(registry.calls[0][0], "abrir_locacao_aluguel")
self.assertEqual(registry.calls[0][1]["data_inicio"], "20/03/2026 10:00")
self.assertEqual(registry.calls[0][1]["data_fim_prevista"], "21/03/2026 10:00")
self.assertIsNone(state.get_entry("pending_rental_drafts", 21))
self.assertIn("LOC-TESTE-123", response)
class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
async def test_review_flow_extracts_relative_datetime_from_followup_message(self):
@ -2066,6 +2419,36 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("- o modelo do veiculo", response)
self.assertNotIn("- a data e hora desejada para a revisao", response)
async def test_review_flow_date_only_supports_day_after_tomorrow(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="depois de 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"), "14/03/2026")
self.assertIn("Perfeito. Tenho a data 14/03/2026.", response)
self.assertIn("- o horario desejado para a revisao", response)
async def test_review_flow_keeps_review_draft_when_time_follow_up_is_misclassified_as_sales(self):
state = FakeState(
entries={
@ -2862,6 +3245,37 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(registry.calls[0][1]["nova_data_hora"], "13/03/2026 11:00")
self.assertIn("13/03/2026 11:00", response)
async def test_review_management_reschedule_consumes_day_after_tomorrow_relative_datetime_follow_up(self):
fixed_now = lambda: datetime(2026, 3, 12, 9, 0)
state = FakeState(
entries={
"pending_review_management_drafts": {
21: {
"action": "reschedule",
"payload": {"protocolo": "REV-20260313-F754AF27"},
"expires_at": utc_now() + timedelta(minutes=30),
}
}
}
)
registry = FakeRegistry()
flow = ReviewFlowHarness(state=state, registry=registry, review_now_provider=fixed_now)
response = await flow._try_handle_review_management(
message="depois de amanha 11h",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "review_reschedule", "domain": "review", "action": "answer_user"},
)
self.assertIsNone(state.get_entry("pending_review_management_drafts", 21))
self.assertEqual(registry.calls[0][0], "editar_data_revisao")
self.assertEqual(registry.calls[0][1]["protocolo"], "REV-20260313-F754AF27")
self.assertEqual(registry.calls[0][1]["nova_data_hora"], "14/03/2026 11:00")
self.assertIn("14/03/2026 11:00", response)
async def test_review_management_reschedule_date_only_then_time_follow_up(self):
fixed_now = lambda: datetime(2026, 3, 12, 9, 0)
state = FakeState(
@ -2904,6 +3318,50 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(registry.calls[0][1]["nova_data_hora"], "13/03/2026 11:00")
self.assertIn("13/03/2026 11:00", second_response)
async def test_review_management_reschedule_conflict_stores_pending_confirmation_suggestion(self):
fixed_now = lambda: datetime(2026, 3, 12, 9, 0)
state = FakeState(
entries={
"pending_review_management_drafts": {
21: {
"action": "reschedule",
"payload": {"protocolo": "REV-20260313-F754AF27"},
"expires_at": utc_now() + timedelta(minutes=30),
}
}
}
)
registry = FakeRegistry()
registry.raise_http_exception = HTTPException(
status_code=409,
detail={
"code": "review_reschedule_conflict",
"message": "O horario 14/03/2026 as 11:00 ja esta ocupado. Posso agendar em 14/03/2026 as 12:00.",
"retryable": True,
"field": "nova_data_hora",
"suggested_iso": "2026-03-14T12:00:00-03:00",
},
)
flow = ReviewFlowHarness(state=state, registry=registry, review_now_provider=fixed_now)
response = await flow._try_handle_review_management(
message="depois de amanha 11h",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "review_reschedule", "domain": "review", "action": "answer_user"},
)
draft = state.get_entry("pending_review_management_drafts", 21)
self.assertIsNotNone(draft)
self.assertNotIn("nova_data_hora", draft["payload"])
self.assertEqual(len(flow.captured_suggestions), 1)
suggestion = flow.captured_suggestions[0]
self.assertEqual(suggestion["tool_name"], "editar_data_revisao")
self.assertEqual(suggestion["arguments"]["protocolo"], "REV-20260313-F754AF27")
self.assertEqual(suggestion["arguments"]["nova_data_hora"], "14/03/2026 11:00")
self.assertIn("ocupado", response)
async def test_review_management_infers_listing_intent_from_agendamentos_message(self):
state = FakeState()
registry = FakeRegistry()
@ -3192,7 +3650,3 @@ class ToolRegistryExecutionTests(unittest.IsolatedAsyncioTestCase):
if __name__ == "__main__":
unittest.main()

@ -168,7 +168,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
"action": "ask_missing_fields",
"entities": {
"generic_memory": {},
"review_fields": {"placa": "abc1234", "data_hora": "10/03/2026 \u00e0s 09:00"},
"review_fields": {"placa": "abc1234", "data_hora": "10/03/2026 às 09:00"},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {}
@ -185,16 +185,73 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
)
planner = MessagePlanner(llm=llm, normalizer=EntityNormalizer())
decision = await planner.extract_turn_decision("Quero agendar revis\u00e3o amanh\u00e3 \u00e0s 09:00", user_id=7)
decision = await planner.extract_turn_decision("Quero agendar revisão amanhã às 09:00", user_id=7)
self.assertEqual(llm.calls, 2)
self.assertEqual(decision["intent"], "review_schedule")
self.assertEqual(decision["domain"], "review")
self.assertEqual(decision["action"], "ask_missing_fields")
self.assertEqual(decision["entities"]["review_fields"]["placa"], "ABC1234")
self.assertEqual(decision["entities"]["review_fields"]["data_hora"], "10/03/2026 \u00e0s 09:00")
self.assertEqual(decision["entities"]["review_fields"]["data_hora"], "10/03/2026 às 09:00")
self.assertEqual(decision["missing_fields"], ["modelo", "ano", "km"])
async def test_extract_turn_bundle_retries_once_and_returns_structured_payload(self):
llm = FakeLLM(
[
{"response": "nao eh json", "tool_call": None},
{
"response": """
{
"turn_decision": {
"intent": "order_create",
"domain": "sales",
"action": "ask_missing_fields",
"entities": {
"generic_memory": {"orcamento_max": 70000},
"review_fields": {},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {}
},
"missing_fields": ["modelo_veiculo"],
"tool_name": null,
"tool_arguments": {},
"response_to_user": "Qual veículo você quer comprar?"
},
"message_plan": {
"orders": [
{
"domain": "sales",
"message": "Quero comprar um carro até 70 mil",
"entities": {
"generic_memory": {"orcamento_max": 70000},
"review_fields": {},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {},
"intents": {"order_create": true}
}
}
]
}
}
""",
"tool_call": None,
},
]
)
planner = MessagePlanner(llm=llm, normalizer=EntityNormalizer())
bundle = await planner.extract_turn_bundle("Quero comprar um carro até 70 mil", user_id=7)
self.assertEqual(llm.calls, 2)
self.assertTrue(bundle["has_turn_decision"])
self.assertTrue(bundle["has_message_plan"])
self.assertEqual(bundle["turn_decision"]["intent"], "order_create")
self.assertEqual(bundle["turn_decision"]["domain"], "sales")
self.assertEqual(bundle["turn_decision"]["entities"]["generic_memory"]["orcamento_max"], 70000)
self.assertEqual(bundle["message_plan"]["orders"][0]["domain"], "sales")
self.assertEqual(bundle["message_plan"]["orders"][0]["message"], "Quero comprar um carro até 70 mil")
def test_parse_json_object_accepts_python_style_dict_with_trailing_commas(self):
normalizer = EntityNormalizer()
@ -1037,6 +1094,81 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("numero da lista, a placa ou o modelo", response)
self.assertEqual(service.llm.calls, 0)
async def test_turn_decision_rental_fleet_listing_seeds_pending_draft_from_message(self):
registry = StaticToolRegistry(
result=[
{"id": 1, "placa": "RAA1A01", "modelo": "Chevrolet Tracker", "categoria": "hatch", "ano": 2024, "valor_diaria": 219.9, "status": "disponivel"},
{"id": 2, "placa": "RAA1A02", "modelo": "Fiat Pulse", "categoria": "hatch", "ano": 2024, "valor_diaria": 189.9, "status": "disponivel"},
]
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = FakeState(
contexts={
7: {
"active_domain": "general",
"active_task": None,
"generic_memory": {},
"shared_memory": {},
"collected_slots": {},
"flow_snapshots": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
"last_rental_results": [],
"selected_rental_vehicle": None,
}
}
)
service.normalizer = EntityNormalizer()
service.tool_executor = ToolExecutor(registry=registry)
service.llm = FakeLLM([])
service._rental_now_provider = lambda: datetime(2026, 3, 19, 9, 0)
service._capture_review_confirmation_suggestion = lambda **kwargs: None
service._capture_tool_invocation_trace = lambda **kwargs: None
service._log_turn_event = lambda *args, **kwargs: None
async def fake_render_tool_response_with_fallback(**kwargs):
raise AssertionError("nao deveria usar llm para listagem de locacao")
service._render_tool_response_with_fallback = fake_render_tool_response_with_fallback
service._http_exception_detail = lambda exc: str(exc)
service._is_low_value_response = lambda text: False
async def finish(response: str, queue_notice: str | None = None) -> str:
return response if not queue_notice else f"{queue_notice}\n{response}"
response = await service._try_execute_business_tool_from_turn_decision(
message="Quero alugar um hatch amanha 10h ate depois de amanha 10h",
user_id=7,
turn_decision={
"action": "call_tool",
"tool_name": "consultar_frota_aluguel",
"tool_arguments": {"status": "disponivel"},
},
queue_notice=None,
finish=finish,
)
self.assertIn("veiculo(s) para locacao", response)
draft = service.state.get_entry("pending_rental_drafts", 7)
self.assertIsNotNone(draft)
self.assertEqual(draft["payload"]["categoria"], "hatch")
self.assertEqual(draft["payload"]["data_inicio"], "20/03/2026 10:00")
self.assertEqual(draft["payload"]["data_fim_prevista"], "21/03/2026 10:00")
pending_selection = service.state.get_entry("pending_rental_selections", 7)
self.assertIsNotNone(pending_selection)
self.assertEqual(pending_selection["search_payload"]["categoria"], "hatch")
self.assertEqual(pending_selection["search_payload"]["data_inicio"], "20/03/2026 10:00")
self.assertEqual(pending_selection["search_payload"]["data_fim_prevista"], "21/03/2026 10:00")
context = service.state.get_user_context(7)
self.assertEqual(context["active_domain"], "rental")
self.assertEqual(context["active_task"], "rental_create")
self.assertEqual(context["last_rental_search_payload"]["categoria"], "hatch")
self.assertEqual(context["last_rental_search_payload"]["data_inicio"], "20/03/2026 10:00")
self.assertEqual(context["last_rental_search_payload"]["data_fim_prevista"], "21/03/2026 10:00")
async def test_confirm_pending_review_clears_open_review_draft_after_suggested_time_success(self):
state = FakeState(
entries={
@ -1137,6 +1269,94 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(context["collected_slots"], {})
self.assertEqual(context["flow_snapshots"], {})
async def test_try_confirm_pending_review_executes_pending_reschedule_confirmation(self):
state = FakeState(
entries={
"pending_review_confirmations": {
7: {
"tool_name": "editar_data_revisao",
"payload": {
"protocolo": "REV-TESTE-321",
"nova_data_hora": "14/03/2026 16:30",
},
"expires_at": utc_now() + timedelta(minutes=15),
}
},
"pending_review_management_drafts": {
7: {
"action": "reschedule",
"payload": {"protocolo": "REV-TESTE-321"},
"expires_at": utc_now() + timedelta(minutes=15),
}
},
},
contexts={
7: {
"active_domain": "review",
"active_task": "review_management",
"generic_memory": {},
"shared_memory": {},
"collected_slots": {
"review_management": {"protocolo": "REV-TESTE-321"},
},
"flow_snapshots": {
"review_management": {
"payload": {"protocolo": "REV-TESTE-321"},
"expires_at": utc_now() + timedelta(minutes=15),
},
"review_confirmation": {
"payload": {
"protocolo": "REV-TESTE-321",
"nova_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-321",
"placa": "ABC1C23",
"data_hora": "14/03/2026 16:30",
"status": "Remarcado",
}
)
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"Agendamento atualizado.\nProtocolo: {tool_result['protocolo']}"
)
response = await service._try_confirm_pending_review(
message="sim",
user_id=7,
extracted_review_fields={},
)
self.assertIn("REV-TESTE-321", response)
self.assertEqual(
service.tool_executor.calls,
[("editar_data_revisao", {"protocolo": "REV-TESTE-321", "nova_data_hora": "14/03/2026 16:30"}, 7)],
)
self.assertIsNone(state.get_entry("pending_review_confirmations", 7))
self.assertIsNone(state.get_entry("pending_review_management_drafts", 7))
self.assertIsNone(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):
service = OrquestradorService.__new__(OrquestradorService)
service.normalizer = EntityNormalizer()
@ -4854,6 +5074,34 @@ class OrquestradorLatencyOptimizationTests(unittest.IsolatedAsyncioTestCase):
service._resolve_entities_for_message_plan = lambda message_plan, routed_message: service.normalizer.empty_extraction_payload()
return service
async def test_handle_message_uses_deterministic_rental_bootstrap_before_llm(self):
service = self._build_service()
rental_calls = []
async def fake_try_collect_and_open_rental(**kwargs):
rental_calls.append(kwargs)
return "Fluxo de locacao continuado."
async def should_not_run(*args, **kwargs):
raise AssertionError("nao deveria consultar o LLM para aluguel explicito")
service._try_collect_and_open_rental = fake_try_collect_and_open_rental
service._extract_turn_bundle_with_llm = should_not_run
service._extract_turn_decision_with_llm = should_not_run
service._extract_message_plan_with_llm = should_not_run
service._extract_entities_with_llm = should_not_run
response = await service.handle_message(
"Quero alugar um hatch amanha 10h ate depois de amanha 10h",
user_id=1,
)
self.assertEqual(response, "Fluxo de locacao continuado.")
self.assertEqual(len(rental_calls), 1)
self.assertEqual(rental_calls[0]["message"], "Quero alugar um hatch amanha 10h ate depois de amanha 10h")
self.assertEqual(rental_calls[0]["turn_decision"]["intent"], "rental_create")
self.assertEqual(rental_calls[0]["turn_decision"]["domain"], "rental")
async def test_handle_message_keeps_message_plan_but_skips_entity_extraction_when_turn_decision_is_enough(self):
service = self._build_service()
planner_calls = []
@ -4960,6 +5208,123 @@ class OrquestradorLatencyOptimizationTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(len(entity_calls), 1)
self.assertEqual(response, "Fluxo de venda continuado.")
async def test_handle_message_uses_turn_bundle_when_available(self):
service = self._build_service()
bundle_calls = []
async def fake_extract_turn_bundle(message: str, user_id: int | None):
bundle_calls.append((message, user_id))
return {
"turn_decision": {
"intent": "order_create",
"domain": "sales",
"action": "ask_missing_fields",
"entities": {
"generic_memory": {"orcamento_max": 70000},
"review_fields": {},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {},
},
"missing_fields": ["modelo_veiculo"],
"selection_index": None,
"tool_name": None,
"tool_arguments": {},
"response_to_user": None,
},
"message_plan": {
"orders": [
{
"domain": "sales",
"message": message,
"entities": service.normalizer.empty_extraction_payload(),
}
]
},
"has_turn_decision": True,
"has_message_plan": True,
}
async def should_not_run_turn_decision(message: str, user_id: int | None):
raise AssertionError("nao deveria consultar turn_decision legado quando o bundle estiver completo")
async def should_not_run_message_plan(message: str, user_id: int | None):
raise AssertionError("nao deveria consultar message_plan legado quando o bundle estiver completo")
async def fake_try_collect_and_create_order(**kwargs):
return "Fluxo de venda continuado."
service._extract_turn_bundle_with_llm = fake_extract_turn_bundle
service._extract_turn_decision_with_llm = should_not_run_turn_decision
service._extract_message_plan_with_llm = should_not_run_message_plan
service._try_collect_and_create_order = fake_try_collect_and_create_order
response = await service.handle_message("quero comprar um carro ate 70 mil", user_id=1)
self.assertEqual(len(bundle_calls), 1)
self.assertEqual(response, "Fluxo de venda continuado.")
async def test_handle_message_falls_back_to_legacy_turn_decision_and_message_plan_when_bundle_is_incomplete(self):
service = self._build_service()
bundle_calls = []
turn_decision_calls = []
message_plan_calls = []
async def fake_extract_turn_bundle(message: str, user_id: int | None):
bundle_calls.append((message, user_id))
return {
"turn_decision": service.normalizer.empty_turn_decision(),
"message_plan": service.normalizer.empty_message_plan(message),
"has_turn_decision": True,
"has_message_plan": False,
}
async def fake_extract_turn_decision(message: str, user_id: int | None):
turn_decision_calls.append((message, user_id))
return {
"intent": "order_create",
"domain": "sales",
"action": "ask_missing_fields",
"entities": {
"generic_memory": {"orcamento_max": 70000},
"review_fields": {},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {},
},
"missing_fields": ["modelo_veiculo"],
"selection_index": None,
"tool_name": None,
"tool_arguments": {},
"response_to_user": None,
}
async def fake_extract_message_plan(message: str, user_id: int | None):
message_plan_calls.append((message, user_id))
return {
"orders": [
{
"domain": "sales",
"message": message,
"entities": service.normalizer.empty_extraction_payload(),
}
]
}
async def fake_try_collect_and_create_order(**kwargs):
return "Fluxo de venda continuado."
service._extract_turn_bundle_with_llm = fake_extract_turn_bundle
service._extract_turn_decision_with_llm = fake_extract_turn_decision
service._extract_message_plan_with_llm = fake_extract_message_plan
service._try_collect_and_create_order = fake_try_collect_and_create_order
response = await service.handle_message("quero comprar um carro ate 70 mil", user_id=1)
self.assertEqual(len(bundle_calls), 1)
self.assertEqual(len(turn_decision_calls), 1)
self.assertEqual(len(message_plan_calls), 1)
self.assertEqual(response, "Fluxo de venda continuado.")

Loading…
Cancel
Save