diff --git a/app/services/flows/order_flow.py b/app/services/flows/order_flow.py index ecd07cd..fe9ff17 100644 --- a/app/services/flows/order_flow.py +++ b/app/services/flows/order_flow.py @@ -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, diff --git a/app/services/flows/rental_flow.py b/app/services/flows/rental_flow.py index ea92363..9593828 100644 --- a/app/services/flows/rental_flow.py +++ b/app/services/flows/rental_flow.py @@ -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) - diff --git a/app/services/flows/rental_flow_support.py b/app/services/flows/rental_flow_support.py index 8411fb7..26caa7c 100644 --- a/app/services/flows/rental_flow_support.py +++ b/app/services/flows/rental_flow_support.py @@ -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: diff --git a/app/services/flows/review_flow.py b/app/services/flows/review_flow.py index f02f542..1a4f92d 100644 --- a/app/services/flows/review_flow.py +++ b/app/services/flows/review_flow.py @@ -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"(? 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) diff --git a/app/services/orchestration/orchestrator_context_manager.py b/app/services/orchestration/orchestrator_context_manager.py index 1c3c292..831521b 100644 --- a/app/services/orchestration/orchestrator_context_manager.py +++ b/app/services/orchestration/orchestrator_context_manager.py @@ -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": diff --git a/app/services/orchestration/orquestrador_service.py b/app/services/orchestration/orquestrador_service.py index 5da494b..3baf58d 100644 --- a/app/services/orchestration/orquestrador_service.py +++ b/app/services/orchestration/orquestrador_service.py @@ -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) diff --git a/app/services/orchestration/technical_normalizer.py b/app/services/orchestration/technical_normalizer.py index c775928..62da10a 100644 --- a/app/services/orchestration/technical_normalizer.py +++ b/app/services/orchestration/technical_normalizer.py @@ -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 diff --git a/tests/test_conversation_adjustments.py b/tests/test_conversation_adjustments.py index 66cb5ba..bf62cb2 100644 --- a/tests/test_conversation_adjustments.py +++ b/tests/test_conversation_adjustments.py @@ -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() - - - - diff --git a/tests/test_turn_decision_contract.py b/tests/test_turn_decision_contract.py index d3325ee..50fe81a 100644 --- a/tests/test_turn_decision_contract.py +++ b/tests/test_turn_decision_contract.py @@ -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.")