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.
249 lines
13 KiB
Python
249 lines
13 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_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 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"
|
|
"- 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")),
|
|
}
|