import re from datetime import datetime, timedelta 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 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, ) from app.services.user.mock_customer_service import hydrate_mock_customer_from_cpf # Esse mixin cuida dos fluxos de venda: # criacao de pedido, selecao de veiculo e cancelamento. class OrderFlowMixin: def _decision_intent(self, turn_decision: dict | None) -> str: return str((turn_decision or {}).get("intent") or "").strip().lower() def _has_explicit_order_request(self, message: str) -> bool: 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._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 _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]: context = self._get_user_context(user_id) if not context: return [] stock_results = context.get("last_stock_results") or [] return stock_results if isinstance(stock_results, list) else [] def _get_selected_vehicle(self, user_id: int | None) -> dict | None: context = self._get_user_context(user_id) if not context: return None selected_vehicle = context.get("selected_vehicle") return dict(selected_vehicle) if isinstance(selected_vehicle, dict) else None def _remember_stock_results(self, user_id: int | None, stock_results: list[dict] | None) -> None: context = self._get_user_context(user_id) if not context: return sanitized: list[dict] = [] for item in stock_results or []: if not isinstance(item, dict): continue try: vehicle_id = int(item.get("id")) preco = float(item.get("preco") or 0) except (TypeError, ValueError): continue sanitized.append( { "id": vehicle_id, "modelo": str(item.get("modelo") or "").strip(), "categoria": str(item.get("categoria") or "").strip(), "preco": preco, } ) context["last_stock_results"] = sanitized if sanitized: context["selected_vehicle"] = None def _store_selected_vehicle(self, user_id: int | None, vehicle: dict | None) -> None: if user_id is None: return context = self._get_user_context(user_id) if not context: return context["selected_vehicle"] = dict(vehicle) if isinstance(vehicle, dict) else None 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 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) -> 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"] = "asc" return arguments 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] return None 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 _render_missing_order_fields_prompt(self, missing_fields: list[str]) -> str: 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('id', 'N/A')}] {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) async def _try_list_stock_for_order_selection( self, message: str, user_id: int | None, payload: dict, turn_decision: dict | None = None, ) -> str | None: if user_id is None or 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) if "preco_max" not in arguments and "categoria" not in arguments: return None try: tool_result = await self.registry.execute( "consultar_estoque", arguments, user_id=user_id, ) except HTTPException as exc: return self._http_exception_detail(exc) self._remember_stock_results(user_id=user_id, stock_results=tool_result if isinstance(tool_result, list) else []) return self._fallback_format_tool_result("consultar_estoque", tool_result) def _render_missing_cancel_order_fields_prompt(self, missing_fields: list[str]) -> str: 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_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.state.get_entry("pending_order_drafts", user_id, expire=True) 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 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.state.pop_entry("pending_order_drafts", user_id) return None if draft is None and not has_intent and not explicit_order_request: return None if draft is None: draft = { "payload": {}, "expires_at": datetime.utcnow() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES), } draft["payload"].update(extracted) 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"]) 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.state.set_entry("pending_order_drafts", user_id, draft) 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: draft["payload"].pop("cpf", None) self.state.set_entry("pending_order_drafts", user_id, draft) return "Para seguir com o pedido, preciso de um CPF valido. Pode me informar novamente?" draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES) self.state.set_entry("pending_order_drafts", user_id, draft) 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, ) 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.registry.execute( "realizar_pedido", { "cpf": draft["payload"]["cpf"], "vehicle_id": draft["payload"]["vehicle_id"], }, user_id=user_id, ) except HTTPException as exc: return self._http_exception_detail(exc) self.state.pop_entry("pending_order_drafts", 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.state.get_entry("pending_cancel_order_drafts", user_id, expire=True) active_order_draft = self.state.get_entry("pending_order_drafts", user_id, expire=True) 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_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_create", False) ) and not extracted ): self.state.pop_entry("pending_cancel_order_drafts", user_id) return None if not has_intent and draft is None: return None if draft is None: draft = { "payload": {}, "expires_at": datetime.utcnow() + timedelta(minutes=PENDING_CANCEL_ORDER_DRAFT_TTL_MINUTES), } if ( "motivo" not in extracted and draft["payload"].get("numero_pedido") and not has_intent ): # 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"] = datetime.utcnow() + timedelta(minutes=PENDING_CANCEL_ORDER_DRAFT_TTL_MINUTES) self.state.set_entry("pending_cancel_order_drafts", user_id, draft) 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.registry.execute( "cancelar_pedido", draft["payload"], user_id=user_id, ) except HTTPException as exc: return self._http_exception_detail(exc) self.state.pop_entry("pending_cancel_order_drafts", user_id) return self._fallback_format_tool_result("cancelar_pedido", tool_result)