🧠 feat(orchestration): reforcar extracao estruturada para pedidos de compra

- remover o fallback semantico local de orcamento e perfil do fluxo de vendas
- enriquecer o turno sempre com uma extracao dedicada de entidades apos a decisao estruturada
- endurecer os prompts para obrigar o modelo a preencher orcamento_max e perfil_veiculo em pedidos de compra
- manter o fluxo alinhado ao contrato do modelo sem reintroduzir regex conversacional
main
parent 0eb56f1f0a
commit 6d6b7291ea

@ -53,50 +53,6 @@ class OrderFlowMixin:
}
return any(term in normalized for term in stock_terms)
def _extract_budget_hint_from_message(self, message: str) -> float | None:
normalized = self._normalize_text(message)
patterns = (
r"(?:ate|até|de|por|valor|orcamento|orcamento de)\s*r?\$?\s*(\d{1,3}(?:[.,]\d{3})*(?:,\d+)?|\d+(?:[.,]\d+)?)\s*mil\b",
r"(?:ate|até|de|por|valor|orcamento|orcamento de)\s*r?\$?\s*(\d{4,6}(?:[.,]\d{2})?)\b",
)
for pattern in patterns:
match = re.search(pattern, normalized)
if not match:
continue
extracted = self._normalize_positive_number(match.group(1) + (" mil" if "mil" in match.group(0) else ""))
if extracted:
return extracted
return None
def _extract_vehicle_profile_hint_from_message(self, message: str) -> list[str]:
normalized = self._normalize_text(message)
allowed = []
for marker in ("suv", "sedan", "hatch", "pickup"):
if marker in normalized:
allowed.append(marker)
return allowed
def _capture_order_search_hints_from_message(self, user_id: int | None, message: str) -> None:
if user_id is None:
return
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return
generic_memory = context.get("generic_memory")
if not isinstance(generic_memory, dict):
generic_memory = {}
context["generic_memory"] = generic_memory
if "orcamento_max" not in generic_memory:
budget = self._extract_budget_hint_from_message(message)
if budget:
generic_memory["orcamento_max"] = int(round(budget))
if "perfil_veiculo" not in generic_memory or not generic_memory.get("perfil_veiculo"):
profile = self._extract_vehicle_profile_hint_from_message(message)
if profile:
generic_memory["perfil_veiculo"] = profile
def _is_valid_cpf(self, cpf: str) -> bool:
return is_valid_cpf(cpf)
@ -434,7 +390,6 @@ class OrderFlowMixin:
}
draft["payload"].update(extracted)
self._capture_order_search_hints_from_message(user_id=user_id, message=message)
self._try_prefill_order_cpf_from_memory(user_id=user_id, payload=draft["payload"])
self._try_prefill_order_vehicle_from_context(user_id=user_id, payload=draft["payload"])

@ -40,6 +40,8 @@ class MessagePlanner:
"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}"
@ -114,6 +116,10 @@ class MessagePlanner:
' "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}"
)
@ -149,6 +155,8 @@ class MessagePlanner:
"- '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 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"

@ -136,20 +136,19 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
message=routing_message,
user_id=user_id,
)
llm_extracted_entities = await self._extract_entities_with_llm(
message=routing_message,
user_id=user_id,
)
extracted_entities = self._merge_extracted_entities(
extracted_entities,
llm_extracted_entities,
)
if self._has_useful_turn_decision(turn_decision):
extracted_entities = self._merge_extracted_entities(
extracted_entities,
self._extracted_entities_from_turn_decision(turn_decision),
)
else:
llm_extracted_entities = await self._extract_entities_with_llm(
message=routing_message,
user_id=user_id,
)
extracted_entities = self._merge_extracted_entities(
extracted_entities,
llm_extracted_entities,
)
self._capture_generic_memory(
user_id=user_id,
llm_generic_fields=extracted_entities.get("generic_memory", {}),

@ -361,38 +361,6 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("Encontrei 2 veiculo(s):", response)
self.assertIn("Honda Civic 2021", response)
async def test_order_flow_extracts_budget_from_message_when_llm_does_not_fill_generic_memory(self):
state = FakeState(
contexts={
10: {
"generic_memory": {"cpf": "12345678909"},
"last_stock_results": [],
"selected_vehicle": None,
}
}
)
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
async def fake_hydrate_mock_customer_from_cpf(cpf: str, user_id: int | None = None):
return {"cpf": cpf, "user_id": user_id}
with patch(
"app.services.flows.order_flow.hydrate_mock_customer_from_cpf",
new=fake_hydrate_mock_customer_from_cpf,
):
response = await flow._try_collect_and_create_order(
message="Quero comprar um carro de 70 mil, meu CPF e 12345678909",
user_id=10,
extracted_fields={"cpf": "12345678909"},
intents={},
turn_decision={"intent": "order_create", "domain": "sales", "action": "collect_order_create"},
)
self.assertEqual(registry.calls[0][0], "consultar_estoque")
self.assertEqual(state.get_user_context(10)["generic_memory"]["orcamento_max"], 70000)
self.assertIn("Encontrei 2 veiculo(s):", response)
async def test_order_flow_lists_stock_from_budget_when_vehicle_is_missing(self):
state = FakeState(
entries={

Loading…
Cancel
Save