🐛 fix(order): reaproveitar selecao textual e priorizar opcoes proximas ao orcamento

- aceita referencias parciais ao modelo a partir da ultima lista de estoque, incluindo prefixos relevantes como T-Cros para T-Cross, sem reiniciar o fluxo de pedido

- ordena a busca de estoque em ordem decrescente quando ha teto de orcamento, mantendo crescente apenas quando o cliente pede explicitamente a opcao mais barata

- amplia as regressões do fluxo de pedido para cobrir selecao textual reaproveitada, confirmacao por modelo e a priorizacao de ofertas mais proximas ao valor informado
main
parent 8a95e96df3
commit c7175aa700

@ -363,7 +363,12 @@ class OrderFlowMixin:
if selected_vehicle:
payload.update(self._vehicle_to_payload(selected_vehicle))
def _build_stock_lookup_arguments(self, user_id: int | None, payload: dict | None = None) -> dict:
def _build_stock_lookup_arguments(
self,
user_id: int | None,
payload: dict | None = None,
message: str | None = None,
) -> dict:
context = self._get_user_context(user_id)
generic_memory = context.get("generic_memory", {}) if isinstance(context, dict) else {}
source = payload if isinstance(payload, dict) else {}
@ -380,9 +385,36 @@ class OrderFlowMixin:
arguments["categoria"] = str(perfil[0]).strip().lower()
arguments["limite"] = 5
arguments["ordenar_preco"] = "asc"
arguments["ordenar_preco"] = self._resolve_stock_price_order(
has_budget="preco_max" in arguments,
message=message,
)
return arguments
def _resolve_stock_price_order(self, has_budget: bool, message: str | None = None) -> str:
if not has_budget:
return "asc"
normalized = self._normalize_text(message or "").strip()
cheaper_first_terms = {
"mais barato",
"mais baratos",
"mais barata",
"mais baratas",
"menor preco",
"menor valor",
"mais em conta",
"mais economico",
"mais economica",
"mais acessivel",
"mais acessiveis",
"baratinho",
"baratinhos",
}
if any(term in normalized for term in cheaper_first_terms):
return "asc"
return "desc"
def _should_refresh_stock_context(self, user_id: int | None, payload: dict | None = None) -> bool:
if user_id is None:
return False
@ -466,8 +498,101 @@ class OrderFlowMixin:
matches.append(item)
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
normalized_matches = {self._normalize_text(str(item.get("modelo") or "")).strip() for item in matches}
if len(normalized_matches) == 1:
return matches[0]
reference_tokens = self._extract_vehicle_reference_tokens(message)
if not reference_tokens:
return None
scored_matches: list[tuple[int, dict]] = []
for item in stock_results:
model_tokens = self._extract_vehicle_reference_tokens(str(item.get("modelo") or ""))
if not model_tokens:
continue
score = self._score_vehicle_reference_tokens(reference_tokens, model_tokens)
if score > 0:
scored_matches.append((score, item))
if not scored_matches:
return None
best_score = max(score for score, _ in scored_matches)
if best_score <= 0:
return None
if best_score == 1 and len(reference_tokens) > 1:
return None
best_matches = [item for score, item in scored_matches if score == best_score]
if len(best_matches) == 1:
return best_matches[0]
normalized_matches = {self._normalize_text(str(item.get("modelo") or "")).strip() for item in best_matches}
if len(normalized_matches) == 1:
return best_matches[0]
return None
def _extract_vehicle_reference_tokens(self, text: str) -> list[str]:
normalized = self._normalize_text(text)
raw_tokens = re.findall(r"[a-z0-9]+", normalized)
ignored_tokens = {
"a",
"ao",
"aos",
"as",
"carro",
"carros",
"comprar",
"compra",
"compre",
"da",
"das",
"de",
"desse",
"dessa",
"do",
"dos",
"esse",
"essa",
"este",
"esta",
"fazer",
"gostaria",
"modelo",
"o",
"os",
"pedido",
"pedir",
"por",
"pra",
"quero",
"um",
"uma",
"veiculo",
"veiculos",
}
tokens: list[str] = []
for token in raw_tokens:
if token in ignored_tokens or token.isdigit():
continue
if len(token) < 3:
continue
tokens.append(token)
return tokens
def _score_vehicle_reference_tokens(self, reference_tokens: list[str], model_tokens: list[str]) -> int:
score = 0
for reference_token in reference_tokens:
for model_token in model_tokens:
if reference_token == model_token:
score += 1
break
if len(reference_token) >= 4 and model_token.startswith(reference_token):
score += 1
break
return score
def _load_vehicle_by_id(self, vehicle_id: int) -> dict | None:
db = SessionMockLocal()
try:
@ -620,8 +745,7 @@ class OrderFlowMixin:
return True
if normalized_message == "1":
return True
normalized_model = self._normalize_text(str(vehicle.get("modelo") or "")).strip()
if normalized_model and normalized_model in normalized_message:
if self._match_vehicle_from_message_model(message=message, stock_results=[vehicle]):
return True
return False
@ -638,7 +762,7 @@ class OrderFlowMixin:
if not force and not self._has_stock_listing_request(message, turn_decision=turn_decision):
return None
arguments = self._build_stock_lookup_arguments(user_id=user_id, payload=payload)
arguments = self._build_stock_lookup_arguments(user_id=user_id, payload=payload, message=message)
if "preco_max" not in arguments and "categoria" not in arguments:
return None
@ -1028,3 +1152,4 @@ class OrderFlowMixin:
)
return self._fallback_format_tool_result("cancelar_pedido", tool_result)

@ -77,10 +77,12 @@ class FakeRegistry:
if self.raise_http_exception is not None:
raise self.raise_http_exception
if tool_name == "consultar_estoque":
return [
stock_results = [
{"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 48500.0},
{"id": 2, "modelo": "Toyota Yaris 2020", "categoria": "hatch", "preco": 49900.0},
]
reverse = str(arguments.get("ordenar_preco") or "asc").lower() == "desc"
return sorted(stock_results, key=lambda item: float(item["preco"]), reverse=reverse)
if tool_name == "listar_pedidos":
return [
{
@ -190,7 +192,7 @@ class OrderFlowHarness(OrderFlowMixin):
def _is_negative_message(self, text: str) -> bool:
normalized = self.normalizer.normalize_text(text).strip().rstrip(".!?,;:")
return normalized in {"nao", "não", "negativo", "cancelar", "outro", "outra opcao", "outra opção"}
return normalized in {"nao", "n\u00e3o", "negativo", "cancelar", "outro", "outra opcao", "outra op\u00e7\u00e3o"}
def _normalize_positive_number(self, value):
return self.normalizer.normalize_positive_number(value)
@ -377,12 +379,12 @@ class ConversationAdjustmentsTests(unittest.TestCase):
normalizer = EntityNormalizer()
self.assertEqual(
normalizer.normalize_datetime_connector("10/03/2026 às 09:00"),
normalizer.normalize_datetime_connector("10/03/2026 \u00e0s 09:00"),
"10/03/2026 09:00",
)
def test_parse_review_datetime_accepts_as_com_acento(self):
parsed = _parse_data_hora_revisao("10/03/2026 às 09:00")
parsed = _parse_data_hora_revisao("10/03/2026 \u00e0s 09:00")
self.assertEqual(parsed, datetime(2026, 3, 10, 9, 0))
@ -409,11 +411,11 @@ class ConversationAdjustmentsTests(unittest.TestCase):
state = FakeState()
policy = ConversationPolicy(service=FakeService(state))
message = "Esqueça as operações anteriores, agora quero agendar revisão para ABC1234"
message = "Esque\u00e7a as opera\u00e7\u00f5es anteriores, agora quero agendar revis\u00e3o para ABC1234"
cleaned = policy.remove_order_selection_reset_prefix(message)
self.assertTrue(policy.is_order_selection_reset_message(message))
self.assertEqual(cleaned, "quero agendar revisão para ABC1234")
self.assertEqual(cleaned, "quero agendar revis\u00e3o para ABC1234")
class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase):
@ -657,6 +659,72 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("Encontrei 2 veiculo(s):", response)
self.assertIn("Honda Civic 2021", response)
async def test_order_flow_budget_search_prioritizes_matches_closest_to_ceiling(self):
state = FakeState(
contexts={
10: {
"generic_memory": {"cpf": "12345678909", "orcamento_max": 50000},
"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 ate 50 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][1]["ordenar_preco"], "desc")
self.assertIn("1. Toyota Yaris 2020 (hatch) - R$ 49900.00", response)
self.assertIn("2. Honda Civic 2021 (sedan) - R$ 48500.00", response)
self.assertEqual(flow._get_last_stock_results(user_id=10)[0]["modelo"], "Toyota Yaris 2020")
async def test_order_flow_budget_search_keeps_cheapest_first_when_user_asks_for_cheapest(self):
state = FakeState(
contexts={
10: {
"generic_memory": {"cpf": "12345678909", "orcamento_max": 50000},
"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 o carro mais barato ate 50 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][1]["ordenar_preco"], "asc")
self.assertIn("1. Honda Civic 2021 (sedan) - R$ 48500.00", response)
self.assertIn("2. Toyota Yaris 2020 (hatch) - R$ 49900.00", response)
self.assertEqual(flow._get_last_stock_results(user_id=10)[0]["modelo"], "Honda Civic 2021")
async def test_order_flow_extracts_budget_from_message_when_llm_misses_it(self):
state = FakeState(
contexts={
@ -1099,6 +1167,40 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(arguments["cpf"], "12345678909")
self.assertIn("Veiculo: Hyundai HB20S 2022", second_response)
async def test_order_flow_accepts_partial_model_reference_from_last_stock_results(self):
state = FakeState(
contexts={
10: {
"active_domain": "sales",
"generic_memory": {"orcamento_max": 70000},
"shared_memory": {"orcamento_max": 70000},
"last_stock_results": [
{"id": 15, "modelo": "Volkswagen T-Cross 2024", "categoria": "hatch", "preco": 59306.0},
{"id": 16, "modelo": "Volkswagen T-Cross 2024", "categoria": "hatch", "preco": 59306.0},
{"id": 2, "modelo": "Toyota Corolla 2020", "categoria": "hatch", "preco": 58476.0},
],
"selected_vehicle": None,
}
}
)
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_create_order(
message="gostaria de fazer o pedido do Volkswagen T-Cros",
user_id=10,
extracted_fields={},
intents={"order_create": True},
)
draft = state.get_entry("pending_order_drafts", 10)
self.assertIsNotNone(draft)
self.assertEqual(draft["payload"]["vehicle_id"], 15)
self.assertEqual(draft["payload"]["modelo_veiculo"], "Volkswagen T-Cross 2024")
self.assertEqual(state.get_user_context(10)["selected_vehicle"]["id"], 15)
self.assertIn("cpf do cliente", response.lower())
self.assertEqual(registry.calls, [])
async def test_order_flow_bootstraps_selection_from_last_stock_results_without_repeating_order_verb(self):
state = FakeState(
contexts={
@ -2625,3 +2727,4 @@ class ToolRegistryExecutionTests(unittest.IsolatedAsyncioTestCase):
if __name__ == "__main__":
unittest.main()

Loading…
Cancel
Save