import logging import json from app.core.settings import settings from app.services.ai.llm_service import LLMService from app.services.orchestration.entity_normalizer import EntityNormalizer from app.services.orchestration.turn_decision import TurnDecision logger = logging.getLogger(__name__) # Esse componente pede ao modelo contratos estruturados # para roteamento, extracao tecnica e decisao por turno. class MessagePlanner: def __init__(self, llm: LLMService, normalizer: EntityNormalizer): self.llm = llm self.normalizer = normalizer async def extract_message_plan(self, message: str, user_id: int | None) -> dict: prompt = ( "Analise a mensagem e retorne APENAS JSON valido com roteamento e entidades por pedido.\n" "Sem markdown e sem texto extra.\n\n" "Formato:\n" "{\n" ' "orders": [\n' " {\n" ' "domain": "review|sales|general",\n' ' "message": "trecho literal do pedido",\n' ' "entities": {\n' ' "generic_memory": {"placa": null, "cpf": null, "orcamento_max": null, "perfil_veiculo": []},\n' ' "review_fields": {"placa": null, "data_hora": null, "modelo": null, "ano": null, "km": null, "revisao_previa_concessionaria": null},\n' ' "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_list": false, "order_cancel": false}\n' " }\n" " }\n" " ]\n" "}\n\n" "Regras:\n" "- Se houver mais de um pedido operacional, separe em itens distintos em ordem de aparicao.\n" "- Se nao houver pedido operacional, use domain='general' com a mensagem inteira.\n" "- Para pedidos de compra com faixa de preco ou orcamento (ex.: '70 mil', 'ate 50 mil', 'R$ 45000'), preencha generic_memory.orcamento_max.\n" "- Para pedidos com tipo de carro (ex.: suv, sedan, hatch, pickup), preencha generic_memory.perfil_veiculo.\n" "- Mantenha cada message curta e fiel ao texto do usuario.\n\n" f"Contexto: user_id={user_id if user_id is not None else 'anonimo'}\n" f"Mensagem do usuario: {message}" ) default = self.normalizer.empty_message_plan(message=message) try: result = await self.llm.generate_response(message=prompt, tools=[]) text = (result.get("response") or "").strip() payload = self.normalizer.parse_json_object(text) if not isinstance(payload, dict): logger.warning("Plano de mensagem invalido (nao JSON objeto). user_id=%s", user_id) return default return self.normalizer.coerce_message_plan(payload=payload, message=message) except Exception: logger.exception("Falha ao extrair plano da mensagem com LLM. user_id=%s", user_id) return default async def extract_turn_bundle(self, message: str, user_id: int | None) -> dict: user_context = f"user_id={user_id}" if user_id is not None else "user_id=anonimo" default_turn_decision = self.normalizer.empty_turn_decision() default_message_plan = self.normalizer.empty_message_plan(message=message) compact_turn_entities = { "generic_memory": {}, "review_fields": {}, "review_management_fields": {}, "order_fields": {}, "cancel_order_fields": {}, } compact_order_entities = { **compact_turn_entities, "intents": {}, } schema_example = json.dumps( { "turn_decision": { "intent": "general", "domain": "general", "action": "answer_user", "entities": compact_turn_entities, "missing_fields": [], "selection_index": None, "tool_name": None, "tool_arguments": {}, "response_to_user": None, }, "message_plan": { "orders": [ { "domain": "general", "message": "trecho literal do pedido", "entities": compact_order_entities, } ] }, }, ensure_ascii=True, ) prompt = ( "Analise a mensagem do usuario e retorne APENAS JSON valido com duas secoes: turn_decision e message_plan.\n" "Sem markdown, sem texto fora do JSON, sem inventar dados ausentes.\n\n" "Contrato:\n" f"{schema_example}\n\n" "Regras:\n" "- turn_decision resume a intencao principal do turno; domain deve ser review, sales ou general.\n" "- message_plan.orders separa pedidos operacionais em ordem; se nao houver pedido operacional, use um unico item general com a mensagem inteira.\n" "- Cada order deve ter domain, message e entities; mantenha message curta e fiel ao texto do usuario.\n" "- Preencha apenas dados claros. Use entities.generic_memory.orcamento_max para teto/faixa de preco e perfil_veiculo para suv/sedan/hatch/pickup.\n" "- Se faltar dado para continuar um fluxo, use action=ask_missing_fields e preencha missing_fields e response_to_user. Se nao houver acao operacional, use action=answer_user.\n" "- Compra efetiva: intent=order_create, domain=sales, prefira tool_name=realizar_pedido.\n" "- Listar pedidos: intent=order_list, domain=sales, action=call_tool, tool_name=listar_pedidos.\n" "- Consultar/listar/buscar/ver estoque para compra: intent=inventory_search, domain=sales, action=call_tool, tool_name=consultar_estoque; tool_arguments so com filtros explicitamente pedidos, como preco_max, categoria e opcionalmente limite.\n" "- Listar revisoes: intent=review_list, domain=review, action=call_tool, tool_name=listar_agendamentos_revisao.\n" "- Cancelar revisao: intent=review_cancel, domain=review, prefira tool_name=cancelar_agendamento_revisao.\n" "- Remarcar revisao: intent=review_reschedule, domain=review, prefira tool_name=editar_data_revisao.\n" "- Avaliar troca com modelo, ano e km: domain=sales, action=call_tool, tool_name=avaliar_veiculo_troca e informe esses campos em tool_arguments.\n\n" f"Contexto: {user_context}\n" f"Mensagem do usuario: {message}" ) preferred_models = getattr(self.llm, "bundle_model_names", None) bundle_generation_config = { "candidate_count": 1, **settings.build_atendimento_generation_config(), } for attempt in range(2): try: result = await self.llm.generate_response( message=prompt, tools=[], preferred_models=preferred_models if attempt == 0 else None, generation_config=bundle_generation_config, ) text = (result.get("response") or "").strip() payload = self.normalizer.parse_json_object(text) if not isinstance(payload, dict): if attempt == 0: logger.warning("Bundle estruturado invalido (nao JSON objeto); repetindo uma vez. user_id=%s", user_id) continue raw_turn_decision = payload.get("turn_decision") raw_message_plan = payload.get("message_plan") has_turn_decision = isinstance(raw_turn_decision, dict) and any( key in raw_turn_decision for key in ( "intent", "domain", "action", "entities", "tool_name", "tool_arguments", "response_to_user", "missing_fields", "selection_index", ) ) raw_orders = raw_message_plan.get("orders") if isinstance(raw_message_plan, dict) else None has_message_plan = isinstance(raw_orders, list) and len(raw_orders) > 0 bundle = { "turn_decision": self.normalizer.coerce_turn_decision(raw_turn_decision), "message_plan": self.normalizer.coerce_message_plan(raw_message_plan, message=message), "has_turn_decision": has_turn_decision, "has_message_plan": has_message_plan, } if has_turn_decision and has_message_plan: return bundle if has_turn_decision or has_message_plan: return bundle if attempt == 0: logger.warning( "Bundle estruturado incompleto; repetindo uma vez. user_id=%s has_turn_decision=%s has_message_plan=%s", user_id, has_turn_decision, has_message_plan, ) except Exception: logger.exception("Falha ao extrair bundle estruturado com LLM. user_id=%s", user_id) break return { "turn_decision": default_turn_decision, "message_plan": default_message_plan, "has_turn_decision": False, "has_message_plan": False, } async def extract_routing(self, message: str, user_id: int | None) -> dict: plan = await self.extract_message_plan(message=message, user_id=user_id) return { "orders": [ { "domain": item.get("domain", "general"), "message": item.get("message", ""), } for item in plan.get("orders", []) ] } async def extract_entities(self, message: str, user_id: int | None) -> dict: user_context = f"user_id={user_id}" if user_id is not None else "user_id=anonimo" prompt = ( "Extraia entidades da mensagem do usuario e retorne APENAS JSON valido.\n" "Nao use markdown, nao adicione texto antes/depois, nao invente dados ausentes.\n" "Se nao houver valor, use null ou lista vazia.\n\n" "Formato obrigatorio:\n" "{\n" ' "generic_memory": {\n' ' "placa": null,\n' ' "cpf": null,\n' ' "orcamento_max": null,\n' ' "perfil_veiculo": []\n' " },\n" ' "review_fields": {\n' ' "placa": null,\n' ' "data_hora": null,\n' ' "modelo": null,\n' ' "ano": null,\n' ' "km": null,\n' ' "revisao_previa_concessionaria": null\n' " },\n" ' "review_management_fields": {\n' ' "protocolo": null,\n' ' "nova_data_hora": null,\n' ' "motivo": null\n' " },\n" ' "order_fields": {\n' ' "cpf": null,\n' ' "vehicle_id": null,\n' ' "modelo_veiculo": null\n' " },\n" ' "cancel_order_fields": {\n' ' "numero_pedido": null,\n' ' "motivo": null\n' " },\n" ' "intents": {\n' ' "review_schedule": false,\n' ' "review_list": false,\n' ' "review_cancel": false,\n' ' "review_reschedule": false,\n' ' "order_create": false,\n' ' "order_list": false,\n' ' "order_cancel": false\n' " }\n" "}\n\n" "Regras adicionais:\n" "- Para pedidos de compra com faixa de preco ou orcamento (ex.: '70 mil', 'ate 50 mil', 'R$ 45000'), preencha generic_memory.orcamento_max.\n" "- Para pedidos com tipo de carro (ex.: suv, sedan, hatch, pickup), preencha generic_memory.perfil_veiculo.\n" "- Nao deixe generic_memory.orcamento_max vazio quando a mensagem expressar claramente o teto de compra.\n\n" f"Contexto: {user_context}\n" f"Mensagem do usuario: {message}" ) default = self.normalizer.empty_extraction_payload() try: result = await self.llm.generate_response(message=prompt, tools=[]) text = (result.get("response") or "").strip() if not text: logger.warning("Extracao vazia do LLM. user_id=%s", user_id) return default payload = self.normalizer.parse_json_object(text) if not isinstance(payload, dict): logger.warning("Extracao invalida (nao JSON objeto). user_id=%s", user_id) return default return self.normalize_extraction_payload(payload) except Exception: logger.exception("Falha ao extrair entidades com LLM. user_id=%s", user_id) return default async def extract_sales_search_context(self, message: str, user_id: int | None) -> dict: user_context = f"user_id={user_id}" if user_id is not None else "user_id=anonimo" prompt = ( "Analise apenas os filtros de compra de veiculos contidos na mensagem e retorne APENAS JSON valido.\n" "Nao use markdown e nao escreva texto fora do JSON.\n" "Preencha apenas quando o valor estiver claramente expresso na mensagem.\n\n" "Formato obrigatorio:\n" "{\n" ' "generic_memory": {\n' ' "orcamento_max": null,\n' ' "perfil_veiculo": []\n' " }\n" "}\n\n" "Regras:\n" "- Se o usuario informar faixa de preco ou teto de compra (ex.: '70 mil', 'ate 50 mil', 'R$ 45000'), preencha generic_memory.orcamento_max.\n" "- Se o usuario informar tipo de carro (ex.: suv, sedan, hatch, pickup), preencha generic_memory.perfil_veiculo.\n" "- Se nao houver um filtro claro, deixe null ou lista vazia.\n\n" f"Contexto: {user_context}\n" f"Mensagem do usuario: {message}" ) try: result = await self.llm.generate_response(message=prompt, tools=[]) text = (result.get("response") or "").strip() payload = self.normalizer.parse_json_object(text) if not isinstance(payload, dict): logger.warning("Extracao de contexto de compra invalida (nao JSON objeto). user_id=%s", user_id) return {} generic_memory = self.normalizer.normalize_generic_fields(payload.get("generic_memory")) return generic_memory if isinstance(generic_memory, dict) else {} except Exception: logger.exception("Falha ao extrair contexto de compra com LLM. user_id=%s", user_id) return {} async def extract_turn_decision(self, message: str, user_id: int | None) -> dict: user_context = f"user_id={user_id}" if user_id is not None else "user_id=anonimo" default = self.normalizer.empty_turn_decision() schema_example = json.dumps(TurnDecision().model_dump(), ensure_ascii=True) prompt = ( "Analise a mensagem do usuario e retorne APENAS JSON valido seguindo o contrato de decisao por turno.\n" "Nao use markdown. Nao escreva texto fora do JSON. Nao invente dados ausentes.\n" "Use regex apenas para formatos tecnicos; a decisao semantica deve vir do modelo.\n\n" "Contrato obrigatorio:\n" f"{schema_example}\n\n" "Regras:\n" "- 'domain' deve ser review, sales ou general.\n" "- 'intent' deve refletir a intencao principal do turno.\n" "- 'action' deve ser uma das acoes do contrato.\n" "- '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 efetivar a compra de um veiculo, use intent='order_create', domain='sales' e prefira tool_name='realizar_pedido'.\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 o usuario quiser consultar, listar, buscar ou ver veiculos/estoque para compra, use intent='inventory_search', domain='sales', action='call_tool' e tool_name='consultar_estoque'.\n" "- Em consultar_estoque, preencha tool_arguments apenas com filtros claramente expressos pelo usuario, como preco_max, categoria e opcionalmente limite.\n" "- Se o usuario quiser listar agendamentos de revisao, use intent='review_list', domain='review', action='call_tool' e tool_name='listar_agendamentos_revisao'.\n" "- Se o usuario quiser cancelar um agendamento de revisao, use intent='review_cancel', domain='review' e prefira tool_name='cancelar_agendamento_revisao'.\n" "- Se o usuario quiser remarcar um agendamento de revisao, use intent='review_reschedule', domain='review' e prefira tool_name='editar_data_revisao'.\n" "- Se o usuario quiser avaliar um veiculo na troca e houver modelo, ano e km, use domain='sales', action='call_tool', tool_name='avaliar_veiculo_troca' e informe esses campos em 'tool_arguments'. Nao peca versao ou placa se isso nao foi solicitado.\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" "- Se nao houver acao operacional, use action='answer_user'.\n\n" f"Contexto: {user_context}\n" f"Mensagem do usuario: {message}" ) # Faz um retry curto quando o modelo devolve JSON invalido, # evitando loops longos e mantendo fallback previsivel. for attempt in range(2): try: result = await self.llm.generate_response(message=prompt, tools=[]) text = (result.get("response") or "").strip() payload = self.normalizer.parse_json_object(text) decision = self.normalizer.coerce_turn_decision(payload) if decision != default: return decision if attempt == 0: logger.warning("Decisao estruturada invalida; repetindo uma vez. user_id=%s", user_id) except Exception: logger.exception("Falha ao extrair decisao por turno com LLM. user_id=%s", user_id) break return default def resolve_entities_for_message_plan(self, message_plan: dict, routed_message: str) -> dict: default = self.normalizer.empty_extraction_payload() if not isinstance(message_plan, dict): return default target = (routed_message or "").strip() raw_orders = message_plan.get("orders") if not isinstance(raw_orders, list): return default for item in raw_orders: if not isinstance(item, dict): continue segment = str(item.get("message") or "").strip() if segment != target: continue return self.normalize_extraction_payload(item.get("entities")) return default def normalize_extraction_payload(self, payload) -> dict: coerced = self.normalizer.coerce_extraction_contract(payload) return { "generic_memory": self.normalizer.normalize_generic_fields(coerced.get("generic_memory")), "review_fields": self.normalizer.normalize_review_fields(coerced.get("review_fields")), "review_management_fields": self.normalizer.normalize_review_management_fields(coerced.get("review_management_fields")), "order_fields": self.normalizer.normalize_order_fields(coerced.get("order_fields")), "cancel_order_fields": self.normalizer.normalize_cancel_order_fields(coerced.get("cancel_order_fields")), "intents": self.normalizer.normalize_intents(coerced.get("intents")), }