🧠 feat(orchestration): completar contexto de busca de compra com extracao estruturada

- adicionar uma extracao estruturada focada em orcamento e perfil de veiculo para fluxos de vendas
- acionar esse enriquecimento apenas quando o turno ja for de compra e os campos vierem vazios
- manter o backend alinhado ao contrato do modelo sem reintroduzir heuristicas locais
- cobrir o enriquecimento com teste de contrato para o fluxo de decisao
main
parent 6d6b7291ea
commit e274dc9017

@ -140,6 +140,39 @@ class MessagePlanner:
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()

@ -153,6 +153,28 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
user_id=user_id,
llm_generic_fields=extracted_entities.get("generic_memory", {}),
)
sales_search_context = await self._extract_missing_sales_search_context_with_llm(
message=routing_message,
user_id=user_id,
turn_decision=turn_decision,
extracted_entities=extracted_entities,
)
if sales_search_context:
extracted_entities = self._merge_extracted_entities(
extracted_entities,
{
"generic_memory": sales_search_context,
"review_fields": {},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {},
"intents": {},
},
)
self._capture_generic_memory(
user_id=user_id,
llm_generic_fields=sales_search_context,
)
domain_hint = self._domain_from_turn_decision(turn_decision)
if domain_hint == "general":
@ -806,6 +828,29 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
async def _extract_entities_with_llm(self, message: str, user_id: int | None) -> dict:
return await self.planner.extract_entities(message=message, user_id=user_id)
async def _extract_sales_search_context_with_llm(self, message: str, user_id: int | None) -> dict:
return await self.planner.extract_sales_search_context(message=message, user_id=user_id)
async def _extract_missing_sales_search_context_with_llm(
self,
message: str,
user_id: int | None,
turn_decision: dict | None,
extracted_entities: dict | None,
) -> dict:
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"}:
return {}
generic_memory = (extracted_entities or {}).get("generic_memory")
if not isinstance(generic_memory, dict):
generic_memory = {}
if generic_memory.get("orcamento_max") or generic_memory.get("perfil_veiculo"):
return {}
return await self._extract_sales_search_context_with_llm(message=message, user_id=user_id)
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)

@ -274,6 +274,31 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(merged["generic_memory"]["orcamento_max"], 70000)
self.assertEqual(merged["order_fields"]["cpf"], "12345678909")
async def test_missing_sales_search_context_triggers_focused_llm_enrichment(self):
service = OrquestradorService.__new__(OrquestradorService)
service.normalizer = EntityNormalizer()
async def fake_extract_sales_search_context_with_llm(message: str, user_id: int | None):
return {"orcamento_max": 70000}
service._extract_sales_search_context_with_llm = fake_extract_sales_search_context_with_llm
result = await service._extract_missing_sales_search_context_with_llm(
message="Quero comprar um carro de 70 mil, meu CPF e 12345678909",
user_id=7,
turn_decision={"domain": "sales", "intent": "order_create", "action": "collect_order_create"},
extracted_entities={
"generic_memory": {},
"review_fields": {},
"review_management_fields": {},
"order_fields": {"cpf": "12345678909"},
"cancel_order_fields": {},
"intents": {},
},
)
self.assertEqual(result["orcamento_max"], 70000)
async def test_turn_decision_call_tool_executes_without_router(self):
service = OrquestradorService.__new__(OrquestradorService)
service.tool_executor = FakeToolExecutor(result={"numero_pedido": "PED-1", "status": "Ativo"})

Loading…
Cancel
Save