import re from datetime import datetime, timedelta from app.core.time_utils import utc_now from fastapi import HTTPException from app.db.mock_database import SessionMockLocal from app.db.mock_models import User, Vehicle from app.services.orchestration.technical_normalizer import extract_budget_from_text, extract_cpf_from_text, is_valid_cpf from app.services.orchestration.orchestrator_config import ( CANCEL_ORDER_REQUIRED_FIELDS, ORDER_REQUIRED_FIELDS, PENDING_CANCEL_ORDER_DRAFT_TTL_MINUTES, PENDING_ORDER_DRAFT_TTL_MINUTES, PENDING_ORDER_SELECTION_TTL_MINUTES, ) from app.services.user.mock_customer_service import hydrate_mock_customer_from_cpf from app.services.flows.order_flow_support import OrderFlowStateSupport # Esse mixin cuida dos fluxos de venda: # criacao de pedido, selecao de veiculo e cancelamento. class OrderFlowMixin: @property def _order_flow_state_support(self) -> OrderFlowStateSupport: support = getattr(self, "__order_flow_state_support", None) if support is None: support = OrderFlowStateSupport(self) setattr(self, "__order_flow_state_support", support) return support def _sanitize_stock_results(self, stock_results: list[dict] | None) -> list[dict]: return self._order_flow_state_support.sanitize_stock_results(stock_results) def _get_order_flow_snapshot(self, user_id: int | None, snapshot_key: str) -> dict | None: return self._order_flow_state_support.get_flow_snapshot( user_id=user_id, snapshot_key=snapshot_key, ) def _set_order_flow_snapshot( self, user_id: int | None, snapshot_key: str, value: dict | None, *, active_task: str | None = None, ) -> None: self._order_flow_state_support.set_flow_snapshot( user_id=user_id, snapshot_key=snapshot_key, value=value, active_task=active_task, ) def _get_order_flow_entry(self, bucket: str, user_id: int | None, snapshot_key: str) -> dict | None: return self._order_flow_state_support.get_flow_entry( bucket=bucket, user_id=user_id, snapshot_key=snapshot_key, ) def _set_order_flow_entry( self, bucket: str, user_id: int | None, snapshot_key: str, value: dict, *, active_task: str | None = None, ) -> None: self._order_flow_state_support.set_flow_entry( bucket=bucket, user_id=user_id, snapshot_key=snapshot_key, value=value, active_task=active_task, ) def _pop_order_flow_entry( self, bucket: str, user_id: int | None, snapshot_key: str, *, active_task: str | None = None, ) -> dict | None: return self._order_flow_state_support.pop_flow_entry( bucket=bucket, user_id=user_id, snapshot_key=snapshot_key, active_task=active_task, ) def _decision_intent(self, turn_decision: dict | None) -> str: return str((turn_decision or {}).get("intent") or "").strip().lower() def _has_order_listing_request(self, message: str, turn_decision: dict | None = None) -> bool: normalized = self._normalize_text(message).strip() review_listing_terms = { "agendamento", "agendamentos", "revisao", "revisoes", } if any(term in normalized for term in review_listing_terms): return False if self._decision_intent(turn_decision) == "order_list": return True listing_terms = { "meus pedidos", "meu pedido", "listar pedidos", "liste meus pedidos", "lista de pedidos", "quais sao meus pedidos", "quais sao os meus pedidos", "mostrar pedidos", "mostre meus pedidos", "consultar pedidos", "ver meus pedidos", "acompanhar pedido", "acompanhar pedidos", "status do pedido", "status dos pedidos", } return any(term in normalized for term in listing_terms) def _has_explicit_order_request(self, message: str) -> bool: if self._has_order_listing_request(message): return False normalized = self._normalize_text(message).strip() order_terms = { "comprar", "compra", "pedido", "pedir", "financiar", "financiamento", "simular compra", "realizar pedido", "fazer um pedido", } return any(term in normalized for term in order_terms) def _has_stock_listing_request(self, message: str, turn_decision: dict | None = None) -> bool: if self._has_order_listing_request(message=message, turn_decision=turn_decision): return False if self._decision_intent(turn_decision) == "inventory_search": return True normalized = self._normalize_text(message).strip() stock_terms = { "estoque", "listar", "liste", "mostrar", "mostre", "ver carros", "ver veiculos", "opcoes", "modelos", } return any(term in normalized for term in stock_terms) def _is_valid_cpf(self, cpf: str) -> bool: return is_valid_cpf(cpf) def _extract_order_cpf_attempt(self, message: str) -> str | None: normalized = self._normalize_text(message).strip() digits = re.sub(r"\D", "", str(message or "")) if len(digits) < 3 or len(digits) > 11: return None if "cpf" in normalized: return digits residue = re.sub(r"[\d\s\.\-_/]", "", str(message or "")) if not residue: return digits return None def _try_capture_order_cpf_from_message(self, message: str, payload: dict) -> None: if payload.get("cpf"): return cpf = extract_cpf_from_text(message) if cpf and self._is_valid_cpf(cpf): payload["cpf"] = cpf 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: 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 budget = extract_budget_from_text(message) if budget: normalized_budget = int(round(budget)) generic_memory["orcamento_max"] = normalized_budget context.setdefault("shared_memory", {})["orcamento_max"] = normalized_budget self._save_user_context(user_id=user_id, context=context) def _try_prefill_order_cpf_from_memory(self, user_id: int | None, payload: dict) -> None: if user_id is None or payload.get("cpf"): return context = self._get_user_context(user_id) if not context: return memory = context.get("generic_memory", {}) cpf = memory.get("cpf") if isinstance(cpf, str) and self._is_valid_cpf(cpf): payload["cpf"] = cpf 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"): return db = SessionMockLocal() 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 finally: db.close() 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) def _store_pending_stock_selection(self, user_id: int | None, stock_results: list[dict] | None) -> None: self._order_flow_state_support.store_pending_stock_selection( user_id=user_id, stock_results=stock_results, ) def _get_selected_vehicle(self, user_id: int | None) -> dict | None: return self._order_flow_state_support.get_selected_vehicle(user_id=user_id) def _get_pending_single_vehicle_confirmation(self, user_id: int | None) -> dict | None: return self._order_flow_state_support.get_pending_single_vehicle_confirmation(user_id=user_id) def _remember_stock_results(self, user_id: int | None, stock_results: list[dict] | None) -> None: self._order_flow_state_support.remember_stock_results( user_id=user_id, stock_results=stock_results, ) def _store_selected_vehicle(self, user_id: int | None, vehicle: dict | None) -> None: self._order_flow_state_support.store_selected_vehicle( user_id=user_id, vehicle=vehicle, ) def _store_pending_single_vehicle_confirmation(self, user_id: int | None, vehicle: dict | None) -> None: self._order_flow_state_support.store_pending_single_vehicle_confirmation( user_id=user_id, vehicle=vehicle, ) def _clear_pending_single_vehicle_confirmation(self, user_id: int | None) -> None: self._order_flow_state_support.clear_pending_single_vehicle_confirmation(user_id=user_id) def _vehicle_to_payload(self, vehicle: dict) -> dict: return { "vehicle_id": int(vehicle["id"]), "modelo_veiculo": str(vehicle["modelo"]), "valor_veiculo": round(float(vehicle["preco"]), 2), } def _try_prefill_order_vehicle_from_context(self, user_id: int | None, payload: dict) -> None: if user_id is None or payload.get("vehicle_id"): return if self._get_pending_single_vehicle_confirmation(user_id=user_id): return selected_vehicle = self._get_selected_vehicle(user_id=user_id) if selected_vehicle: payload.update(self._vehicle_to_payload(selected_vehicle)) def _build_stock_lookup_arguments( self, user_id: int | None, payload: dict | None = None, message: str | None = None, ) -> dict: context = self._get_user_context(user_id) generic_memory = context.get("generic_memory", {}) if isinstance(context, dict) else {} source = payload if isinstance(payload, dict) else {} budget = generic_memory.get("orcamento_max") if budget is None: budget = source.get("valor_veiculo") arguments: dict = {} if isinstance(budget, (int, float)) and float(budget) > 0: arguments["preco_max"] = float(budget) perfil = generic_memory.get("perfil_veiculo") if isinstance(perfil, list) and perfil: arguments["categoria"] = str(perfil[0]).strip().lower() arguments["limite"] = 5 arguments["ordenar_preco"] = self._resolve_stock_price_order( has_budget="preco_max" in arguments, message=message, ) return arguments def _resolve_stock_price_order(self, has_budget: bool, message: str | None = None) -> str: if not has_budget: return "asc" normalized = self._normalize_text(message or "").strip() cheaper_first_terms = { "mais barato", "mais baratos", "mais barata", "mais baratas", "menor preco", "menor valor", "mais em conta", "mais economico", "mais economica", "mais acessivel", "mais acessiveis", "baratinho", "baratinhos", } if any(term in normalized for term in cheaper_first_terms): return "asc" return "desc" def _should_refresh_stock_context(self, user_id: int | None, payload: dict | None = None) -> bool: if user_id is None: return False context = self._get_user_context(user_id) if not isinstance(context, dict): return False generic_memory = context.get("generic_memory", {}) if isinstance(context.get("generic_memory"), dict) else {} selected_vehicle = context.get("selected_vehicle") last_stock_results = context.get("last_stock_results") or [] source = payload if isinstance(payload, dict) else {} budget = generic_memory.get("orcamento_max") if isinstance(budget, (int, float)) and float(budget) > 0: if isinstance(selected_vehicle, dict): try: if not bool(selected_vehicle.get("budget_relaxed")) and float(selected_vehicle.get("preco") or 0) > float(budget): return True except (TypeError, ValueError): return True for item in last_stock_results: if not isinstance(item, dict): continue if bool(item.get("budget_relaxed")): continue try: if float(item.get("preco") or 0) > float(budget): return True except (TypeError, ValueError): return True perfil = generic_memory.get("perfil_veiculo") expected_category = str(perfil[0]).strip().lower() if isinstance(perfil, list) and perfil else None if expected_category: if isinstance(selected_vehicle, dict): if str(selected_vehicle.get("categoria") or "").strip().lower() != expected_category: return True for item in last_stock_results: if not isinstance(item, dict): continue if str(item.get("categoria") or "").strip().lower() != expected_category: return True vehicle_budget = source.get("valor_veiculo") if isinstance(vehicle_budget, (int, float)) and isinstance(selected_vehicle, dict): try: if float(selected_vehicle.get("preco") or 0) > float(vehicle_budget): return True except (TypeError, ValueError): return True return False def _reset_order_stock_context(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 context["last_stock_results"] = [] context["selected_vehicle"] = None context["pending_single_vehicle_confirmation"] = None self.state.pop_entry("pending_stock_selections", user_id) self._save_user_context(user_id=user_id, context=context) def _match_vehicle_from_message_index(self, message: str, stock_results: list[dict]) -> dict | None: tokens = [token for token in re.findall(r"\d+", str(message or "")) if token.isdigit()] if not tokens: return None choice = int(tokens[0]) if 1 <= choice <= len(stock_results): return stock_results[choice - 1] return None def _match_vehicle_from_message_model(self, message: str, stock_results: list[dict]) -> dict | None: normalized_message = self._normalize_text(message) matches = [] for item in stock_results: normalized_model = self._normalize_text(str(item.get("modelo") or "")) if normalized_model and normalized_model in normalized_message: matches.append(item) if len(matches) == 1: return matches[0] if len(matches) > 1: normalized_matches = {self._normalize_text(str(item.get("modelo") or "")).strip() for item in matches} if len(normalized_matches) == 1: return matches[0] reference_tokens = self._extract_vehicle_reference_tokens(message) if not reference_tokens: return None scored_matches: list[tuple[int, dict]] = [] for item in stock_results: model_tokens = self._extract_vehicle_reference_tokens(str(item.get("modelo") or "")) if not model_tokens: continue score = self._score_vehicle_reference_tokens(reference_tokens, model_tokens) if score > 0: scored_matches.append((score, item)) if not scored_matches: return None best_score = max(score for score, _ in scored_matches) if best_score <= 0: return None if best_score == 1 and len(reference_tokens) > 1: return None best_matches = [item for score, item in scored_matches if score == best_score] if len(best_matches) == 1: return best_matches[0] normalized_matches = {self._normalize_text(str(item.get("modelo") or "")).strip() for item in best_matches} if len(normalized_matches) == 1: return best_matches[0] return None def _extract_vehicle_reference_tokens(self, text: str) -> list[str]: normalized = self._normalize_text(text) raw_tokens = re.findall(r"[a-z0-9]+", normalized) ignored_tokens = { "a", "ao", "aos", "as", "carro", "carros", "comprar", "compra", "compre", "da", "das", "de", "desse", "dessa", "do", "dos", "esse", "essa", "este", "esta", "fazer", "gostaria", "modelo", "o", "os", "pedido", "pedir", "por", "pra", "quero", "um", "uma", "veiculo", "veiculos", } tokens: list[str] = [] for token in raw_tokens: if token in ignored_tokens or token.isdigit(): continue if len(token) < 3: continue tokens.append(token) return tokens def _score_vehicle_reference_tokens(self, reference_tokens: list[str], model_tokens: list[str]) -> int: score = 0 for reference_token in reference_tokens: for model_token in model_tokens: if reference_token == model_token: score += 1 break if len(reference_token) >= 4 and model_token.startswith(reference_token): score += 1 break return score def _load_vehicle_by_id(self, vehicle_id: int) -> dict | None: db = SessionMockLocal() try: vehicle = db.query(Vehicle).filter(Vehicle.id == vehicle_id).first() if not vehicle: return None return { "id": int(vehicle.id), "modelo": str(vehicle.modelo), "categoria": str(vehicle.categoria), "preco": float(vehicle.preco), } finally: db.close() def _try_resolve_order_vehicle(self, message: str, user_id: int | None, payload: dict) -> dict | None: # Primeiro tenta um vehicle_id explicito; depois tenta casar # a resposta do usuario com a ultima lista de estoque mostrada. vehicle_id = payload.get("vehicle_id") if isinstance(vehicle_id, int) and vehicle_id > 0: return self._load_vehicle_by_id(vehicle_id) stock_results = self._get_last_stock_results(user_id=user_id) selected_from_model = self._match_vehicle_from_message_model(message=message, stock_results=stock_results) if selected_from_model: return selected_from_model selected_from_index = self._match_vehicle_from_message_index(message=message, stock_results=stock_results) if selected_from_index: return selected_from_index normalized_model = self._normalize_text(str(payload.get("modelo_veiculo") or "")) if normalized_model: matches = [ item for item in stock_results if self._normalize_text(str(item.get("modelo") or "")) == normalized_model ] if len(matches) == 1: return matches[0] return None def _should_bootstrap_order_from_context( self, message: str, user_id: int | None, payload: dict | None = None, ) -> bool: if user_id is None: return False pending_single_vehicle = self._get_pending_single_vehicle_confirmation(user_id=user_id) if pending_single_vehicle and ( self._message_confirms_single_vehicle(message=message, vehicle=pending_single_vehicle) or self._is_negative_message(message) ): return True stock_results = self._get_last_stock_results(user_id=user_id) if not stock_results: return False normalized_payload = payload if isinstance(payload, dict) else {} return bool( self._match_vehicle_from_message_model(message=message, stock_results=stock_results) or self._match_vehicle_from_message_index(message=message, stock_results=stock_results) or ( normalized_payload.get("modelo_veiculo") and self._try_resolve_order_vehicle(message=message, user_id=user_id, payload=normalized_payload) ) ) def _should_restart_open_order_draft( self, message: str, user_id: int | None, turn_decision: dict | None = None, ) -> bool: if user_id is None: return False if self.normalizer.normalize_cpf(message): return False if self._should_bootstrap_order_from_context(message=message, user_id=user_id, payload={}): return False current_draft = self.state.get_entry("pending_order_drafts", user_id, expire=True) draft_payload = current_draft.get("payload", {}) if isinstance(current_draft, dict) else {} selected_vehicle = self._get_selected_vehicle(user_id=user_id) if self._has_stock_listing_request(message=message, turn_decision=turn_decision): return bool( (isinstance(draft_payload, dict) and draft_payload.get("vehicle_id")) or selected_vehicle ) if not self._has_explicit_order_request(message): return False normalized = self._normalize_text(message).strip() if extract_budget_from_text(message) is not None: return True restart_terms = { "agora quero comprar", "quero comprar outro", "outro carro", "outro veiculo", "nova busca", "novo pedido", "faixa de preco", "faixa de valor", "ate ", "modelo ", "tipo de carro", "suv", "sedan", "hatch", "pickup", "picape", } return any(term in normalized for term in restart_terms) 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." ) labels = { "cpf": "o CPF do cliente", "vehicle_id": "qual veiculo do estoque voce quer comprar", } itens = [f"- {labels[field]}" for field in missing_fields] return "Para realizar o pedido, preciso dos dados abaixo:\n" + "\n".join(itens) def _render_vehicle_selection_from_stock_prompt(self, stock_results: list[dict]) -> str: lines = ["Para realizar o pedido, escolha primeiro qual veiculo voce quer comprar:"] for idx, item in enumerate(stock_results[:5], start=1): lines.append( f"- {idx}. {item.get('modelo', 'N/A')} " f"({item.get('categoria', 'N/A')}) - R$ {float(item.get('preco', 0)):.2f}" ) lines.append("Pode responder com o numero da lista ou com o modelo do veiculo.") return "\n".join(lines) def _render_single_vehicle_confirmation_prompt(self, vehicle: dict) -> str: return ( "Encontrei 1 opcao para o seu pedido:\n" f"- 1. {vehicle.get('modelo', 'N/A')} ({vehicle.get('categoria', 'N/A')}) - " f"R$ {float(vehicle.get('preco', 0)):.2f}\n" "Posso seguir com essa opcao? Responda com 1, sim ou com o modelo do veiculo." ) def _message_confirms_single_vehicle(self, message: str, vehicle: dict) -> bool: normalized_message = self._normalize_text(message).strip() if self._is_affirmative_message(message): return True if normalized_message == "1": return True if self._match_vehicle_from_message_model(message=message, stock_results=[vehicle]): return True return False async def _try_list_stock_for_order_selection( self, message: str, user_id: int | None, payload: dict, turn_decision: dict | None = None, force: bool = False, ) -> str | None: if user_id is None: return None if not force and not self._has_stock_listing_request(message, turn_decision=turn_decision): return None arguments = self._build_stock_lookup_arguments(user_id=user_id, payload=payload, message=message) if "preco_max" not in arguments and "categoria" not in arguments: return None try: tool_result = await self.tool_executor.execute( "consultar_estoque", arguments, user_id=user_id, ) except HTTPException as exc: return self._http_exception_detail(exc) stock_results = tool_result if isinstance(tool_result, list) else [] self._remember_stock_results(user_id=user_id, stock_results=stock_results) if len(stock_results) == 1: self._store_pending_single_vehicle_confirmation(user_id=user_id, vehicle=stock_results[0]) return self._render_single_vehicle_confirmation_prompt(stock_results[0]) return self._fallback_format_tool_result("consultar_estoque", tool_result) def _render_missing_cancel_order_fields_prompt(self, missing_fields: list[str]) -> str: if missing_fields == ["motivo"]: return "Encontrei o pedido informado. Qual o motivo do cancelamento?" labels = { "numero_pedido": "o numero do pedido (ex.: PED-20260305123456-ABC123)", "motivo": "o motivo do cancelamento", } itens = [f"- {labels[field]}" for field in missing_fields] return "Para cancelar o pedido, preciso dos dados abaixo:\n" + "\n".join(itens) async def _try_handle_order_listing( self, message: str, user_id: int | None, intents: dict | None = None, turn_decision: dict | None = None, ) -> str | None: if user_id is None: return None normalized_intents = self._normalize_intents(intents) if any(term in self._normalize_text(message).strip() for term in {"agendamento", "agendamentos", "revisao", "revisoes"}): return None has_intent = ( self._decision_intent(turn_decision) == "order_list" or normalized_intents.get("order_list", False) or self._has_order_listing_request(message=message, turn_decision=turn_decision) ) if not has_intent: return None try: tool_result = await self.tool_executor.execute( "listar_pedidos", {"limite": 50}, user_id=user_id, ) except HTTPException as exc: return self._http_exception_detail(exc) return self._fallback_format_tool_result("listar_pedidos", tool_result) async def _try_collect_and_create_order( self, message: str, user_id: int | None, extracted_fields: dict | None = None, intents: dict | None = None, turn_decision: dict | None = None, ) -> str | None: if user_id is None: return None normalized_intents = self._normalize_intents(intents) draft = self._get_order_flow_entry("pending_order_drafts", user_id, "order_create") extracted = self._normalize_order_fields(extracted_fields) decision_intent = self._decision_intent(turn_decision) has_intent = decision_intent == "order_create" or normalized_intents.get("order_create", False) explicit_order_request = self._has_explicit_order_request(message) if draft and self._should_restart_open_order_draft( message=message, user_id=user_id, turn_decision=turn_decision, ): self._pop_order_flow_entry( "pending_order_drafts", user_id, "order_create", active_task="order_create", ) self._reset_order_stock_context(user_id=user_id) draft = None should_bootstrap_from_context = draft is None and self._should_bootstrap_order_from_context( message=message, user_id=user_id, payload=extracted, ) if ( draft and not has_intent and ( decision_intent in { "review_schedule", "review_list", "review_cancel", "review_reschedule", "order_cancel", } or normalized_intents.get("review_schedule", False) or normalized_intents.get("review_list", False) or normalized_intents.get("review_cancel", False) or normalized_intents.get("review_reschedule", False) or normalized_intents.get("order_cancel", False) ) and not extracted ): self._pop_order_flow_entry( "pending_order_drafts", user_id, "order_create", active_task="order_create", ) return None if draft is None and not has_intent and not explicit_order_request and not should_bootstrap_from_context: return None if draft is None: draft = { "payload": {}, "expires_at": utc_now() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES), } draft["payload"].update(extracted) self._try_capture_order_cpf_from_message(message=message, payload=draft["payload"]) cpf_attempt = self._extract_order_cpf_attempt(message) if cpf_attempt and not draft["payload"].get("cpf") and not self._is_valid_cpf(cpf_attempt): 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 "Para seguir com o pedido, preciso de um CPF valido. Pode me informar novamente?" self._try_capture_order_budget_from_message(user_id=user_id, message=message, payload=draft["payload"]) self._try_prefill_order_cpf_from_memory(user_id=user_id, payload=draft["payload"]) self._try_prefill_order_vehicle_from_context(user_id=user_id, payload=draft["payload"]) if self._should_refresh_stock_context(user_id=user_id, payload=draft["payload"]): self._reset_order_stock_context(user_id=user_id) draft["payload"].pop("vehicle_id", None) draft["payload"].pop("modelo_veiculo", None) draft["payload"].pop("valor_veiculo", None) pending_single_vehicle = self._get_pending_single_vehicle_confirmation(user_id=user_id) if pending_single_vehicle and not draft["payload"].get("vehicle_id"): if self._message_confirms_single_vehicle(message=message, vehicle=pending_single_vehicle): self._store_selected_vehicle(user_id=user_id, vehicle=pending_single_vehicle) draft["payload"].update(self._vehicle_to_payload(pending_single_vehicle)) pending_single_vehicle = None elif self._is_negative_message(message): self._clear_pending_single_vehicle_confirmation(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 "Sem problema. Me diga outro modelo ou ajuste o valor para eu buscar novas opcoes." elif not self._has_explicit_order_request(message): 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_single_vehicle_confirmation_prompt(pending_single_vehicle) resolved_vehicle = self._try_resolve_order_vehicle( message=message, user_id=user_id, payload=draft["payload"], ) if resolved_vehicle: # Mantem a selecao no estado para que o usuario informe # o CPF depois sem perder o veiculo escolhido. self._store_selected_vehicle(user_id=user_id, vehicle=resolved_vehicle) draft["payload"].update(self._vehicle_to_payload(resolved_vehicle)) cpf_value = draft["payload"].get("cpf") if cpf_value and not self._is_valid_cpf(str(cpf_value)): draft["payload"].pop("cpf", None) self._set_order_flow_entry( "pending_order_drafts", user_id, "order_create", draft, active_task="order_create", ) return "Para seguir com o pedido, preciso de um CPF valido. Pode me informar novamente?" if cpf_value: try: await hydrate_mock_customer_from_cpf( cpf=str(cpf_value), user_id=user_id, ) except ValueError as exc: draft["payload"].pop("cpf", None) self._set_order_flow_entry( "pending_order_drafts", user_id, "order_create", draft, active_task="order_create", ) if str(exc) == "cpf_already_linked": return "Este CPF ja esta vinculado a outro usuario. Pode me informar um CPF diferente?" return "Para seguir com o pedido, preciso de um CPF valido. Pode me informar novamente?" 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", ) missing = [field for field in ORDER_REQUIRED_FIELDS if field not in draft["payload"]] if missing: if "vehicle_id" in missing: stock_response = await self._try_list_stock_for_order_selection( message=message, user_id=user_id, payload=draft["payload"], turn_decision=turn_decision, force=bool(draft) or has_intent or explicit_order_request, ) if stock_response: return stock_response stock_results = self._get_last_stock_results(user_id=user_id) if stock_results: return self._render_vehicle_selection_from_stock_prompt(stock_results) return self._render_missing_order_fields_prompt(missing) try: tool_result = await self.tool_executor.execute( "realizar_pedido", { "cpf": draft["payload"]["cpf"], "vehicle_id": draft["payload"]["vehicle_id"], }, user_id=user_id, ) except HTTPException as exc: error = self.tool_executor.coerce_http_error(exc) if error.get("retryable") and error.get("field"): draft["payload"].pop(str(error["field"]), None) draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES) self._set_order_flow_entry( "pending_order_drafts", user_id, "order_create", draft, active_task="order_create", ) else: self._pop_order_flow_entry( "pending_order_drafts", user_id, "order_create", active_task="order_create", ) self._reset_order_stock_context(user_id=user_id) return self._http_exception_detail(exc) self._pop_order_flow_entry( "pending_order_drafts", user_id, "order_create", active_task="order_create", ) self._reset_order_stock_context(user_id=user_id) if hasattr(self, "_capture_successful_tool_side_effects"): self._capture_successful_tool_side_effects( tool_name="realizar_pedido", arguments={ "cpf": draft["payload"]["cpf"], "vehicle_id": draft["payload"]["vehicle_id"], }, tool_result=tool_result, user_id=user_id, ) return self._fallback_format_tool_result("realizar_pedido", tool_result) async def _try_collect_and_cancel_order( self, message: str, user_id: int | None, extracted_fields: dict | None = None, intents: dict | None = None, turn_decision: dict | None = None, ) -> str | None: if user_id is None: return None normalized_intents = self._normalize_intents(intents) draft = self._get_order_flow_entry("pending_cancel_order_drafts", user_id, "order_cancel") active_order_draft = self._get_order_flow_entry("pending_order_drafts", user_id, "order_create") extracted = self._normalize_cancel_order_fields(extracted_fields) decision_intent = self._decision_intent(turn_decision) has_intent = decision_intent == "order_cancel" or normalized_intents.get("order_cancel", False) if ( draft is None and active_order_draft is not None and ( not has_intent or ("numero_pedido" not in extracted and "motivo" not in extracted) ) ): return None if ( draft and not has_intent and ( decision_intent in { "review_schedule", "review_list", "review_cancel", "review_reschedule", "order_list", "order_create", } or normalized_intents.get("review_schedule", False) or normalized_intents.get("review_list", False) or normalized_intents.get("review_cancel", False) or normalized_intents.get("review_reschedule", False) or normalized_intents.get("order_list", False) or normalized_intents.get("order_create", False) ) and not extracted ): self._pop_order_flow_entry( "pending_cancel_order_drafts", user_id, "order_cancel", active_task="order_cancel", ) return None if not has_intent and draft is None: return None if draft is None: draft = { "payload": {}, "expires_at": utc_now() + timedelta(minutes=PENDING_CANCEL_ORDER_DRAFT_TTL_MINUTES), } if ( "motivo" not in extracted and draft["payload"].get("numero_pedido") and "numero_pedido" not in extracted ): # Quando o pedido ja foi identificado, um texto livre curto # e tratado como motivo do cancelamento. free_text = (message or "").strip() if free_text and len(free_text) >= 4: extracted["motivo"] = free_text draft["payload"].update(extracted) draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_CANCEL_ORDER_DRAFT_TTL_MINUTES) self._set_order_flow_entry( "pending_cancel_order_drafts", user_id, "order_cancel", draft, active_task="order_cancel", ) missing = [field for field in CANCEL_ORDER_REQUIRED_FIELDS if field not in draft["payload"]] if missing: return self._render_missing_cancel_order_fields_prompt(missing) try: tool_result = await self.tool_executor.execute( "cancelar_pedido", draft["payload"], user_id=user_id, ) except HTTPException as exc: error = self.tool_executor.coerce_http_error(exc) if error.get("retryable") and error.get("field"): draft["payload"].pop(str(error["field"]), None) draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_CANCEL_ORDER_DRAFT_TTL_MINUTES) self._set_order_flow_entry( "pending_cancel_order_drafts", user_id, "order_cancel", draft, active_task="order_cancel", ) return self._http_exception_detail(exc) self._pop_order_flow_entry( "pending_cancel_order_drafts", user_id, "order_cancel", active_task="order_cancel", ) if hasattr(self, "_capture_successful_tool_side_effects"): self._capture_successful_tool_side_effects( tool_name="cancelar_pedido", arguments=draft["payload"], tool_result=tool_result, user_id=user_id, ) return self._fallback_format_tool_result("cancelar_pedido", tool_result)