diff --git a/app/db/tool_seed.py b/app/db/tool_seed.py index 7bba095..c447618 100644 --- a/app/db/tool_seed.py +++ b/app/db/tool_seed.py @@ -236,6 +236,32 @@ def get_tools_definitions(): "required": ["cpf", "vehicle_id"], }, }, + { + "name": "listar_pedidos", + "description": ( + "Use esta ferramenta quando o cliente quiser listar ou consultar os pedidos " + "que ja possui. Ela retorna os pedidos do usuario autenticado, com numero, " + "veiculo, valor e status, e tambem pode filtrar por status quando necessario." + ), + "parameters": { + "type": "object", + "properties": { + "cpf": { + "type": "string", + "description": "CPF do cliente quando for necessario filtrar manualmente. Opcional.", + }, + "status": { + "type": "string", + "description": "Status do pedido para filtrar, por exemplo 'Ativo' ou 'Cancelado'. Opcional.", + }, + "limite": { + "type": "integer", + "description": "Quantidade maxima de pedidos retornados. Opcional.", + }, + }, + "required": [], + }, + }, { "name": "cancelar_pedido", "description": ( diff --git a/app/integrations/telegram_satellite_service.py b/app/integrations/telegram_satellite_service.py index 12e79dd..613c861 100644 --- a/app/integrations/telegram_satellite_service.py +++ b/app/integrations/telegram_satellite_service.py @@ -18,6 +18,17 @@ from app.services.user.user_service import UserService logger = logging.getLogger(__name__) +def _ensure_supported_runtime_configuration() -> None: + """ + Em producao, o satelite nao deve operar com estado conversacional apenas em memoria, + porque isso quebra continuidade entre reinicios e instancias. + """ + if settings.environment == "production" and settings.conversation_state_backend == "memory": + raise RuntimeError( + "Telegram satellite em producao exige conversation_state_backend diferente de memory." + ) + + def _acquire_single_instance_lock(lock_name: str): """ Garante apenas uma instancia local do satelite por lock de arquivo. @@ -214,6 +225,7 @@ async def main() -> None: token = settings.telegram_bot_token if not token: raise RuntimeError("TELEGRAM_BOT_TOKEN nao configurado.") + _ensure_supported_runtime_configuration() lock_handle = _acquire_single_instance_lock("orquestrador_telegram_satellite.lock") diff --git a/app/services/domain/order_service.py b/app/services/domain/order_service.py index 30507ac..457307b 100644 --- a/app/services/domain/order_service.py +++ b/app/services/domain/order_service.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import Any from uuid import uuid4 -from sqlalchemy import text +from sqlalchemy import or_, text from sqlalchemy.exc import OperationalError, SQLAlchemyError from app.db.mock_database import SessionMockLocal @@ -16,6 +16,79 @@ from app.services.user.mock_customer_service import hydrate_mock_customer_from_c # Responsabilidade: regra de pedido. +async def listar_pedidos( + user_id: int | None = None, + cpf: str | None = None, + status: str | None = None, + limite: int = 20, +) -> list[dict[str, Any]]: + cpf_norm = normalize_cpf(cpf) if cpf else None + db = SessionMockLocal() + try: + user = None + if user_id is not None: + user = db.query(User).filter(User.id == user_id).first() + if user and not cpf_norm: + cpf_norm = normalize_cpf(user.cpf) + + query = db.query(Order) + if user_id is not None and cpf_norm: + query = query.filter( + or_( + Order.user_id == user_id, + (Order.user_id.is_(None) & (Order.cpf == cpf_norm)), + ) + ) + elif user_id is not None: + query = query.filter(Order.user_id == user_id) + elif cpf_norm: + query = query.filter(Order.cpf == cpf_norm) + else: + raise_tool_http_error( + status_code=400, + code="order_list_missing_identity", + message="Preciso identificar o cliente para listar os pedidos.", + retryable=True, + field="cpf", + ) + + normalized_status = str(status or "").strip() + if normalized_status: + query = query.filter(Order.status == normalized_status) + + safe_limit = max(1, min(int(limite or 20), 50)) + pedidos = query.order_by(Order.created_at.desc()).limit(safe_limit).all() + + if user_id is not None and pedidos: + attached_legacy_orders = False + for pedido in pedidos: + if pedido.user_id is None: + pedido.user_id = user_id + attached_legacy_orders = True + if attached_legacy_orders: + db.commit() + for pedido in pedidos: + db.refresh(pedido) + + return [ + { + "numero_pedido": pedido.numero_pedido, + "user_id": pedido.user_id, + "cpf": pedido.cpf, + "vehicle_id": pedido.vehicle_id, + "modelo_veiculo": pedido.modelo_veiculo, + "valor_veiculo": pedido.valor_veiculo, + "status": pedido.status, + "motivo": pedido.motivo_cancelamento, + "data_cancelamento": pedido.data_cancelamento.isoformat() if pedido.data_cancelamento else None, + "created_at": pedido.created_at.isoformat() if pedido.created_at else None, + } + for pedido in pedidos + ] + finally: + db.close() + + async def cancelar_pedido( numero_pedido: str, motivo: str, diff --git a/app/services/flows/order_flow.py b/app/services/flows/order_flow.py index e1bc7e7..3e80009 100644 --- a/app/services/flows/order_flow.py +++ b/app/services/flows/order_flow.py @@ -21,7 +21,32 @@ class OrderFlowMixin: def _decision_intent(self, turn_decision: dict | None) -> str: return str((turn_decision or {}).get("intent") or "").strip().lower() + def _has_order_listing_request(self, message: str, turn_decision: dict | None = None) -> bool: + if self._decision_intent(turn_decision) == "order_list": + return True + normalized = self._normalize_text(message).strip() + listing_terms = { + "meus pedidos", + "meu pedido", + "listar pedidos", + "liste meus pedidos", + "lista de pedidos", + "quais sao meus pedidos", + "quais sao os meus pedidos", + "mostrar pedidos", + "mostre meus pedidos", + "consultar pedidos", + "ver meus pedidos", + "acompanhar pedido", + "acompanhar pedidos", + "status do pedido", + "status dos pedidos", + } + return any(term in normalized for term in listing_terms) + def _has_explicit_order_request(self, message: str) -> bool: + if self._has_order_listing_request(message): + return False normalized = self._normalize_text(message).strip() order_terms = { "comprar", @@ -37,6 +62,8 @@ class OrderFlowMixin: return any(term in normalized for term in order_terms) def _has_stock_listing_request(self, message: str, turn_decision: dict | None = None) -> bool: + if self._has_order_listing_request(message=message, turn_decision=turn_decision): + return False if self._decision_intent(turn_decision) == "inventory_search": return True normalized = self._normalize_text(message).strip() @@ -360,6 +387,8 @@ class OrderFlowMixin: return self._fallback_format_tool_result("consultar_estoque", tool_result) def _render_missing_cancel_order_fields_prompt(self, missing_fields: list[str]) -> str: + if missing_fields == ["motivo"]: + return "Encontrei o pedido informado. Qual o motivo do cancelamento?" labels = { "numero_pedido": "o numero do pedido (ex.: PED-20260305123456-ABC123)", "motivo": "o motivo do cancelamento", @@ -367,6 +396,35 @@ class OrderFlowMixin: itens = [f"- {labels[field]}" for field in missing_fields] return "Para cancelar o pedido, preciso dos dados abaixo:\n" + "\n".join(itens) + async def _try_handle_order_listing( + self, + message: str, + user_id: int | None, + intents: dict | None = None, + turn_decision: dict | None = None, + ) -> str | None: + if user_id is None: + return None + + normalized_intents = self._normalize_intents(intents) + has_intent = ( + self._decision_intent(turn_decision) == "order_list" + or normalized_intents.get("order_list", False) + or self._has_order_listing_request(message=message, turn_decision=turn_decision) + ) + if not has_intent: + return None + + try: + tool_result = await self.tool_executor.execute( + "listar_pedidos", + {"limite": 10}, + user_id=user_id, + ) + except HTTPException as exc: + return self._http_exception_detail(exc) + return self._fallback_format_tool_result("listar_pedidos", tool_result) + async def _try_collect_and_create_order( self, message: str, @@ -534,12 +592,14 @@ class OrderFlowMixin: "review_list", "review_cancel", "review_reschedule", + "order_list", "order_create", } or normalized_intents.get("review_schedule", False) or normalized_intents.get("review_list", False) or normalized_intents.get("review_cancel", False) or normalized_intents.get("review_reschedule", False) + or normalized_intents.get("order_list", False) or normalized_intents.get("order_create", False) ) and not extracted @@ -559,7 +619,7 @@ class OrderFlowMixin: if ( "motivo" not in extracted and draft["payload"].get("numero_pedido") - and not has_intent + and "numero_pedido" not in extracted ): # Quando o pedido ja foi identificado, um texto livre curto # e tratado como motivo do cancelamento. diff --git a/app/services/orchestration/conversation_policy.py b/app/services/orchestration/conversation_policy.py index 022c409..37d2a78 100644 --- a/app/services/orchestration/conversation_policy.py +++ b/app/services/orchestration/conversation_policy.py @@ -281,7 +281,19 @@ class ConversationPolicy: def remove_order_selection_reset_prefix(self, message: str) -> str: raw = (message or "").strip() normalized = self.service.normalizer.normalize_text(raw) - prefixes = ("esqueca tudo agora", "esqueca tudo", "esquece tudo agora", "esquece tudo") + prefixes = ( + "esqueca as operacoes anteriores e", + "esqueca as operacoes anteriores, agora", + "esqueca as operacoes anteriores agora", + "esqueca as operacoes anteriores", + "desconsidere as operacoes anteriores", + "desconsidera as operacoes anteriores", + "esqueca a conversa anterior e", + "esqueca tudo agora", + "esquece tudo agora", + "esqueca tudo", + "esquece tudo", + ) for prefix in prefixes: if normalized.startswith(prefix): return raw[len(prefix):].lstrip(" ,.:;-") @@ -292,6 +304,12 @@ class ConversationPolicy: def is_order_selection_reset_message(self, message: str) -> bool: normalized = self.service.normalizer.normalize_text(message).strip() reset_terms = { + "esqueca as operacoes anteriores e", + "esqueca as operacoes anteriores, agora", + "esqueca as operacoes anteriores", + "desconsidere as operacoes anteriores", + "desconsidera as operacoes anteriores", + "esqueca a conversa anterior", "esqueca tudo", "esqueca tudo agora", "esquece tudo", @@ -596,7 +614,11 @@ class ConversationPolicy: + int(normalized.get("review_cancel", False)) + int(normalized.get("review_reschedule", False)) ) - sales_score = int(normalized.get("order_create", False)) + int(normalized.get("order_cancel", False)) + sales_score = ( + int(normalized.get("order_create", False)) + + int(normalized.get("order_list", False)) + + int(normalized.get("order_cancel", False)) + ) if review_score > sales_score and review_score > 0: return "review" if sales_score > review_score and sales_score > 0: @@ -718,6 +740,12 @@ class ConversationPolicy: if pending_switch["expires_at"] < datetime.utcnow(): context["pending_switch"] = None self._save_context(user_id=user_id, context=context) + elif ( + self._decision_domain(turn_decision) in {"review", "sales"} + and self._decision_domain(turn_decision) != pending_switch["target_domain"] + ): + context["pending_switch"] = None + self._save_context(user_id=user_id, context=context) elif self.is_context_switch_confirmation(message, turn_decision=turn_decision): if self.service._is_affirmative_message(message) or self._decision_domain(turn_decision) == pending_switch["target_domain"]: target_domain = pending_switch["target_domain"] diff --git a/app/services/orchestration/entity_normalizer.py b/app/services/orchestration/entity_normalizer.py index ab2587d..8a176b0 100644 --- a/app/services/orchestration/entity_normalizer.py +++ b/app/services/orchestration/entity_normalizer.py @@ -21,6 +21,8 @@ class EntityNormalizer: "buy_car": "order_create", "purchase_car": "order_create", "cancel_order": "order_cancel", + "list_orders": "order_list", + "show_orders": "order_list", "list_inventory": "inventory_search", "search_inventory": "inventory_search", "clear_conversation": "conversation_reset", @@ -38,6 +40,44 @@ class EntityNormalizer: "veiculo": "vehicle_id", "carro": "vehicle_id", } + _TOOL_ARGUMENT_ALIASES = { + "cancelar_pedido": { + "order_id": "numero_pedido", + "pedido_id": "numero_pedido", + "id_pedido": "numero_pedido", + "numero": "numero_pedido", + "order_number": "numero_pedido", + "reason": "motivo", + }, + "realizar_pedido": { + "order_id": "vehicle_id", + "car_id": "vehicle_id", + "id_veiculo": "vehicle_id", + "customer_cpf": "cpf", + }, + "listar_pedidos": { + "max_results": "limite", + "limit": "limite", + "customer_cpf": "cpf", + }, + "cancelar_agendamento_revisao": { + "review_id": "protocolo", + "schedule_id": "protocolo", + "reason": "motivo", + }, + "editar_data_revisao": { + "review_id": "protocolo", + "schedule_id": "protocolo", + "data_hora": "nova_data_hora", + "new_datetime": "nova_data_hora", + }, + } + _TOOL_REQUIRED_ARGUMENTS = { + "cancelar_pedido": ("numero_pedido", "motivo"), + "realizar_pedido": ("cpf", "vehicle_id"), + "editar_data_revisao": ("protocolo", "nova_data_hora"), + "cancelar_agendamento_revisao": ("protocolo",), + } def empty_turn_decision(self) -> dict: return TurnDecision().model_dump() @@ -167,11 +207,18 @@ class EntityNormalizer: if isinstance(entities, dict): normalized["entities"] = dict(entities) + tool_name = str(normalized.get("tool_name") or "").strip() + 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) + if self._should_route_order_alias_to_collection(normalized): normalized["action"] = "collect_order_create" normalized["missing_fields"] = [] normalized["response_to_user"] = None + normalized = self._coerce_incomplete_tool_call_to_collection(normalized) + return normalized def _normalize_turn_missing_fields(self, missing_fields: list) -> list[str]: @@ -203,9 +250,123 @@ class EntityNormalizer: return False return True + def _coerce_incomplete_tool_call_to_collection(self, payload: dict) -> dict: + if payload.get("action") != "call_tool": + return payload + + tool_name = str(payload.get("tool_name") or "").strip() + if not tool_name: + return payload + + required_fields = self._TOOL_REQUIRED_ARGUMENTS.get(tool_name) + tool_arguments = payload.get("tool_arguments") + if not required_fields or not isinstance(tool_arguments, dict): + return payload + + missing_fields = [field for field in required_fields if tool_arguments.get(field) in (None, "", [])] + if not missing_fields: + return payload + + entities = payload.get("entities") + if not isinstance(entities, dict): + entities = self.empty_extraction_payload() + payload["entities"] = entities + + if tool_name == "cancelar_pedido" and payload.get("intent") == "order_cancel": + cancel_entities = self.normalize_cancel_order_fields( + { + **(entities.get("cancel_order_fields") or {}), + **tool_arguments, + } + ) + entities["cancel_order_fields"] = cancel_entities + payload["action"] = "collect_order_cancel" + payload["tool_name"] = None + payload["tool_arguments"] = {} + payload["missing_fields"] = [] + payload["response_to_user"] = None + return payload + + if tool_name == "realizar_pedido" and payload.get("intent") == "order_create": + order_entities = self.normalize_order_fields( + { + **(entities.get("order_fields") or {}), + **tool_arguments, + } + ) + entities["order_fields"] = order_entities + payload["action"] = "collect_order_create" + payload["tool_name"] = None + payload["tool_arguments"] = {} + payload["missing_fields"] = [] + payload["response_to_user"] = None + return payload + + if tool_name in {"cancelar_agendamento_revisao", "editar_data_revisao"} and str(payload.get("domain") or "") == "review": + review_management_entities = self.normalize_review_management_fields( + { + **(entities.get("review_management_fields") or {}), + **tool_arguments, + } + ) + entities["review_management_fields"] = review_management_entities + payload["action"] = "collect_review_management" + payload["tool_name"] = None + payload["tool_arguments"] = {} + payload["missing_fields"] = [] + payload["response_to_user"] = None + return payload + + return payload + def normalize_text(self, text: str) -> str: return technical_normalizer.normalize_text(text) + def normalize_tool_arguments(self, tool_name: str, arguments) -> dict: + if not isinstance(arguments, dict): + return {} + + normalized_tool_name = str(tool_name or "").strip() + aliases = self._TOOL_ARGUMENT_ALIASES.get(normalized_tool_name, {}) + normalized_arguments: dict = {} + for raw_key, value in arguments.items(): + candidate_key = self.normalize_text(str(raw_key or "")).replace("-", "_").replace(" ", "_") + canonical_key = aliases.get(candidate_key, candidate_key) + if canonical_key not in normalized_arguments: + normalized_arguments[canonical_key] = value + + if normalized_tool_name == "cancelar_pedido": + coerced = self.normalize_cancel_order_fields(normalized_arguments) + if "motivo" not in coerced and isinstance(normalized_arguments.get("motivo"), str): + motivo = str(normalized_arguments.get("motivo") or "").strip() + if motivo: + coerced["motivo"] = motivo + return coerced + + if normalized_tool_name == "realizar_pedido": + return self.normalize_order_fields(normalized_arguments) + + if normalized_tool_name == "listar_pedidos": + coerced: dict = {} + cpf = self.normalize_cpf(normalized_arguments.get("cpf")) + if cpf: + coerced["cpf"] = cpf + status = str(normalized_arguments.get("status") or "").strip() + if status: + coerced["status"] = status + limite = self.normalize_positive_number(normalized_arguments.get("limite")) + if limite: + coerced["limite"] = max(1, min(int(round(limite)), 50)) + return coerced + + if normalized_tool_name == "cancelar_agendamento_revisao": + return self.normalize_review_management_fields(normalized_arguments) + + if normalized_tool_name == "editar_data_revisao": + return self.normalize_review_management_fields(normalized_arguments) + + return normalized_arguments + def normalize_plate(self, value) -> str | None: return technical_normalizer.normalize_plate(value) @@ -353,6 +514,7 @@ class EntityNormalizer: "review_cancel": bool(self.normalize_bool(data.get("review_cancel"))), "review_reschedule": bool(self.normalize_bool(data.get("review_reschedule"))), "order_create": bool(self.normalize_bool(data.get("order_create"))), + "order_list": bool(self.normalize_bool(data.get("order_list"))), "order_cancel": bool(self.normalize_bool(data.get("order_cancel"))), } diff --git a/app/services/orchestration/message_planner.py b/app/services/orchestration/message_planner.py index 982e6c7..1d39d20 100644 --- a/app/services/orchestration/message_planner.py +++ b/app/services/orchestration/message_planner.py @@ -32,7 +32,7 @@ class MessagePlanner: ' "review_management_fields": {"protocolo": null, "nova_data_hora": null, "motivo": null},\n' ' "order_fields": {"cpf": null, "vehicle_id": null, "modelo_veiculo": null},\n' ' "cancel_order_fields": {"numero_pedido": null, "motivo": null},\n' - ' "intents": {"review_schedule": false, "review_list": false, "review_cancel": false, "review_reschedule": false, "order_create": false, "order_cancel": false}\n' + ' "intents": {"review_schedule": false, "review_list": false, "review_cancel": false, "review_reschedule": false, "order_create": false, "order_list": false, "order_cancel": false}\n' " }\n" " }\n" " ]\n" @@ -113,6 +113,7 @@ class MessagePlanner: ' "review_cancel": false,\n' ' "review_reschedule": false,\n' ' "order_create": false,\n' + ' "order_list": false,\n' ' "order_cancel": false\n' " }\n" "}\n\n" @@ -190,6 +191,7 @@ class MessagePlanner: "- 'entities' deve manter as secoes generic_memory, review_fields, review_management_fields, order_fields e cancel_order_fields.\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 listar os pedidos dele, use intent='order_list', domain='sales', action='call_tool' e tool_name='listar_pedidos'.\n" "- Se faltar dado para continuar um fluxo, use action='ask_missing_fields' e preencha 'missing_fields' e 'response_to_user'.\n" "- Se o usuario estiver escolhendo entre pedidos enfileirados (ex.: '1', '2', 'o segundo'), preencha 'selection_index' com base zero.\n" "- Se for necessaria uma tool de orquestracao, use action compativel e preencha 'tool_name' e 'tool_arguments' quando apropriado.\n" diff --git a/app/services/orchestration/orchestrator_config.py b/app/services/orchestration/orchestrator_config.py index bdb4b0c..0c50c92 100644 --- a/app/services/orchestration/orchestrator_config.py +++ b/app/services/orchestration/orchestrator_config.py @@ -40,6 +40,8 @@ LOW_VALUE_RESPONSES = { } DETERMINISTIC_RESPONSE_TOOLS = { + "cancelar_pedido", + "listar_pedidos", "limpar_contexto_conversa", "continuar_proximo_pedido", "descartar_pedidos_pendentes", diff --git a/app/services/orchestration/orquestrador_service.py b/app/services/orchestration/orquestrador_service.py index 0a283e8..365e515 100644 --- a/app/services/orchestration/orquestrador_service.py +++ b/app/services/orchestration/orquestrador_service.py @@ -85,6 +85,14 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): message=message, user_id=user_id, ) + reset_override = await self._try_handle_immediate_context_reset( + message=message, + user_id=user_id, + turn_decision=early_turn_decision, + finish=finish, + ) + if reset_override: + return reset_override pending_order_selection = await self._try_resolve_pending_order_selection( message=message, user_id=user_id, @@ -132,10 +140,13 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): ) # Depois do roteamento para um unico pedido, pede a decisao # estruturada do turno final que sera executado. - turn_decision = await self._extract_turn_decision_with_llm( - message=routing_message, - user_id=user_id, - ) + if (routing_message or "").strip() == (message or "").strip(): + turn_decision = early_turn_decision + else: + turn_decision = await self._extract_turn_decision_with_llm( + message=routing_message, + user_id=user_id, + ) llm_extracted_entities = await self._extract_entities_with_llm( message=routing_message, user_id=user_id, @@ -203,13 +214,29 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): decision_action = str(turn_decision.get("action") or "") decision_response = str(turn_decision.get("response_to_user") or "").strip() + should_prioritize_review_flow = self._should_prioritize_review_flow( + turn_decision=turn_decision, + extracted_entities=extracted_entities, + user_id=user_id, + ) should_prioritize_order_flow = self._should_prioritize_order_flow( turn_decision=turn_decision, extracted_entities=extracted_entities, + user_id=user_id, ) - if decision_action == "ask_missing_fields" and decision_response and not should_prioritize_order_flow: + if ( + decision_action == "ask_missing_fields" + and decision_response + and not should_prioritize_review_flow + and not should_prioritize_order_flow + ): return await finish(decision_response, queue_notice=queue_notice) - if decision_action == "answer_user" and decision_response and not should_prioritize_order_flow: + if ( + decision_action == "answer_user" + and decision_response + and not should_prioritize_review_flow + and not should_prioritize_order_flow + ): return await finish(decision_response, queue_notice=queue_notice) planned_tool_response = await self._try_execute_business_tool_from_turn_decision( @@ -262,6 +289,14 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): ) if cancel_order_response: return await finish(cancel_order_response, queue_notice=queue_notice) + order_listing_response = await self._try_handle_order_listing( + message=routing_message, + user_id=user_id, + intents={}, + turn_decision=turn_decision, + ) + if order_listing_response: + return await finish(order_listing_response, queue_notice=queue_notice) # 4) Fluxo de coleta incremental para realizacao de pedido. order_response = await self._try_collect_and_create_order( message=routing_message, @@ -857,7 +892,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): decision = turn_decision or {} decision_intent = str(decision.get("intent") or "").strip().lower() decision_domain = str(decision.get("domain") or "").strip().lower() - if decision_domain != "sales" and decision_intent not in {"order_create", "inventory_search"}: + if decision_domain != "sales" and decision_intent not in {"order_create", "order_list", "inventory_search"}: return {} generic_memory = (extracted_entities or {}).get("generic_memory") @@ -870,6 +905,36 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): async def _extract_turn_decision_with_llm(self, message: str, user_id: int | None) -> dict: return await self.planner.extract_turn_decision(message=message, user_id=user_id) + async def _try_handle_immediate_context_reset( + self, + message: str, + user_id: int | None, + turn_decision: dict | None, + finish, + ) -> str | None: + decision = turn_decision or {} + decision_action = str(decision.get("action") or "").strip().lower() + decision_intent = str(decision.get("intent") or "").strip().lower() + if ( + decision_action != "clear_context" + and decision_intent != "conversation_reset" + and not ( + hasattr(self, "policy") + and self._is_order_selection_reset_message(message) + ) + ): + return None + + cleaned_message = ( + self._remove_order_selection_reset_prefix(message) + if hasattr(self, "policy") + else message + ) + self._clear_user_conversation_state(user_id=user_id) + if not cleaned_message or cleaned_message.strip() == (message or "").strip(): + return await finish("Contexto da conversa limpo. Podemos recomecar do zero.") + return await self.handle_message(cleaned_message, user_id=user_id) + def _resolve_entities_for_message_plan(self, message_plan: dict, routed_message: str) -> dict: return self.planner.resolve_entities_for_message_plan(message_plan=message_plan, routed_message=routed_message) @@ -929,9 +994,27 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): self, turn_decision: dict | None, extracted_entities: dict | None, + user_id: int | None = None, ) -> bool: decision = turn_decision or {} - if str(decision.get("intent") or "").strip().lower() != "order_create": + decision_intent = str(decision.get("intent") or "").strip().lower() + has_open_cancel_order_draft = bool( + user_id is not None and self.state.get_entry("pending_cancel_order_drafts", user_id, expire=True) + ) + if has_open_cancel_order_draft: + return True + if decision_intent == "order_list": + return True + if decision_intent == "order_cancel": + cancel_order_fields = (extracted_entities if isinstance(extracted_entities, dict) else {}).get("cancel_order_fields") + if not isinstance(cancel_order_fields, dict): + cancel_order_fields = {} + return bool( + cancel_order_fields.get("numero_pedido") + or cancel_order_fields.get("motivo") + or has_open_cancel_order_draft + ) + if decision_intent != "order_create": return False entities = extracted_entities if isinstance(extracted_entities, dict) else {} @@ -952,6 +1035,48 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): ) ) + def _should_prioritize_review_flow( + self, + turn_decision: dict | None, + extracted_entities: dict | None, + user_id: int | None = None, + ) -> bool: + has_open_review_draft = bool( + user_id is not None + and ( + self.state.get_entry("pending_review_drafts", user_id, expire=True) + or self.state.get_entry("pending_review_reuse_confirmations", user_id, expire=True) + or self.state.get_entry("pending_review_confirmations", user_id, expire=True) + ) + ) + if has_open_review_draft: + return True + + decision = turn_decision or {} + decision_intent = str(decision.get("intent") or "").strip().lower() + if decision_intent != "review_schedule": + return False + + entities = extracted_entities if isinstance(extracted_entities, dict) else {} + review_fields = entities.get("review_fields") + generic_memory = entities.get("generic_memory") + if not isinstance(review_fields, dict): + review_fields = {} + if not isinstance(generic_memory, dict): + generic_memory = {} + + return any( + ( + review_fields.get("placa"), + review_fields.get("data_hora"), + review_fields.get("modelo"), + review_fields.get("ano"), + review_fields.get("km"), + review_fields.get("revisao_previa_concessionaria"), + generic_memory.get("placa"), + ) + ) + def _parse_json_object(self, text: str): return self.normalizer.parse_json_object(text) diff --git a/app/services/orchestration/response_formatter.py b/app/services/orchestration/response_formatter.py index eda4147..f0e0f32 100644 --- a/app/services/orchestration/response_formatter.py +++ b/app/services/orchestration/response_formatter.py @@ -62,6 +62,21 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str: lines.append(f"Status do veiculo: {status_veiculo}") return "\n".join(lines) + if tool_name == "listar_pedidos" and isinstance(tool_result, list): + if not tool_result: + return "Nao encontrei pedidos vinculados a sua conta." + linhas = [f"Encontrei {len(tool_result)} pedido(s):"] + for idx, item in enumerate(tool_result[:10], start=1): + numero = item.get("numero_pedido", "N/A") + modelo = item.get("modelo_veiculo") or "Veiculo nao informado" + status = item.get("status", "N/A") + valor = format_currency_br(item.get("valor_veiculo")) if item.get("valor_veiculo") is not None else "N/A" + linhas.append(f"{idx}. {numero} | {modelo} | {status} | {valor}") + restantes = len(tool_result) - 10 + if restantes > 0: + linhas.append(f"... e mais {restantes} pedido(s).") + return "\n".join(linhas) + if tool_name == "agendar_revisao" and isinstance(tool_result, dict): placa = tool_result.get("placa", "N/A") data_hora = format_datetime_for_chat(tool_result.get("data_hora", "N/A")) diff --git a/app/services/orchestration/turn_decision.py b/app/services/orchestration/turn_decision.py index 69ece47..cb203ee 100644 --- a/app/services/orchestration/turn_decision.py +++ b/app/services/orchestration/turn_decision.py @@ -11,6 +11,7 @@ TurnIntent = Literal[ "review_cancel", "review_reschedule", "order_create", + "order_list", "order_cancel", "inventory_search", "conversation_reset", diff --git a/app/services/tools/handlers.py b/app/services/tools/handlers.py index f61251c..4094a0d 100644 --- a/app/services/tools/handlers.py +++ b/app/services/tools/handlers.py @@ -2,7 +2,7 @@ from typing import Any from app.services.domain.credit_service import validar_cliente_venda from app.services.domain.inventory_service import avaliar_veiculo_troca, consultar_estoque -from app.services.domain.order_service import cancelar_pedido, realizar_pedido +from app.services.domain.order_service import cancelar_pedido, listar_pedidos, realizar_pedido from app.services.domain.review_service import ( agendar_revisao, cancelar_agendamento_revisao, @@ -28,6 +28,7 @@ __all__ = [ "consultar_estoque", "editar_data_revisao", "listar_agendamentos_revisao", + "listar_pedidos", "realizar_pedido", "validar_cliente_venda", ] diff --git a/app/services/tools/tool_registry.py b/app/services/tools/tool_registry.py index a7ade46..85cf725 100644 --- a/app/services/tools/tool_registry.py +++ b/app/services/tools/tool_registry.py @@ -12,6 +12,7 @@ from app.services.tools.handlers import ( cancelar_pedido, editar_data_revisao, listar_agendamentos_revisao, + listar_pedidos, consultar_estoque, realizar_pedido, validar_cliente_venda, @@ -27,6 +28,7 @@ HANDLERS: Dict[str, Callable] = { "cancelar_agendamento_revisao": cancelar_agendamento_revisao, "editar_data_revisao": editar_data_revisao, "cancelar_pedido": cancelar_pedido, + "listar_pedidos": listar_pedidos, "realizar_pedido": realizar_pedido, }