From e274dc9017c6ea409675bc42b46ade20d933e808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vitor=20Hugo=20Belorio=20Sim=C3=A3o?= Date: Wed, 11 Mar 2026 11:55:56 -0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=A0=20feat(orchestration):=20completar?= =?UTF-8?q?=20contexto=20de=20busca=20de=20compra=20com=20extracao=20estru?= =?UTF-8?q?turada?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/services/orchestration/message_planner.py | 33 ++++++++++++++ .../orchestration/orquestrador_service.py | 45 +++++++++++++++++++ tests/test_turn_decision_contract.py | 25 +++++++++++ 3 files changed, 103 insertions(+) diff --git a/app/services/orchestration/message_planner.py b/app/services/orchestration/message_planner.py index 53a77dd..982e6c7 100644 --- a/app/services/orchestration/message_planner.py +++ b/app/services/orchestration/message_planner.py @@ -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() diff --git a/app/services/orchestration/orquestrador_service.py b/app/services/orchestration/orquestrador_service.py index 1490040..65620e1 100644 --- a/app/services/orchestration/orquestrador_service.py +++ b/app/services/orchestration/orquestrador_service.py @@ -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) diff --git a/tests/test_turn_decision_contract.py b/tests/test_turn_decision_contract.py index d5f5eff..f9e053e 100644 --- a/tests/test_turn_decision_contract.py +++ b/tests/test_turn_decision_contract.py @@ -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"})