diff --git a/app/services/flows/review_flow.py b/app/services/flows/review_flow.py index fbf198f..896f2fa 100644 --- a/app/services/flows/review_flow.py +++ b/app/services/flows/review_flow.py @@ -1,5 +1,6 @@ import re from datetime import datetime, timedelta +from app.core.time_utils import utc_now from fastapi import HTTPException @@ -77,7 +78,7 @@ class ReviewFlowMixin: snapshot = self._get_review_flow_snapshot(user_id=user_id, snapshot_key=snapshot_key) if not snapshot: return None - if snapshot.get("expires_at") and snapshot["expires_at"] < datetime.utcnow(): + if snapshot.get("expires_at") and snapshot["expires_at"] < utc_now(): self._set_review_flow_snapshot(user_id=user_id, snapshot_key=snapshot_key, value=None) return None @@ -240,6 +241,19 @@ class ReviewFlowMixin: return payload["data_hora_base"] = date_only + def _is_review_temporal_follow_up(self, message: str, payload: dict | None) -> bool: + if not isinstance(payload, dict): + return False + if payload.get("data_hora"): + return False + has_time = bool(self.normalizer.extract_hhmm_from_text(message)) + if has_time and payload.get("data_hora_base"): + return True + has_date_only = bool(self._extract_review_date_only_text(message)) + if has_date_only and not payload.get("data_hora"): + return True + return False + def _infer_review_management_action( self, message: str, @@ -367,7 +381,7 @@ class ReviewFlowMixin: draft = { "action": action, "payload": {}, - "expires_at": datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES), + "expires_at": utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES), } else: if has_reschedule_intent: @@ -393,7 +407,7 @@ class ReviewFlowMixin: extracted["motivo"] = free_text draft["payload"].update(extracted) - draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES) + draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES) self._set_review_flow_entry( "pending_review_management_drafts", user_id, @@ -419,7 +433,7 @@ class ReviewFlowMixin: 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"] = datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES) + draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES) self._set_review_flow_entry( "pending_review_management_drafts", user_id, @@ -452,7 +466,7 @@ class ReviewFlowMixin: 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"] = datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES) + draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES) self._set_review_flow_entry( "pending_review_management_drafts", user_id, @@ -469,7 +483,7 @@ class ReviewFlowMixin: ) return self._fallback_format_tool_result("cancelar_agendamento_revisao", tool_result) - def _render_missing_review_fields_prompt(self, missing_fields: list[str]) -> str: + def _render_missing_review_fields_prompt(self, missing_fields: list[str], payload: dict | None = None) -> str: labels = { "placa": "a placa do veiculo", "data_hora": "a data e hora desejada para a revisao", @@ -478,6 +492,14 @@ class ReviewFlowMixin: "km": "a quilometragem atual (km)", "revisao_previa_concessionaria": "se ja fez revisao na concessionaria (sim/nao)", } + if isinstance(payload, dict) and payload.get("data_hora_base") and "data_hora" in missing_fields: + itens = ["- o horario desejado para a revisao"] + itens.extend(f"- {labels[field]}" for field in missing_fields if field != "data_hora") + return ( + f"Perfeito. Tenho a data {payload['data_hora_base']}. " + "Para agendar sua revisao, ainda preciso dos dados abaixo:\n" + + "\n".join(itens) + ) itens = [f"- {labels[field]}" for field in missing_fields] return "Para agendar sua revisao, preciso dos dados abaixo:\n" + "\n".join(itens) @@ -535,7 +557,7 @@ class ReviewFlowMixin: user_id, { "payload": sanitized, - "expires_at": datetime.utcnow() + timedelta(minutes=LAST_REVIEW_PACKAGE_TTL_MINUTES), + "expires_at": utc_now() + timedelta(minutes=LAST_REVIEW_PACKAGE_TTL_MINUTES), }, ) @@ -624,7 +646,7 @@ class ReviewFlowMixin: if not extracted: draft = { "payload": {}, - "expires_at": datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES), + "expires_at": utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES), } self._set_review_flow_entry( "pending_review_drafts", @@ -652,7 +674,7 @@ class ReviewFlowMixin: if draft is None: draft = { "payload": seed_payload, - "expires_at": datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES), + "expires_at": utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES), } else: for key, value in seed_payload.items(): @@ -708,7 +730,7 @@ class ReviewFlowMixin: "review_reuse_confirmation", { "payload": last_package, - "expires_at": datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES), + "expires_at": utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES), }, ) self._log_review_flow_source(source="last_review_package", payload=last_package) @@ -724,13 +746,14 @@ class ReviewFlowMixin: ) and not extracted ): - self._pop_review_flow_entry( - "pending_review_drafts", - user_id, - "review_schedule", - active_task="review_schedule", - ) - return None + if not self._is_review_temporal_follow_up(message=message, payload=draft.get("payload")): + self._pop_review_flow_entry( + "pending_review_drafts", + user_id, + "review_schedule", + active_task="review_schedule", + ) + return None bootstrap_payload = dict(extracted) self._supplement_review_fields_from_message(message=message, payload=bootstrap_payload) @@ -749,7 +772,7 @@ class ReviewFlowMixin: review_flow_source = "active_domain_fallback" if should_bootstrap_from_context and not has_intent else "intent_bootstrap" draft = { "payload": dict(bootstrap_payload), - "expires_at": datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES), + "expires_at": utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES), } draft["payload"].update(extracted) @@ -768,7 +791,7 @@ class ReviewFlowMixin: draft["payload"]["revisao_previa_concessionaria"] = True elif self._is_negative_message(message): draft["payload"]["revisao_previa_concessionaria"] = False - draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES) + draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES) self._set_review_flow_entry( "pending_review_drafts", user_id, @@ -785,7 +808,7 @@ class ReviewFlowMixin: f"Perfeito. Tenho a data {draft['payload']['data_hora_base']}. " "Agora me informe o horario desejado para a revisao." ) - return self._render_missing_review_fields_prompt(missing) + return self._render_missing_review_fields_prompt(missing, payload=draft["payload"]) try: tool_result = await self.tool_executor.execute( @@ -803,7 +826,7 @@ class ReviewFlowMixin: ) if error.get("retryable") and error.get("field"): draft["payload"].pop(str(error["field"]), None) - draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES) + draft["expires_at"] = utc_now() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES) self._set_review_flow_entry( "pending_review_drafts", user_id, diff --git a/app/services/orchestration/entity_normalizer.py b/app/services/orchestration/entity_normalizer.py index f264701..ec19f8b 100644 --- a/app/services/orchestration/entity_normalizer.py +++ b/app/services/orchestration/entity_normalizer.py @@ -1,3 +1,4 @@ +import ast import json import logging import re @@ -24,6 +25,7 @@ class EntityNormalizer: "marcar_revisao": "agendar_revisao", "agendar revisao": "agendar_revisao", "schedule_review": "agendar_revisao", + "agendar_revisao_veiculo": "agendar_revisao", "list_reviews": "listar_agendamentos_revisao", "listar_revisoes": "listar_agendamentos_revisao", "listar_agendamentos": "listar_agendamentos_revisao", @@ -57,6 +59,66 @@ class EntityNormalizer: "cancel_flow": "cancel_active_flow", "reset_context": "clear_context", } + _TURN_DOMAIN_ALIASES = { + "service": "review", + "services": "review", + "post_sales": "review", + "after_sales": "review", + "purchase": "sales", + "buy": "sales", + "order": "sales", + "orders": "sales", + "conversation": "general", + "chat": "general", + } + _TURN_VALID_DOMAINS = {"review", "sales", "general"} + _TURN_VALID_INTENTS = { + "review_schedule", + "review_list", + "review_cancel", + "review_reschedule", + "order_create", + "order_list", + "order_cancel", + "inventory_search", + "conversation_reset", + "queue_continue", + "discard_queue", + "cancel_active_flow", + "general", + } + _TURN_VALID_ACTIONS = { + "collect_review_schedule", + "collect_review_management", + "collect_order_create", + "collect_order_cancel", + "ask_missing_fields", + "answer_user", + "call_tool", + "clear_context", + "continue_queue", + "discard_queue", + "cancel_active_flow", + } + _TURN_TOP_LEVEL_FIELD_ALIASES = { + "response": "response_to_user", + "answer": "response_to_user", + "reply": "response_to_user", + "message": "response_to_user", + "tool": "tool_name", + "function_name": "tool_name", + "function": "tool_name", + "arguments": "tool_arguments", + "args": "tool_arguments", + "tool_args": "tool_arguments", + "tool_parameters": "tool_arguments", + "missing": "missing_fields", + "required_fields": "missing_fields", + "missing_data": "missing_fields", + "selected_index": "selection_index", + "choice_index": "selection_index", + "selected_option_index": "selection_index", + } _ORDER_MISSING_FIELD_ALIASES = { "modelo_carro": "vehicle_id", "modelo_do_carro": "vehicle_id", @@ -64,6 +126,35 @@ class EntityNormalizer: "veiculo": "vehicle_id", "carro": "vehicle_id", } + _TURN_MISSING_FIELD_ALIASES = { + **_ORDER_MISSING_FIELD_ALIASES, + "date": "data_hora", + "date_time": "data_hora", + "datetime": "data_hora", + "data": "data_hora", + "data_e_hora": "data_hora", + "data_hora": "data_hora", + "data_agendamento": "data_hora", + "horario": "data_hora", + "hora": "data_hora", + "time": "data_hora", + "modelo": "modelo", + "modelo_veiculo": "modelo", + "vehicle_model": "modelo", + "ano_veiculo": "ano", + "vehicle_year": "ano", + "quilometragem": "km", + "quilometragem_atual": "km", + "vehicle_km": "km", + "revisao_previa": "revisao_previa_concessionaria", + "reviewed_before": "revisao_previa_concessionaria", + "numero": "numero_pedido", + "order_number": "numero_pedido", + "order_id": "numero_pedido", + "review_id": "protocolo", + "schedule_id": "protocolo", + "new_datetime": "nova_data_hora", + } _TOOL_ARGUMENT_ALIASES = { "cancelar_pedido": { "order_id": "numero_pedido", @@ -115,6 +206,10 @@ class EntityNormalizer: "vehicle_km": "km", "data": "data_hora", "datetime": "data_hora", + "data_agendamento": "data_agendamento", + "appointment_date": "data_agendamento", + "horario_agendamento": "horario_agendamento", + "appointment_time": "horario_agendamento", "reviewed_before": "revisao_previa_concessionaria", "revisao_previa": "revisao_previa_concessionaria", }, @@ -197,21 +292,66 @@ class EntityNormalizer: candidate = (text or "").strip() if not candidate: return None - if candidate.startswith("```"): - candidate = re.sub(r"^```(?:json)?\s*", "", candidate, flags=re.IGNORECASE) - candidate = re.sub(r"\s*```$", "", candidate) - try: - return json.loads(candidate) - except json.JSONDecodeError: - match = re.search(r"\{.*\}", candidate, flags=re.DOTALL) - if not match: - logger.warning("Extracao sem JSON valido no texto retornado.") - return None + + candidates: list[str] = [] + stripped_candidate = self._strip_json_fence(candidate) + for option in (stripped_candidate, candidate): + option = str(option or "").strip() + if option and option not in candidates: + candidates.append(option) + + match = re.search(r"\{.*\}", stripped_candidate or candidate, flags=re.DOTALL) + if match: + clipped = match.group(0).strip() + if clipped and clipped not in candidates: + candidates.append(clipped) + + for option in candidates: + parsed = self._try_parse_json_candidate(option) + if isinstance(parsed, dict): + return parsed + + logger.warning("Extracao sem JSON valido no texto retornado.") + return None + + def _strip_json_fence(self, candidate: str) -> str: + stripped = str(candidate or "").strip() + if stripped.startswith("```"): + stripped = re.sub(r"^```(?:json)?\s*", "", stripped, flags=re.IGNORECASE) + stripped = re.sub(r"\s*```$", "", stripped) + return stripped.strip() + + def _try_parse_json_candidate(self, candidate: str): + normalized = str(candidate or "").strip() + if not normalized: + return None + normalized = ( + normalized.replace("\u201c", '"') + .replace("\u201d", '"') + .replace("\u2018", "'") + .replace("\u2019", "'") + .replace(chr(0x201C), '"') + .replace(chr(0x201D), '"') + .replace(chr(0x2018), "'") + .replace(chr(0x2019), "'") + ) + variants = [normalized] + without_trailing_commas = re.sub(r",(\s*[}\]])", r"\1", normalized) + if without_trailing_commas != normalized: + variants.append(without_trailing_commas) + + for variant in variants: try: - return json.loads(match.group(0)) + return json.loads(variant) except json.JSONDecodeError: - logger.warning("Extracao com JSON invalido apos recorte.") - return None + pass + try: + parsed = ast.literal_eval(variant) + except (ValueError, SyntaxError): + continue + if isinstance(parsed, dict): + return parsed + return None def coerce_turn_decision(self, payload) -> dict: if not isinstance(payload, dict): @@ -219,8 +359,12 @@ class EntityNormalizer: payload = self._normalize_turn_decision_payload(payload) try: model = TurnDecision.model_validate(payload) - except ValidationError: - logger.warning("Decisao de turno invalida; usando fallback estruturado.") + except ValidationError as exc: + details = "; ".join( + f"{'.'.join(str(part) for part in error.get('loc', []))}: {error.get('msg', 'erro')}" + for error in exc.errors()[:3] + ) + logger.warning("Decisao de turno invalida; usando fallback estruturado. detalhes=%s", details or "n/a") return self.empty_turn_decision() normalized_entities = { @@ -237,30 +381,44 @@ class EntityNormalizer: return dumped def _normalize_turn_decision_payload(self, payload: dict) -> dict: - normalized = dict(payload) - - raw_intent = self.normalize_text(str(normalized.get("intent") or "")).replace("-", "_").replace(" ", "_") - if raw_intent in self._TURN_INTENT_ALIASES: - normalized["intent"] = self._TURN_INTENT_ALIASES[raw_intent] - - raw_action = self.normalize_text(str(normalized.get("action") or "")).replace("-", "_").replace(" ", "_") - if raw_action in self._TURN_ACTION_ALIASES: - normalized["action"] = self._TURN_ACTION_ALIASES[raw_action] - - missing_fields = normalized.get("missing_fields") - if isinstance(missing_fields, list): - normalized["missing_fields"] = self._normalize_turn_missing_fields(missing_fields) - - entities = normalized.get("entities") - if isinstance(entities, dict): - normalized["entities"] = dict(entities) + normalized = self._unwrap_turn_decision_payload(payload) + + tool_call = normalized.get("tool_call") + if isinstance(tool_call, dict): + if "tool_name" not in normalized: + normalized["tool_name"] = tool_call.get("tool_name") or tool_call.get("name") + if "tool_arguments" not in normalized: + normalized["tool_arguments"] = tool_call.get("tool_arguments") or tool_call.get("arguments") or {} + + for alias, canonical in self._TURN_TOP_LEVEL_FIELD_ALIASES.items(): + if canonical not in normalized and alias in normalized: + normalized[canonical] = normalized.get(alias) + + normalized["domain"] = self._normalize_turn_domain(normalized.get("domain")) + normalized["intent"] = self._normalize_turn_intent(normalized.get("intent")) + normalized["action"] = self._normalize_turn_action(normalized.get("action")) + normalized["response_to_user"] = self._normalize_turn_response(normalized.get("response_to_user")) + normalized["selection_index"] = self._normalize_turn_selection_index(normalized.get("selection_index")) + normalized["missing_fields"] = self._normalize_turn_missing_fields(normalized.get("missing_fields")) + embedded_intents = self._extract_turn_intents(normalized) + normalized["entities"] = self._normalize_turn_entities(normalized) + + if normalized["intent"] == "general": + inferred_intent = self._infer_primary_turn_intent(embedded_intents) + if inferred_intent: + normalized["intent"] = inferred_intent + if normalized["domain"] == "general": + normalized["domain"] = self._domain_from_turn_intent(normalized.get("intent")) tool_name = self.normalize_tool_name(normalized.get("tool_name")) - if tool_name: - normalized["tool_name"] = tool_name + normalized["tool_name"] = tool_name or None tool_arguments = normalized.get("tool_arguments") if tool_name and isinstance(tool_arguments, dict): normalized["tool_arguments"] = self.normalize_tool_arguments(tool_name, tool_arguments) + else: + normalized["tool_arguments"] = tool_arguments if isinstance(tool_arguments, dict) else {} + + normalized = self._coerce_incomplete_action_to_collection(normalized) if self._should_route_order_alias_to_collection(normalized): normalized["action"] = "collect_order_create" @@ -269,15 +427,296 @@ class EntityNormalizer: normalized = self._coerce_incomplete_tool_call_to_collection(normalized) + if ( + normalized.get("intent") == "general" + and not normalized.get("tool_name") + and not any((normalized.get("entities") or {}).values()) + ): + normalized["domain"] = "general" + + return { + "intent": normalized.get("intent") or "general", + "domain": normalized.get("domain") or "general", + "action": normalized.get("action") or "answer_user", + "entities": normalized.get("entities") if isinstance(normalized.get("entities"), dict) else self.empty_turn_decision()["entities"], + "missing_fields": normalized.get("missing_fields") if isinstance(normalized.get("missing_fields"), list) else [], + "selection_index": normalized.get("selection_index"), + "tool_name": normalized.get("tool_name"), + "tool_arguments": normalized.get("tool_arguments") if isinstance(normalized.get("tool_arguments"), dict) else {}, + "response_to_user": normalized.get("response_to_user"), + } + + def _unwrap_turn_decision_payload(self, payload: dict) -> dict: + normalized = dict(payload) + if any(key in normalized for key in ("intent", "domain", "action", "entities", "tool_name", "tool_arguments")): + return normalized + for key in ("turn_decision", "decision", "payload", "data"): + nested = normalized.get(key) + if isinstance(nested, dict): + return dict(nested) return normalized - def _normalize_turn_missing_fields(self, missing_fields: list) -> list[str]: + def _normalize_turn_domain(self, value) -> str: + candidate = self.normalize_text(str(value or "")).replace("-", "_").replace(" ", "_") + candidate = self._TURN_DOMAIN_ALIASES.get(candidate, candidate) + return candidate if candidate in self._TURN_VALID_DOMAINS else "general" + + def _normalize_turn_intent(self, value) -> str: + candidate = self.normalize_text(str(value or "")).replace("-", "_").replace(" ", "_") + candidate = self._TURN_INTENT_ALIASES.get(candidate, candidate) + extra_aliases = { + "schedule_review": "review_schedule", + "review_schedule_follow_up": "review_schedule", + "review_schedule_followup": "review_schedule", + "review_management": "review_reschedule", + "reset": "conversation_reset", + "restart_conversation": "conversation_reset", + "continue_queue": "queue_continue", + "next_order": "queue_continue", + "cancel_current_flow": "cancel_active_flow", + } + candidate = extra_aliases.get(candidate, candidate) + return candidate if candidate in self._TURN_VALID_INTENTS else "general" + + def _normalize_turn_action(self, value) -> str: + candidate = self.normalize_text(str(value or "")).replace("-", "_").replace(" ", "_") + candidate = self._TURN_ACTION_ALIASES.get(candidate, candidate) + extra_aliases = { + "answer": "answer_user", + "reply": "answer_user", + "respond": "answer_user", + "tool_call": "call_tool", + "execute_tool": "call_tool", + "use_tool": "call_tool", + "ask_for_missing_fields": "ask_missing_fields", + "request_missing_fields": "ask_missing_fields", + "collect_missing_fields": "ask_missing_fields", + "continue": "continue_queue", + "next_order": "continue_queue", + "discard": "discard_queue", + "reset": "clear_context", + "clear_conversation": "clear_context", + } + candidate = extra_aliases.get(candidate, candidate) + return candidate if candidate in self._TURN_VALID_ACTIONS else "answer_user" + + def _normalize_turn_response(self, value) -> str | None: + if value is None: + return None + if isinstance(value, str): + stripped = value.strip() + return stripped or None + if isinstance(value, (int, float)) and not isinstance(value, bool): + return str(value) + return None + + def _normalize_turn_selection_index(self, value) -> int | None: + if value in (None, "") or isinstance(value, bool): + return None + if isinstance(value, (int, float)): + candidate = int(value) + return candidate if candidate >= 0 else None + text = self.normalize_text(str(value or "")).strip() + ordinal_aliases = { + "primeiro": 0, + "primeira": 0, + "segundo": 1, + "segunda": 1, + "terceiro": 2, + "terceira": 2, + } + if text in ordinal_aliases: + return ordinal_aliases[text] + match = re.search(r"\d+", text) + if not match: + return None + candidate = int(match.group(0)) + return candidate if candidate >= 0 else None + + def _normalize_turn_entities(self, payload: dict) -> dict: + container = payload.get("entities") if isinstance(payload.get("entities"), dict) else {} + normalized_entities: dict[str, dict] = {} + for key in ( + "generic_memory", + "review_fields", + "review_management_fields", + "order_fields", + "cancel_order_fields", + ): + merged: dict = {} + top_level_value = payload.get(key) + if isinstance(top_level_value, dict): + merged.update(top_level_value) + nested_value = container.get(key) + if isinstance(nested_value, dict): + merged.update(nested_value) + normalized_entities[key] = merged + return normalized_entities + + def _extract_turn_intents(self, payload: dict) -> dict: + candidates: list = [] + if isinstance(payload.get("intents"), dict): + candidates.append(payload.get("intents")) + entities = payload.get("entities") if isinstance(payload.get("entities"), dict) else {} + if isinstance(entities.get("intents"), dict): + candidates.append(entities.get("intents")) + for candidate in candidates: + normalized = self.normalize_intents(candidate) + if any(normalized.values()): + return normalized + return {} + + def _infer_primary_turn_intent(self, intents: dict) -> str | None: + if not isinstance(intents, dict): + return None + priority = ( + "review_schedule", + "review_reschedule", + "review_cancel", + "review_list", + "order_create", + "order_cancel", + "order_list", + ) + for key in priority: + if intents.get(key): + return key + return None + + def _domain_from_turn_intent(self, intent: str | None) -> str: + if intent in {"review_schedule", "review_list", "review_cancel", "review_reschedule"}: + return "review" + if intent in {"order_create", "order_list", "order_cancel", "inventory_search", "queue_continue", "discard_queue", "cancel_active_flow"}: + return "sales" + return "general" + + def _coerce_incomplete_action_to_collection(self, payload: dict) -> dict: + action = payload.get("action") + collection_action = self._infer_collection_action(payload) + + if action == "ask_missing_fields" and not payload.get("response_to_user"): + if collection_action: + payload["action"] = collection_action + payload["response_to_user"] = None + else: + payload["action"] = "answer_user" + payload["missing_fields"] = [] + return payload + + if action == "call_tool" and not str(payload.get("tool_name") or "").strip(): + if collection_action: + payload["action"] = collection_action + payload = self._merge_tool_arguments_into_collection_entities(payload, collection_action) + else: + payload["action"] = "answer_user" + payload["tool_name"] = None + payload["tool_arguments"] = {} + payload["response_to_user"] = payload.get("response_to_user") + return payload + + return payload + + def _infer_collection_action(self, payload: dict) -> str | None: + intent = str(payload.get("intent") or "").strip() + if intent == "review_schedule": + return "collect_review_schedule" + if intent in {"review_cancel", "review_reschedule"}: + return "collect_review_management" + if intent == "order_create": + return "collect_order_create" + if intent == "order_cancel": + return "collect_order_cancel" + + entities = payload.get("entities") if isinstance(payload.get("entities"), dict) else {} + domain = str(payload.get("domain") or "").strip() + if domain == "review": + if entities.get("review_management_fields"): + return "collect_review_management" + if entities.get("review_fields"): + return "collect_review_schedule" + if domain == "sales": + if entities.get("cancel_order_fields"): + return "collect_order_cancel" + if entities.get("order_fields") or entities.get("generic_memory"): + return "collect_order_create" + return None + + def _merge_tool_arguments_into_collection_entities(self, payload: dict, collection_action: str) -> dict: + entities = payload.get("entities") if isinstance(payload.get("entities"), dict) else {} + payload["entities"] = entities + raw_arguments = payload.get("tool_arguments") if isinstance(payload.get("tool_arguments"), dict) else {} + intent = str(payload.get("intent") or "").strip() + + if collection_action == "collect_order_cancel": + normalized_arguments = self.normalize_tool_arguments("cancelar_pedido", raw_arguments) + entities["cancel_order_fields"] = self.normalize_cancel_order_fields( + { + **(entities.get("cancel_order_fields") or {}), + **normalized_arguments, + } + ) + return payload + + if collection_action == "collect_order_create": + normalized_arguments = self.normalize_tool_arguments("realizar_pedido", raw_arguments) + entities["order_fields"] = self.normalize_order_fields( + { + **(entities.get("order_fields") or {}), + **normalized_arguments, + } + ) + return payload + + if collection_action == "collect_review_schedule": + normalized_arguments = self.normalize_tool_arguments("agendar_revisao", raw_arguments) + entities["review_fields"] = self.normalize_review_fields( + { + **(entities.get("review_fields") or {}), + **normalized_arguments, + } + ) + return payload + + if collection_action == "collect_review_management": + tool_name = "editar_data_revisao" if intent == "review_reschedule" else "cancelar_agendamento_revisao" + normalized_arguments = self.normalize_tool_arguments(tool_name, raw_arguments) + entities["review_management_fields"] = self.normalize_review_management_fields( + { + **(entities.get("review_management_fields") or {}), + **normalized_arguments, + } + ) + return payload + + return payload + + def _normalize_turn_missing_fields(self, missing_fields) -> list[str]: + if missing_fields is None: + return [] + raw_fields = missing_fields if isinstance(missing_fields, list) else [missing_fields] normalized_fields: list[str] = [] - for field in missing_fields: - candidate = self.normalize_text(str(field or "")).replace("-", "_").replace(" ", "_") - canonical = self._ORDER_MISSING_FIELD_ALIASES.get(candidate, candidate) - if canonical and canonical not in normalized_fields: - normalized_fields.append(canonical) + for field in raw_fields: + if field in (None, ""): + continue + text_value = str(field or "").strip() + if not text_value: + continue + normalized_value = self.normalize_text(text_value).replace("-", "_").replace(" ", "_") + segments = [normalized_value] + if normalized_value not in self._TURN_MISSING_FIELD_ALIASES: + split_segments = [segment for segment in re.split(r"[,;/]", normalized_value) if segment] + if len(split_segments) > 1: + segments = split_segments + for segment in segments: + if segment not in self._TURN_MISSING_FIELD_ALIASES and "_e_" in segment: + for part in (item for item in segment.split("_e_") if item): + canonical = self._TURN_MISSING_FIELD_ALIASES.get(part, part) + if canonical and canonical not in normalized_fields: + normalized_fields.append(canonical) + continue + canonical = self._TURN_MISSING_FIELD_ALIASES.get(segment, segment) + if canonical and canonical not in normalized_fields: + normalized_fields.append(canonical) return normalized_fields def _should_route_order_alias_to_collection(self, payload: dict) -> bool: @@ -454,6 +893,14 @@ class EntityNormalizer: return self.normalize_review_management_fields(normalized_arguments) if normalized_tool_name == "agendar_revisao": + schedule_date = str(normalized_arguments.pop("data_agendamento", "") or "").strip() + schedule_time = str(normalized_arguments.pop("horario_agendamento", "") or "").strip() + if "data_hora" not in normalized_arguments: + combined_datetime = self.normalize_review_datetime_text( + " ".join(part for part in (schedule_date, schedule_time) if part) + ) + if combined_datetime: + normalized_arguments["data_hora"] = combined_datetime return self.normalize_review_fields(normalized_arguments) return normalized_arguments diff --git a/app/services/orchestration/orquestrador_service.py b/app/services/orchestration/orquestrador_service.py index 7c0dd8d..9abcfb3 100644 --- a/app/services/orchestration/orquestrador_service.py +++ b/app/services/orchestration/orquestrador_service.py @@ -1,5 +1,6 @@ import logging from datetime import datetime, timedelta +from app.core.time_utils import utc_now from time import perf_counter from uuid import uuid4 @@ -88,6 +89,15 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): ) if reset_override: return reset_override + if hasattr(self, "policy"): + pending_switch_override = self._handle_context_switch( + message=message, + user_id=user_id, + target_domain_hint="general", + turn_decision=None, + ) + if pending_switch_override: + return await finish(pending_switch_override) pending_stock_selection_follow_up = await self._try_handle_pending_stock_selection_follow_up( message=message, user_id=user_id, @@ -361,8 +371,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): ) if llm_result["tool_call"]: - tool_name = llm_result["tool_call"]["name"] - arguments = llm_result["tool_call"]["arguments"] + tool_name, arguments = self._normalize_tool_invocation( + tool_name=llm_result["tool_call"]["name"], + arguments=llm_result["tool_call"]["arguments"], + user_id=user_id, + ) try: tool_result = await self._execute_tool_with_trace( @@ -645,7 +658,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): if not tool_name or tool_name in ORCHESTRATION_CONTROL_TOOLS: return None - arguments = decision.get("tool_arguments") if isinstance(decision.get("tool_arguments"), dict) else {} + tool_name, arguments = self._normalize_tool_invocation( + tool_name=tool_name, + arguments=decision.get("tool_arguments") if isinstance(decision.get("tool_arguments"), dict) else {}, + user_id=user_id, + ) try: tool_result = await self._execute_tool_with_trace( tool_name, @@ -1620,7 +1637,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): payload["data_hora"] = suggested_iso self.state.set_entry("pending_review_confirmations", user_id, { "payload": payload, - "expires_at": datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_TTL_MINUTES), + "expires_at": utc_now() + timedelta(minutes=PENDING_REVIEW_TTL_MINUTES), }) async def _try_confirm_pending_review( @@ -1665,7 +1682,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): ) return self._http_exception_detail(exc) - self.state.pop_entry("pending_review_confirmations", user_id) + self._reset_pending_review_states(user_id=user_id) self._store_last_review_package(user_id=user_id, payload=payload) return self._fallback_format_tool_result("agendar_revisao", tool_result) @@ -1681,7 +1698,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): self.state.pop_entry("pending_review_confirmations", user_id) return self._http_exception_detail(exc) - self.state.pop_entry("pending_review_confirmations", user_id) + self._reset_pending_review_states(user_id=user_id) self._store_last_review_package(user_id=user_id, payload=pending.get("payload")) return self._fallback_format_tool_result("agendar_revisao", tool_result) @@ -1750,7 +1767,64 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): ) raise + def _merge_pending_draft_tool_arguments( + self, + tool_name: str, + arguments: dict, + user_id: int | None, + ) -> dict: + if user_id is None or not isinstance(arguments, dict): + return dict(arguments or {}) + if not hasattr(self, "state") or self.state is None: + return dict(arguments) + + bucket_map = { + "agendar_revisao": "pending_review_drafts", + "realizar_pedido": "pending_order_drafts", + "cancelar_pedido": "pending_cancel_order_drafts", + "cancelar_agendamento_revisao": "pending_review_management_drafts", + "editar_data_revisao": "pending_review_management_drafts", + } + bucket = bucket_map.get(tool_name) + if not bucket: + return dict(arguments) + + draft = self.state.get_entry(bucket, user_id, expire=True) + if not isinstance(draft, dict): + return dict(arguments) + payload = draft.get("payload") + if not isinstance(payload, dict): + return dict(arguments) + + merged_arguments = dict(payload) + merged_arguments.update(arguments) + return merged_arguments + + def _normalize_tool_invocation( + self, + tool_name: str, + arguments: dict | None, + user_id: int | None, + ) -> tuple[str, dict]: + normalizer = getattr(self, "normalizer", None) + if normalizer is None: + normalizer = EntityNormalizer() + self.normalizer = normalizer + normalized_tool_name = normalizer.normalize_tool_name(tool_name) or str(tool_name or "").strip() + normalized_arguments = normalizer.normalize_tool_arguments(normalized_tool_name, arguments or {}) + normalized_arguments = self._merge_pending_draft_tool_arguments( + tool_name=normalized_tool_name, + arguments=normalized_arguments, + user_id=user_id, + ) + return normalized_tool_name, normalized_arguments + async def _execute_tool_with_trace(self, tool_name: str, arguments: dict, user_id: int | None): + tool_name, arguments = self._normalize_tool_invocation( + tool_name=tool_name, + arguments=arguments, + user_id=user_id, + ) started_at = perf_counter() try: result = await self.tool_executor.execute(tool_name, arguments, user_id=user_id) diff --git a/app/services/orchestration/technical_normalizer.py b/app/services/orchestration/technical_normalizer.py index 84007b7..c775928 100644 --- a/app/services/orchestration/technical_normalizer.py +++ b/app/services/orchestration/technical_normalizer.py @@ -255,9 +255,9 @@ def normalize_review_datetime_text(value, now_provider=None) -> str | None: normalized = normalize_text(text) day_offset = None - if "amanha" in normalized: + if "amanha" in normalized or "tomorrow" in normalized: day_offset = 1 - elif "hoje" in normalized: + elif "hoje" in normalized or "today" in normalized: day_offset = 0 if day_offset is None: return None diff --git a/tests/test_conversation_adjustments.py b/tests/test_conversation_adjustments.py index ea7ec0d..b386bef 100644 --- a/tests/test_conversation_adjustments.py +++ b/tests/test_conversation_adjustments.py @@ -1,6 +1,7 @@ import os import unittest from datetime import datetime, timedelta +from app.core.time_utils import utc_now from unittest.mock import patch os.environ.setdefault("DEBUG", "false") @@ -325,7 +326,7 @@ class ConversationAdjustmentsTests(unittest.TestCase): "pending_cancel_order_drafts": { 7: { "payload": {"numero_pedido": "PED-20260305120000-ABC123"}, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } } @@ -347,7 +348,7 @@ class ConversationAdjustmentsTests(unittest.TestCase): "km": 30000, "revisao_previa_concessionaria": True, }, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } } @@ -438,7 +439,7 @@ class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase): "pending_cancel_order_drafts": { 42: { "payload": {"numero_pedido": "PED-20260305120000-ABC123"}, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } } @@ -468,7 +469,7 @@ class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase): "pending_cancel_order_drafts": { 42: { "payload": {"numero_pedido": "PED-20260305120000-ABC123"}, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } } @@ -500,7 +501,7 @@ class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase): "pending_cancel_order_drafts": { 42: { "payload": {"numero_pedido": "PED-20260305120000-ABC123"}, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } } @@ -525,7 +526,7 @@ class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase): "pending_order_drafts": { 42: { "payload": {}, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } } @@ -557,7 +558,7 @@ class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase): "flow_snapshots": { "order_cancel": { "payload": {"numero_pedido": "PED-20260305120000-ABC123"}, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } }, "last_stock_results": [], @@ -591,7 +592,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): "pending_order_drafts": { 10: { "payload": {"cpf": "12345678909"}, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } } @@ -694,7 +695,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): "pending_order_drafts": { 10: { "payload": {}, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } }, @@ -733,7 +734,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): "pending_order_drafts": { 10: { "payload": {"cpf": "12345678909"}, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } }, @@ -779,7 +780,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): "modelo_veiculo": "Volkswagen T-Cross 2022", "valor_veiculo": 73224.0, }, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } }, @@ -926,7 +927,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): "pending_order_drafts": { 10: { "payload": {"cpf": "12345678909"}, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } }, @@ -1006,7 +1007,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): "pending_order_drafts": { 10: { "payload": {"cpf": "12345678909"}, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } }, @@ -1156,7 +1157,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): {"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0}, {"id": 3, "modelo": "Chevrolet Onix 2022", "categoria": "suv", "preco": 51809.0}, ], - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } }, @@ -1193,7 +1194,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): {"id": 9, "modelo": "Hyundai HB20S 2022", "categoria": "suv", "preco": 76000.0, "budget_relaxed": True}, {"id": 2, "modelo": "Toyota Corolla 2020", "categoria": "suv", "preco": 58476.0, "budget_relaxed": True}, ], - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } }, @@ -1254,7 +1255,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): "flow_snapshots": { "order_create": { "payload": {"vehicle_id": 2, "modelo_veiculo": "Toyota Corolla 2020"}, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } }, "last_stock_results": [ @@ -1295,7 +1296,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): "pending_order_drafts": { 10: { "payload": {"cpf": "12345678909"}, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } }, @@ -1338,7 +1339,7 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase): "pending_order_drafts": { 10: { "payload": {"cpf": "12345678909", "vehicle_id": 99}, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } }, @@ -1463,7 +1464,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): "pending_review_drafts": { 21: { "payload": {"placa": "ABC1269"}, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } } @@ -1486,13 +1487,78 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): self.assertIn("o modelo do veiculo", response) self.assertTrue(any(payload.get("review_flow_source") == "draft" for _, payload in flow.logged_events)) + async def test_review_flow_date_only_with_other_missing_fields_mentions_captured_date_and_requested_time(self): + fixed_now = lambda: datetime(2026, 3, 12, 9, 0) + state = FakeState( + entries={ + "pending_review_drafts": { + 21: { + "payload": {"placa": "ABC1269"}, + "expires_at": utc_now() + timedelta(minutes=30), + } + } + } + ) + registry = FakeRegistry() + flow = ReviewFlowHarness(state=state, registry=registry, review_now_provider=fixed_now) + + response = await flow._try_collect_and_schedule_review( + message="amanha", + user_id=21, + extracted_fields={}, + intents={}, + turn_decision={"intent": "review_schedule", "domain": "review", "action": "answer_user"}, + ) + + draft = state.get_entry("pending_review_drafts", 21) + self.assertIsNotNone(draft) + self.assertEqual(draft["payload"].get("data_hora_base"), "13/03/2026") + self.assertIn("Perfeito. Tenho a data 13/03/2026.", response) + self.assertIn("- o horario desejado para a revisao", response) + self.assertIn("- o modelo do veiculo", response) + self.assertNotIn("- a data e hora desejada para a revisao", response) + + async def test_review_flow_keeps_review_draft_when_time_follow_up_is_misclassified_as_sales(self): + state = FakeState( + entries={ + "pending_review_drafts": { + 21: { + "payload": { + "placa": "ABC1269", + "modelo": "Onix", + "ano": 2024, + "km": 12000, + "revisao_previa_concessionaria": False, + "data_hora_base": "13/03/2026", + }, + "expires_at": utc_now() + timedelta(minutes=30), + } + } + } + ) + registry = FakeRegistry() + flow = ReviewFlowHarness(state=state, registry=registry) + + response = await flow._try_collect_and_schedule_review( + message="16h", + user_id=21, + extracted_fields={}, + intents={}, + turn_decision={"intent": "order_create", "domain": "sales", "action": "answer_user"}, + ) + + self.assertEqual(registry.calls[0][0], "agendar_revisao") + self.assertEqual(registry.calls[0][1]["data_hora"], "13/03/2026 16:00") + self.assertIsNone(state.get_entry("pending_review_drafts", 21)) + self.assertIn("REV-TESTE-123", response) + async def test_review_flow_extracts_model_year_km_and_review_history_from_free_text(self): state = FakeState( entries={ "pending_review_drafts": { 21: { "payload": {"placa": "ABC1269", "data_hora": "13/03/2026 16:00"}, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } } @@ -1620,7 +1686,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): "flow_snapshots": { "review_schedule": { "payload": {"placa": "A0T1C23", "data_hora": "14/03/2026 18:00"}, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } }, "order_queue": [], @@ -1672,7 +1738,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): "km": 30000, "revisao_previa_concessionaria": True, }, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } } @@ -1708,7 +1774,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): "km": 30000, "revisao_previa_concessionaria": True, }, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } } @@ -1742,7 +1808,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): "km": 30000, "revisao_previa_concessionaria": True, }, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } } @@ -1776,7 +1842,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): "km": 20000, "revisao_previa_concessionaria": False, }, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } } @@ -1830,7 +1896,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): "km": 20000, "revisao_previa_concessionaria": False, }, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } }, @@ -1870,7 +1936,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): "km": 20000, "revisao_previa_concessionaria": False, }, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } } @@ -1901,7 +1967,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): "km": 50000, "revisao_previa_concessionaria": False, }, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } } @@ -1947,7 +2013,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): "km": 50000, "revisao_previa_concessionaria": False, }, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } } @@ -1993,7 +2059,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): "km": 50000, "revisao_previa_concessionaria": False, }, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } } @@ -2050,7 +2116,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): "km": 30000, "revisao_previa_concessionaria": True, }, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } } @@ -2083,7 +2149,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): "km": 15000, "revisao_previa_concessionaria": True, }, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } } @@ -2156,7 +2222,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): 21: { "action": "reschedule", "payload": {"protocolo": "REV-20260313-F754AF27"}, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } } @@ -2187,7 +2253,7 @@ class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase): "km": 30000, "revisao_previa_concessionaria": True, }, - "expires_at": datetime.utcnow() + timedelta(minutes=30), + "expires_at": utc_now() + timedelta(minutes=30), } } } @@ -2278,7 +2344,7 @@ class ContextSwitchPolicyTests(unittest.TestCase): "km": 30000, "revisao_previa_concessionaria": False, }, - "expires_at": datetime.utcnow() + timedelta(minutes=15), + "expires_at": utc_now() + timedelta(minutes=15), } } }, @@ -2316,7 +2382,7 @@ class ContextSwitchPolicyTests(unittest.TestCase): "km": 30000, "revisao_previa_concessionaria": False, }, - "expires_at": datetime.utcnow() + timedelta(minutes=15), + "expires_at": utc_now() + timedelta(minutes=15), } } }, @@ -2364,7 +2430,7 @@ class ContextSwitchPolicyTests(unittest.TestCase): 9: { "pending_switch": { "target_domain": "sales", - "expires_at": datetime.utcnow() + timedelta(minutes=15), + "expires_at": utc_now() + timedelta(minutes=15), }, "active_domain": "general", "generic_memory": {}, diff --git a/tests/test_turn_decision_contract.py b/tests/test_turn_decision_contract.py index 29c29d0..6c6ed8c 100644 --- a/tests/test_turn_decision_contract.py +++ b/tests/test_turn_decision_contract.py @@ -4,6 +4,7 @@ import unittest os.environ.setdefault("DEBUG", "false") from datetime import datetime, timedelta +from app.core.time_utils import utc_now from app.services.orchestration.conversation_policy import ConversationPolicy from app.services.orchestration.entity_normalizer import EntityNormalizer @@ -174,6 +175,53 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(decision["entities"]["review_fields"]["data_hora"], "10/03/2026 às 09:00") self.assertEqual(decision["missing_fields"], ["modelo", "ano", "km"]) + def test_parse_json_object_accepts_python_style_dict_with_trailing_commas(self): + normalizer = EntityNormalizer() + + payload = normalizer.parse_json_object( + """ + ```json + { + 'intent': 'review_schedule', + 'domain': 'review', + 'action': 'answer_user', + } + ``` + """ + ) + + self.assertEqual(payload["intent"], "review_schedule") + self.assertEqual(payload["domain"], "review") + self.assertEqual(payload["action"], "answer_user") + + def test_coerce_turn_decision_maps_top_level_aliases_and_embedded_intents(self): + normalizer = EntityNormalizer() + + decision = normalizer.coerce_turn_decision( + { + "domain": "service", + "action": "answer", + "response": "Certo, vou seguir com a revisao.", + "selected_index": "2", + "entities": { + "generic_memory": {"cpf": "12345678909"}, + "review_fields": {"placa": "abc1234"}, + "review_management_fields": {}, + "order_fields": {}, + "cancel_order_fields": {}, + "intents": {"review_schedule": True}, + }, + } + ) + + self.assertEqual(decision["intent"], "review_schedule") + self.assertEqual(decision["domain"], "review") + self.assertEqual(decision["action"], "answer_user") + self.assertEqual(decision["response_to_user"], "Certo, vou seguir com a revisao.") + self.assertEqual(decision["selection_index"], 2) + self.assertEqual(decision["entities"]["review_fields"]["placa"], "ABC1234") + self.assertEqual(decision["entities"]["generic_memory"]["cpf"], "12345678909") + def test_coerce_turn_decision_rejects_invalid_shape_with_fallback(self): normalizer = EntityNormalizer() @@ -337,6 +385,44 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(decision["tool_name"], "agendar_revisao") self.assertEqual(decision["tool_arguments"]["placa"], "ABC1234") + def test_coerce_turn_decision_normalizes_legacy_review_vehicle_tool_alias(self): + normalizer = EntityNormalizer() + + decision = normalizer.coerce_turn_decision( + { + "intent": "review_schedule", + "domain": "review", + "action": "call_tool", + "tool_name": "agendar_revisao_veiculo", + "tool_arguments": { + "placa_veiculo": "ABC1234", + "data_agendamento": "tomorrow", + "horario_agendamento": "14:00", + "modelo_veiculo": "Onix", + "ano_veiculo": 2024, + "quilometragem": 12000, + "revisao_previa": False, + }, + "entities": { + "generic_memory": {}, + "review_fields": {}, + "review_management_fields": {}, + "order_fields": {}, + "cancel_order_fields": {}, + }, + "missing_fields": [], + "response_to_user": None, + } + ) + + self.assertEqual(decision["tool_name"], "agendar_revisao") + self.assertEqual(decision["tool_arguments"]["placa"], "ABC1234") + self.assertEqual(decision["tool_arguments"]["modelo"], "Onix") + self.assertEqual(decision["tool_arguments"]["ano"], 2024) + self.assertEqual(decision["tool_arguments"]["km"], 12000) + self.assertFalse(decision["tool_arguments"]["revisao_previa_concessionaria"]) + self.assertTrue(decision["tool_arguments"]["data_hora"].endswith("14:00")) + def test_coerce_turn_decision_normalizes_review_schedule_tool_argument_aliases(self): normalizer = EntityNormalizer() @@ -525,7 +611,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(decision["tool_arguments"], {}) self.assertEqual(decision["entities"]["cancel_order_fields"]["numero_pedido"], "PED-20260310124202-5EF4E9") - def test_coerce_turn_decision_rejects_missing_fields_without_response_payload(self): + def test_coerce_turn_decision_downgrades_missing_response_ask_missing_fields_to_collection(self): normalizer = EntityNormalizer() decision = normalizer.coerce_turn_decision( @@ -535,20 +621,51 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): "action": "ask_missing_fields", "entities": { "generic_memory": {}, - "review_fields": {}, + "review_fields": {"placa": "ABC1234"}, "review_management_fields": {}, "order_fields": {}, "cancel_order_fields": {}, }, - "missing_fields": [], + "missing_fields": ["data e hora", "modelo"], "tool_name": None, "tool_arguments": {}, "response_to_user": "", } ) - self.assertEqual(decision["intent"], "general") - self.assertEqual(decision["action"], "answer_user") + self.assertEqual(decision["intent"], "review_schedule") + self.assertEqual(decision["action"], "collect_review_schedule") + self.assertEqual(decision["missing_fields"], ["data_hora", "modelo"]) + self.assertEqual(decision["entities"]["review_fields"]["placa"], "ABC1234") + + def test_coerce_turn_decision_downgrades_call_tool_without_tool_name_to_cancel_order_collection(self): + normalizer = EntityNormalizer() + + decision = normalizer.coerce_turn_decision( + { + "intent": "order_cancel", + "domain": "sales", + "action": "call_tool", + "arguments": { + "order_id": "PED-20260310124202-5EF4E9", + "reason": "desisti da compra", + }, + "entities": { + "generic_memory": {}, + "review_fields": {}, + "review_management_fields": {}, + "order_fields": {}, + "cancel_order_fields": {}, + }, + } + ) + + self.assertEqual(decision["intent"], "order_cancel") + self.assertEqual(decision["action"], "collect_order_cancel") + self.assertIsNone(decision["tool_name"]) + self.assertEqual(decision["tool_arguments"], {}) + self.assertEqual(decision["entities"]["cancel_order_fields"]["numero_pedido"], "PED-20260310124202-5EF4E9") + self.assertEqual(decision["entities"]["cancel_order_fields"]["motivo"], "desisti da compra") def test_turn_decision_entities_do_not_rebuild_legacy_intents(self): service = OrquestradorService.__new__(OrquestradorService) @@ -644,6 +761,60 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(cached["payload"]["modelo"], "Civic") self.assertTrue(cached["payload"]["revisao_previa_concessionaria"]) + async def test_execute_tool_with_trace_normalizes_direct_review_tool_alias_and_merges_open_draft(self): + state = FakeState( + entries={ + "pending_review_drafts": { + 7: { + "payload": { + "placa": "ABC1463", + "modelo": "Civic", + "ano": 2024, + "km": 30000, + "revisao_previa_concessionaria": False, + }, + "expires_at": utc_now() + timedelta(minutes=15), + } + } + }, + contexts={ + 7: { + "active_domain": "review", + "generic_memory": {}, + "shared_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service.tool_executor = FakeToolExecutor(result={"protocolo": "REV-TESTE-123"}) + service._log_turn_event = lambda *args, **kwargs: None + + result = await service._execute_tool_with_trace( + tool_name="agendar_revisao_veiculo", + arguments={ + "data_agendamento": "tomorrow", + "horario_agendamento": "14:00", + }, + user_id=7, + ) + + self.assertEqual(result["protocolo"], "REV-TESTE-123") + self.assertEqual(service.tool_executor.calls[0][0], "agendar_revisao") + self.assertEqual(service.tool_executor.calls[0][2], 7) + self.assertEqual(service.tool_executor.calls[0][1]["placa"], "ABC1463") + self.assertEqual(service.tool_executor.calls[0][1]["modelo"], "Civic") + self.assertEqual(service.tool_executor.calls[0][1]["ano"], 2024) + self.assertEqual(service.tool_executor.calls[0][1]["km"], 30000) + self.assertFalse(service.tool_executor.calls[0][1]["revisao_previa_concessionaria"]) + self.assertTrue(service.tool_executor.calls[0][1]["data_hora"].endswith("14:00")) + def test_capture_tool_result_context_stores_pending_stock_selection_entry(self): state = FakeState( contexts={ @@ -805,6 +976,106 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(response, "Pedido PED-1 atualizado.\nStatus: Cancelado") self.assertEqual(service.llm.calls, 0) + async def test_confirm_pending_review_clears_open_review_draft_after_suggested_time_success(self): + state = FakeState( + entries={ + "pending_review_drafts": { + 7: { + "payload": { + "placa": "ABC1C23", + "modelo": "Onix", + "ano": 2024, + "km": 20000, + "revisao_previa_concessionaria": False, + }, + "expires_at": utc_now() + timedelta(minutes=15), + } + }, + "pending_review_confirmations": { + 7: { + "payload": { + "placa": "ABC1C23", + "data_hora": "14/03/2026 16:30", + "modelo": "Onix", + "ano": 2024, + "km": 20000, + "revisao_previa_concessionaria": False, + }, + "expires_at": utc_now() + timedelta(minutes=15), + } + }, + }, + contexts={ + 7: { + "active_domain": "review", + "active_task": "review_schedule", + "generic_memory": {"placa": "ABC1C23"}, + "shared_memory": {"placa": "ABC1C23"}, + "collected_slots": { + "review_schedule": { + "placa": "ABC1C23", + "modelo": "Onix", + "ano": 2024, + } + }, + "flow_snapshots": { + "review_schedule": { + "payload": { + "placa": "ABC1C23", + "modelo": "Onix", + "ano": 2024, + }, + "expires_at": utc_now() + timedelta(minutes=15), + }, + "review_confirmation": { + "payload": { + "placa": "ABC1C23", + "data_hora": "14/03/2026 16:30", + }, + "expires_at": utc_now() + timedelta(minutes=15), + }, + }, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": None, + "last_stock_results": [], + "selected_vehicle": None, + } + }, + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service.tool_executor = FakeToolExecutor( + result={ + "protocolo": "REV-TESTE-999", + "placa": "ABC1C23", + "data_hora": "14/03/2026 16:30", + "valor_revisao": 728.0, + } + ) + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + service._http_exception_detail = lambda exc: str(exc) + service._fallback_format_tool_result = lambda tool_name, tool_result: ( + f"Revisao agendada com sucesso.\nProtocolo: {tool_result['protocolo']}" + ) + + response = await service._try_confirm_pending_review( + message="sim", + user_id=7, + extracted_review_fields={}, + ) + + self.assertIn("REV-TESTE-999", response) + self.assertIsNone(state.get_entry("pending_review_confirmations", 7)) + self.assertIsNone(state.get_entry("pending_review_drafts", 7)) + self.assertIsNotNone(state.get_entry("last_review_packages", 7)) + context = state.get_user_context(7) + self.assertEqual(context["active_task"], None) + self.assertEqual(context["collected_slots"], {}) + self.assertEqual(context["flow_snapshots"], {}) + async def test_empty_stock_search_suggests_nearby_options(self): service = OrquestradorService.__new__(OrquestradorService) service.normalizer = EntityNormalizer() @@ -982,7 +1253,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): "pending_review_drafts": { 1: { "payload": {"placa": "ABC1269"}, - "expires_at": datetime.utcnow() + timedelta(minutes=15), + "expires_at": utc_now() + timedelta(minutes=15), } } } @@ -1018,7 +1289,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): "pending_switch": None, "last_stock_results": [], "selected_vehicle": None, - "expires_at": datetime.utcnow() + timedelta(minutes=15), + "expires_at": utc_now() + timedelta(minutes=15), } } ) @@ -1047,7 +1318,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): "pending_review_drafts": { 1: { "payload": {"placa": "ABC1269"}, - "expires_at": datetime.utcnow() + timedelta(minutes=15), + "expires_at": utc_now() + timedelta(minutes=15), } } }, @@ -1354,7 +1625,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): {"id": 15, "modelo": "Volkswagen T-Cross 2022", "categoria": "suv", "preco": 73224.0}, {"id": 11, "modelo": "Toyota Corolla 2024", "categoria": "suv", "preco": 76087.0}, ], - "expires_at": datetime.utcnow() + timedelta(minutes=15), + "expires_at": utc_now() + timedelta(minutes=15), } } }, @@ -1425,7 +1696,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): "pending_order_drafts": { 1: { "payload": {"vehicle_id": 15, "modelo_veiculo": "Volkswagen T-Cross 2022", "valor_veiculo": 73224.0}, - "expires_at": datetime.utcnow() + timedelta(minutes=15), + "expires_at": utc_now() + timedelta(minutes=15), } } }, @@ -1561,6 +1832,78 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(response, "Pedido criado com sucesso.") + async def test_handle_message_prioritizes_pending_switch_confirmation_before_sales_follow_up(self): + state = FakeState( + entries={ + "pending_stock_selections": { + 1: { + "payload": [ + {"id": 15, "modelo": "Volkswagen T-Cross 2022", "categoria": "suv", "preco": 73224.0}, + ], + "expires_at": utc_now() + timedelta(minutes=15), + } + } + }, + contexts={ + 1: { + "active_domain": "sales", + "generic_memory": {}, + "shared_memory": {}, + "order_queue": [], + "pending_order_selection": None, + "pending_switch": { + "source_domain": "sales", + "target_domain": "review", + "expires_at": utc_now() + timedelta(minutes=15), + }, + "last_stock_results": [ + {"id": 15, "modelo": "Volkswagen T-Cross 2022", "categoria": "suv", "preco": 73224.0}, + ], + "selected_vehicle": {"id": 15, "modelo": "Volkswagen T-Cross 2022", "categoria": "suv", "preco": 73224.0}, + } + } + ) + service = OrquestradorService.__new__(OrquestradorService) + service.state = state + service.normalizer = EntityNormalizer() + service.policy = ConversationPolicy(service=service) + service._empty_extraction_payload = service.normalizer.empty_extraction_payload + service._log_turn_event = lambda *args, **kwargs: None + service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response + service._get_user_context = lambda user_id: state.get_user_context(user_id) + service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context) + + async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None): + return base_response + + service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order + service._upsert_user_context = lambda user_id: None + service._is_order_selection_reset_message = lambda message: False + + async def fake_try_handle_pending_stock_selection_follow_up(**kwargs): + raise AssertionError("nao deveria entrar no follow-up de estoque antes de confirmar a troca de contexto") + + service._try_handle_pending_stock_selection_follow_up = fake_try_handle_pending_stock_selection_follow_up + + async def fake_try_handle_active_sales_follow_up(**kwargs): + raise AssertionError("nao deveria entrar no follow-up de vendas antes de confirmar a troca de contexto") + + service._try_handle_active_sales_follow_up = fake_try_handle_active_sales_follow_up + + response = await service.handle_message( + "sim", + user_id=1, + ) + + self.assertEqual( + response, + "Certo, contexto anterior encerrado. Vamos seguir com agendamento de revisao.\n" + "Pode me informar a placa ou, se preferir, ja mandar placa, data/hora, modelo, ano, km e se ja fez revisao.", + ) + self.assertEqual(state.get_user_context(1)["active_domain"], "review") + self.assertIsNone(state.get_user_context(1).get("pending_switch")) + self.assertIsNone(state.get_entry("pending_stock_selections", 1)) + async def test_handle_message_prioritizes_immediate_reset_before_active_sales_follow_up(self): state = FakeState( contexts={ @@ -1665,7 +2008,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): "modelo_veiculo": "Volkswagen T-Cross 2022", "valor_veiculo": 73224.0, }, - "expires_at": datetime.utcnow() + timedelta(minutes=15), + "expires_at": utc_now() + timedelta(minutes=15), } } }, @@ -1772,7 +2115,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): "pending_cancel_order_drafts": { 1: { "payload": {"numero_pedido": "PED-202603101204814-6ED33A"}, - "expires_at": datetime.utcnow() + timedelta(minutes=15), + "expires_at": utc_now() + timedelta(minutes=15), } } } @@ -1841,7 +2184,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): {"domain": "review", "message": "agendar revisao", "memory_seed": {}}, {"domain": "sales", "message": "fazer pedido", "memory_seed": {}}, ], - "expires_at": datetime.utcnow() + timedelta(minutes=15), + "expires_at": utc_now() + timedelta(minutes=15), }, "order_queue": [], "active_domain": "general", @@ -1868,7 +2211,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): {"domain": "review", "message": "agendar revisao", "memory_seed": {}}, {"domain": "sales", "message": "fazer pedido", "memory_seed": {}}, ], - "expires_at": datetime.utcnow() + timedelta(minutes=15), + "expires_at": utc_now() + timedelta(minutes=15), }, "order_queue": [], "active_domain": "general", @@ -1894,7 +2237,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): "target_domain": "sales", "queued_message": "fazer pedido", "memory_seed": {"cpf": "12345678909"}, - "expires_at": datetime.utcnow() + timedelta(minutes=15), + "expires_at": utc_now() + timedelta(minutes=15), }, "active_domain": "general", "generic_memory": {}, @@ -1922,7 +2265,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): 9: { "pending_switch": { "target_domain": "review", - "expires_at": datetime.utcnow() + timedelta(minutes=15), + "expires_at": utc_now() + timedelta(minutes=15), }, "active_domain": "sales", "generic_memory": {}, @@ -1955,7 +2298,7 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): "pending_review_drafts": { 9: { "payload": {"placa": "ABC1234"}, - "expires_at": datetime.utcnow() + timedelta(minutes=15), + "expires_at": utc_now() + timedelta(minutes=15), } } },