You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
orquestrador/app/services/orchestration/message_planner.py

357 lines
20 KiB
Python

import logging
import json
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)
schema_example = json.dumps(
{
"turn_decision": TurnDecision().model_dump(),
"message_plan": {
"orders": [
{
"domain": "general",
"message": "trecho literal do pedido",
"entities": self.normalizer.empty_extraction_payload(),
}
]
},
},
ensure_ascii=True,
)
prompt = (
"Analise a mensagem do usuario e retorne APENAS JSON valido com duas secoes: turn_decision e message_plan.\n"
"Nao use markdown. Nao escreva texto fora do JSON. Nao invente dados ausentes.\n\n"
"Formato obrigatorio:\n"
f"{schema_example}\n\n"
"Regras para turn_decision:\n"
"- 'turn_decision' deve seguir o contrato de decisao por turno.\n"
"- 'domain' deve ser review, sales ou general.\n"
"- 'intent' deve refletir a intencao principal do turno completo.\n"
"- 'action' deve ser uma das acoes do contrato.\n"
"- Se faltar dado para continuar um fluxo, use action='ask_missing_fields' e preencha 'missing_fields' e 'response_to_user'.\n"
"- Se nao houver acao operacional, use action='answer_user'.\n"
"- Em pedidos de compra com faixa de preco ou orcamento (ex.: '70 mil', 'ate 50 mil', 'R$ 45000'), preencha entities.generic_memory.orcamento_max.\n"
"- Em pedidos com tipo de carro (ex.: suv, sedan, hatch, pickup), preencha entities.generic_memory.perfil_veiculo.\n"
"- Se o usuario quiser efetivar a compra de um veiculo, use intent='order_create', domain='sales' e prefira tool_name='realizar_pedido'.\n"
"- Se o usuario quiser listar pedidos, use intent='order_list', domain='sales', action='call_tool' e tool_name='listar_pedidos'.\n"
"- Se o usuario quiser listar revisoes, use intent='review_list', domain='review', action='call_tool' e tool_name='listar_agendamentos_revisao'.\n"
"- Se o usuario quiser cancelar revisao, use intent='review_cancel', domain='review' e prefira tool_name='cancelar_agendamento_revisao'.\n"
"- Se o usuario quiser remarcar revisao, use intent='review_reschedule', domain='review' e prefira tool_name='editar_data_revisao'.\n\n"
"Regras para message_plan:\n"
"- 'message_plan.orders' deve listar os pedidos operacionais em ordem de aparicao.\n"
"- Se houver mais de um pedido operacional, separe em itens distintos.\n"
"- Se nao houver pedido operacional, use domain='general' com a mensagem inteira.\n"
"- Cada item deve conter 'domain', 'message' e 'entities'.\n"
"- Mantenha cada 'message' curta e fiel ao texto do usuario.\n"
"- Em pedidos de compra com faixa de preco ou orcamento (ex.: '70 mil', 'ate 50 mil', 'R$ 45000'), preencha entities.generic_memory.orcamento_max.\n"
"- Em pedidos com tipo de carro (ex.: suv, sedan, hatch, pickup), preencha entities.generic_memory.perfil_veiculo.\n\n"
f"Contexto: {user_context}\n"
f"Mensagem do usuario: {message}"
)
for attempt in range(2):
try:
result = await self.llm.generate_response(message=prompt, tools=[])
text = (result.get("response") or "").strip()
payload = self.normalizer.parse_json_object(text)
if not isinstance(payload, dict):
if attempt == 0:
logger.warning("Bundle estruturado invalido (nao JSON objeto); repetindo uma vez. user_id=%s", user_id)
continue
raw_turn_decision = payload.get("turn_decision")
raw_message_plan = payload.get("message_plan")
has_turn_decision = isinstance(raw_turn_decision, dict) and any(
key in raw_turn_decision
for key in (
"intent",
"domain",
"action",
"entities",
"tool_name",
"tool_arguments",
"response_to_user",
"missing_fields",
"selection_index",
)
)
raw_orders = raw_message_plan.get("orders") if isinstance(raw_message_plan, dict) else None
has_message_plan = isinstance(raw_orders, list) and len(raw_orders) > 0
bundle = {
"turn_decision": self.normalizer.coerce_turn_decision(raw_turn_decision),
"message_plan": self.normalizer.coerce_message_plan(raw_message_plan, message=message),
"has_turn_decision": has_turn_decision,
"has_message_plan": has_message_plan,
}
if has_turn_decision and has_message_plan:
return bundle
if attempt == 0:
logger.warning(
"Bundle estruturado incompleto; repetindo uma vez. user_id=%s has_turn_decision=%s has_message_plan=%s",
user_id,
has_turn_decision,
has_message_plan,
)
except Exception:
logger.exception("Falha ao extrair bundle estruturado com LLM. user_id=%s", user_id)
break
return {
"turn_decision": default_turn_decision,
"message_plan": default_message_plan,
"has_turn_decision": False,
"has_message_plan": False,
}
async def extract_routing(self, message: str, user_id: int | None) -> dict:
plan = await self.extract_message_plan(message=message, user_id=user_id)
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 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")),
}