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.
386 lines
21 KiB
Python
386 lines
21 KiB
Python
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")),
|
|
}
|
|
|
|
|