🐛 fix(sales): atualizar estoque pelo contexto e padronizar escolha por lista

- invalidar resultados antigos quando orcamento ou perfil de veiculo mudarem durante a compra
- relistar o estoque automaticamente ao iniciar um novo pedido com contexto atualizado
- preservar drafts de pedido em erros recuperaveis e limpar apenas o campo invalido
- remover IDs da vitrine de estoque e orientar a escolha pelo numero da opcao exibida
main
parent 407727d80b
commit 5f229bd745

@ -161,6 +161,66 @@ class OrderFlowMixin:
arguments["ordenar_preco"] = "asc"
return arguments
def _should_refresh_stock_context(self, user_id: int | None, payload: dict | None = None) -> bool:
if user_id is None:
return False
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return False
generic_memory = context.get("generic_memory", {}) if isinstance(context.get("generic_memory"), dict) else {}
selected_vehicle = context.get("selected_vehicle")
last_stock_results = context.get("last_stock_results") or []
source = payload if isinstance(payload, dict) else {}
budget = generic_memory.get("orcamento_max")
if isinstance(budget, (int, float)) and float(budget) > 0:
if isinstance(selected_vehicle, dict):
try:
if float(selected_vehicle.get("preco") or 0) > float(budget):
return True
except (TypeError, ValueError):
return True
for item in last_stock_results:
if not isinstance(item, dict):
continue
try:
if float(item.get("preco") or 0) > float(budget):
return True
except (TypeError, ValueError):
return True
perfil = generic_memory.get("perfil_veiculo")
expected_category = str(perfil[0]).strip().lower() if isinstance(perfil, list) and perfil else None
if expected_category:
if isinstance(selected_vehicle, dict):
if str(selected_vehicle.get("categoria") or "").strip().lower() != expected_category:
return True
for item in last_stock_results:
if not isinstance(item, dict):
continue
if str(item.get("categoria") or "").strip().lower() != expected_category:
return True
vehicle_budget = source.get("valor_veiculo")
if isinstance(vehicle_budget, (int, float)) and isinstance(selected_vehicle, dict):
try:
if float(selected_vehicle.get("preco") or 0) > float(vehicle_budget):
return True
except (TypeError, ValueError):
return True
return False
def _reset_order_stock_context(self, user_id: int | None) -> None:
if user_id is None:
return
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return
context["last_stock_results"] = []
context["selected_vehicle"] = None
def _match_vehicle_from_message_index(self, message: str, stock_results: list[dict]) -> dict | None:
tokens = [token for token in re.findall(r"\d+", str(message or "")) if token.isdigit()]
if not tokens:
@ -236,7 +296,7 @@ class OrderFlowMixin:
lines = ["Para realizar o pedido, escolha primeiro qual veiculo voce quer comprar:"]
for idx, item in enumerate(stock_results[:5], start=1):
lines.append(
f"- {idx}. [{item.get('id', 'N/A')}] {item.get('modelo', 'N/A')} "
f"- {idx}. {item.get('modelo', 'N/A')} "
f"({item.get('categoria', 'N/A')}) - R$ {float(item.get('preco', 0)):.2f}"
)
lines.append("Pode responder com o numero da lista ou com o modelo do veiculo.")
@ -248,8 +308,11 @@ class OrderFlowMixin:
user_id: int | None,
payload: dict,
turn_decision: dict | None = None,
force: bool = False,
) -> str | None:
if user_id is None or not self._has_stock_listing_request(message, turn_decision=turn_decision):
if user_id is None:
return None
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)
@ -257,7 +320,7 @@ class OrderFlowMixin:
return None
try:
tool_result = await self.registry.execute(
tool_result = await self.tool_executor.execute(
"consultar_estoque",
arguments,
user_id=user_id,
@ -330,6 +393,12 @@ class OrderFlowMixin:
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"])
if self._should_refresh_stock_context(user_id=user_id, payload=draft["payload"]):
self._reset_order_stock_context(user_id=user_id)
draft["payload"].pop("vehicle_id", None)
draft["payload"].pop("modelo_veiculo", None)
draft["payload"].pop("valor_veiculo", None)
resolved_vehicle = self._try_resolve_order_vehicle(
message=message,
user_id=user_id,
@ -368,6 +437,7 @@ class OrderFlowMixin:
user_id=user_id,
payload=draft["payload"],
turn_decision=turn_decision,
force=has_intent or explicit_order_request,
)
if stock_response:
return stock_response
@ -377,7 +447,7 @@ class OrderFlowMixin:
return self._render_missing_order_fields_prompt(missing)
try:
tool_result = await self.registry.execute(
tool_result = await self.tool_executor.execute(
"realizar_pedido",
{
"cpf": draft["payload"]["cpf"],
@ -386,6 +456,11 @@ class OrderFlowMixin:
user_id=user_id,
)
except HTTPException as exc:
error = self.tool_executor.coerce_http_error(exc)
if error.get("retryable") and error.get("field"):
draft["payload"].pop(str(error["field"]), None)
draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES)
self.state.set_entry("pending_order_drafts", user_id, draft)
return self._http_exception_detail(exc)
self.state.pop_entry("pending_order_drafts", user_id)
@ -471,12 +546,17 @@ class OrderFlowMixin:
return self._render_missing_cancel_order_fields_prompt(missing)
try:
tool_result = await self.registry.execute(
tool_result = await self.tool_executor.execute(
"cancelar_pedido",
draft["payload"],
user_id=user_id,
)
except HTTPException as exc:
error = self.tool_executor.coerce_http_error(exc)
if error.get("retryable") and error.get("field"):
draft["payload"].pop(str(error["field"]), None)
draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_CANCEL_ORDER_DRAFT_TTL_MINUTES)
self.state.set_entry("pending_cancel_order_drafts", user_id, draft)
return self._http_exception_detail(exc)
self.state.pop_entry("pending_cancel_order_drafts", user_id)

@ -31,11 +31,11 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str:
modelo = item.get("modelo", "N/A")
categoria = item.get("categoria", "N/A")
preco = format_currency_br(item.get("preco"))
codigo = item.get("id", "N/A")
linhas.append(f"{idx}. [{codigo}] {modelo} ({categoria}) - {preco}")
linhas.append(f"{idx}. {modelo} ({categoria}) - {preco}")
restantes = len(tool_result) - 10
if restantes > 0:
linhas.append(f"... e mais {restantes} veiculo(s).")
linhas.append("Para escolher, responda com o numero da opcao desejada. Exemplo: 1.")
return "\n".join(linhas)
if tool_name == "cancelar_pedido" and isinstance(tool_result, dict):

@ -5,7 +5,10 @@ from unittest.mock import patch
os.environ.setdefault("DEBUG", "false")
from fastapi import HTTPException
from app.services.flows.order_flow import OrderFlowMixin
from app.services.flows.review_flow import ReviewFlowMixin
from app.services.orchestration.conversation_policy import ConversationPolicy
from app.services.orchestration.entity_normalizer import EntityNormalizer
from app.services.tools.handlers import _parse_data_hora_revisao
@ -53,9 +56,12 @@ class FakeService:
class FakeRegistry:
def __init__(self):
self.calls = []
self.raise_http_exception = None
async def execute(self, tool_name: str, arguments: dict, user_id: int | None = None):
self.calls.append((tool_name, arguments, user_id))
if self.raise_http_exception is not None:
raise self.raise_http_exception
if tool_name == "consultar_estoque":
return [
{"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 48500.0},
@ -65,6 +71,8 @@ class FakeRegistry:
vehicle_map = {
1: ("Honda Civic 2021", 51524.0),
2: ("Toyota Corolla 2020", 58476.0),
3: ("Chevrolet Onix 2022", 51809.0),
7: ("Fiat Argo 2020", 61857.0),
}
modelo_veiculo, valor_veiculo = vehicle_map[arguments["vehicle_id"]]
return {
@ -79,11 +87,21 @@ class FakeRegistry:
"motivo": arguments["motivo"],
}
def coerce_http_error(self, exc):
detail = exc.detail if isinstance(exc.detail, dict) else {"message": str(exc.detail)}
return {
"code": detail.get("code", "tool_error"),
"message": detail.get("message", str(exc.detail)),
"retryable": bool(detail.get("retryable", False)),
"field": detail.get("field"),
}
class OrderFlowHarness(OrderFlowMixin):
def __init__(self, state, registry):
self.state = state
self.registry = registry
self.tool_executor = registry
self.normalizer = EntityNormalizer()
def _get_user_context(self, user_id: int | None):
@ -108,7 +126,8 @@ class OrderFlowHarness(OrderFlowMixin):
if tool_name == "consultar_estoque":
lines = [f"Encontrei {len(tool_result)} veiculo(s):"]
for idx, item in enumerate(tool_result, start=1):
lines.append(f"{idx}. [{item['id']}] {item['modelo']} ({item['categoria']}) - R$ {item['preco']:.2f}")
lines.append(f"{idx}. {item['modelo']} ({item['categoria']}) - R$ {item['preco']:.2f}")
lines.append("Para escolher, responda com o numero da opcao desejada. Exemplo: 1.")
return "\n".join(lines)
if tool_name == "realizar_pedido":
return (
@ -134,6 +153,51 @@ class OrderFlowHarness(OrderFlowMixin):
return None
class ReviewFlowHarness(ReviewFlowMixin):
def __init__(self, state, registry):
self.state = state
self.registry = registry
self.tool_executor = registry
self.normalizer = EntityNormalizer()
self.captured_suggestions = []
def _normalize_intents(self, data) -> dict:
return self.normalizer.normalize_intents(data)
def _normalize_review_fields(self, data) -> dict:
return self.normalizer.normalize_review_fields(data)
def _normalize_review_management_fields(self, data) -> dict:
return self.normalizer.normalize_review_management_fields(data)
def _normalize_text(self, text: str) -> str:
return self.normalizer.normalize_text(text)
def _http_exception_detail(self, exc) -> str:
detail = exc.detail if isinstance(exc.detail, dict) else {}
return str(detail.get("message") or exc)
def _fallback_format_tool_result(self, tool_name: str, tool_result) -> str:
return f"{tool_name}:{tool_result}"
def _extract_review_protocol_from_text(self, text: str) -> str | None:
return self.normalizer.extract_review_protocol_from_text(text)
def _is_affirmative_message(self, text: str) -> bool:
normalized = self.normalizer.normalize_text(text).strip().rstrip(".!?,;:")
return normalized in {"sim", "pode", "ok", "confirmo", "aceito", "fechado", "pode sim", "tenho", "tenho sim"}
def _is_negative_message(self, text: str) -> bool:
normalized = self.normalizer.normalize_text(text).strip().rstrip(".!?,;:")
return normalized in {"nao", "nao quero", "prefiro outro", "outro horario"} or normalized.startswith("nao")
def _capture_review_confirmation_suggestion(self, **kwargs) -> None:
self.captured_suggestions.append(kwargs)
def _try_prefill_review_fields_from_memory(self, user_id: int | None, payload: dict) -> None:
return None
class ConversationAdjustmentsTests(unittest.TestCase):
def test_defer_flow_cancel_when_order_cancel_draft_waits_for_reason(self):
state = FakeState(
@ -276,15 +340,23 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_create_order(
message="Quero comprar um carro de 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"},
)
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 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.assertIn("qual veiculo do estoque voce quer comprar", response.lower())
self.assertIn("Encontrei 2 veiculo(s):", response)
self.assertIn("Honda Civic 2021", response)
async def test_order_flow_lists_stock_from_budget_when_vehicle_is_missing(self):
state = FakeState(
@ -307,13 +379,20 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_create_order(
message="liste os carros com esse valor em estoque",
user_id=10,
extracted_fields={},
intents={},
turn_decision={"intent": "inventory_search", "domain": "sales", "action": "call_tool"},
)
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="liste os carros com esse valor em estoque",
user_id=10,
extracted_fields={},
intents={},
turn_decision={"intent": "inventory_search", "domain": "sales", "action": "call_tool"},
)
self.assertEqual(registry.calls[0][0], "consultar_estoque")
self.assertIn("Encontrei 2 veiculo(s):", response)
@ -468,6 +547,218 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(arguments["cpf"], "12345678909")
self.assertIn("Veiculo: Toyota Corolla 2020", response)
async def test_order_flow_selection_uses_list_position_not_vehicle_id(self):
state = FakeState(
entries={
"pending_order_drafts": {
10: {
"payload": {"cpf": "12345678909"},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
},
contexts={
10: {
"generic_memory": {"cpf": "12345678909"},
"last_stock_results": [
{"id": 3, "modelo": "Chevrolet Onix 2022", "categoria": "suv", "preco": 51809.0},
{"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0},
],
"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="3",
user_id=10,
extracted_fields={},
intents={},
)
self.assertEqual(registry.calls, [])
self.assertIn("escolha primeiro qual veiculo", response.lower())
self.assertIn("1. Chevrolet Onix 2022", response)
self.assertIn("2. Fiat Argo 2020", response)
async def test_order_flow_keeps_draft_and_clears_retryable_field_on_tool_error(self):
state = FakeState(
entries={
"pending_order_drafts": {
10: {
"payload": {"cpf": "12345678909", "vehicle_id": 99},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
},
contexts={
10: {
"generic_memory": {"cpf": "12345678909"},
"last_stock_results": [],
"selected_vehicle": None,
}
},
)
registry = FakeRegistry()
registry.raise_http_exception = HTTPException(
status_code=409,
detail={
"code": "vehicle_already_reserved",
"message": "Este veiculo ja esta reservado e nao aparece mais no estoque disponivel.",
"retryable": True,
"field": "vehicle_id",
},
)
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 esse carro",
user_id=10,
extracted_fields={},
intents={},
)
draft = state.get_entry("pending_order_drafts", 10)
self.assertIn("ja esta reservado", response)
self.assertIsNotNone(draft)
self.assertEqual(draft["payload"].get("cpf"), "12345678909")
self.assertNotIn("vehicle_id", draft["payload"])
async def test_order_flow_refreshes_stale_stock_results_when_budget_changes(self):
state = FakeState(
contexts={
10: {
"generic_memory": {"cpf": "12345678909", "orcamento_max": 45000},
"last_stock_results": [
{"id": 3, "modelo": "Chevrolet Onix 2022", "categoria": "suv", "preco": 51809.0},
{"id": 7, "modelo": "Fiat Argo 2020", "categoria": "suv", "preco": 61857.0},
],
"selected_vehicle": {"id": 3, "modelo": "Chevrolet Onix 2022", "categoria": "suv", "preco": 51809.0},
}
}
)
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 45 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.assertNotIn("Chevrolet Onix 2022", response)
self.assertEqual(state.get_user_context(10)["selected_vehicle"], None)
self.assertEqual(len(state.get_user_context(10)["last_stock_results"]), 2)
async def test_order_flow_refreshes_stale_stock_results_when_profile_changes(self):
state = FakeState(
contexts={
10: {
"generic_memory": {"cpf": "12345678909", "orcamento_max": 50000, "perfil_veiculo": ["hatch"]},
"last_stock_results": [
{"id": 3, "modelo": "Chevrolet Onix 2022", "categoria": "suv", "preco": 48000.0},
],
"selected_vehicle": {"id": 3, "modelo": "Chevrolet Onix 2022", "categoria": "suv", "preco": 48000.0},
}
}
)
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 hatch de 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][0], "consultar_estoque")
self.assertEqual(state.get_user_context(10)["selected_vehicle"], None)
self.assertTrue(
all(item.get("categoria") != "suv" for item in state.get_user_context(10)["last_stock_results"])
)
class ReviewFlowDraftTests(unittest.IsolatedAsyncioTestCase):
async def test_review_flow_keeps_draft_and_clears_data_hora_on_retryable_error(self):
state = FakeState(
entries={
"pending_review_drafts": {
21: {
"payload": {
"placa": "ABC1234",
"data_hora": "2026-03-10T09:00:00-03:00",
"modelo": "HB20",
"ano": 2022,
"km": 15000,
"revisao_previa_concessionaria": True,
},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
}
)
registry = FakeRegistry()
registry.raise_http_exception = HTTPException(
status_code=409,
detail={
"code": "review_schedule_conflict",
"message": "O horario solicitado esta ocupado.",
"retryable": True,
"field": "data_hora",
"suggested_iso": "2026-03-10T09:30:00-03:00",
},
)
flow = ReviewFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_schedule_review(
message="agendar revisao",
user_id=21,
extracted_fields={},
intents={},
turn_decision={"intent": "review_schedule", "domain": "review", "action": "call_tool"},
)
draft = state.get_entry("pending_review_drafts", 21)
self.assertIn("ocupado", response)
self.assertIsNotNone(draft)
self.assertEqual(draft["payload"].get("placa"), "ABC1234")
self.assertNotIn("data_hora", draft["payload"])
if __name__ == "__main__":
unittest.main()

Loading…
Cancel
Save