🚗 feat(sales): vincular pedido ao veiculo selecionado e endurecer fluxos conversacionais

Passa a criar pedidos de compra a partir de um veiculo concreto do estoque selecionado na conversa, reaproveitando a ultima consulta e exibindo o modelo escolhido na resposta final.

Tambem endurece a orquestracao contra vazamento de contexto entre compra, cancelamento e revisao, preserva o estado necessario no fluxo e adiciona testes de regressao para os cenarios validados no Telegram.
main
parent 9316e3e495
commit 134a5fef41

@ -87,5 +87,5 @@ class CancelarPedidoRequest(BaseModel):
class RealizarPedidoRequest(BaseModel): class RealizarPedidoRequest(BaseModel):
cpf: str cpf: str
valor_veiculo: float vehicle_id: int
user_id: Optional[int] = None user_id: Optional[int] = None

@ -54,6 +54,9 @@ class Order(MockBase):
numero_pedido = Column(String(40), unique=True, nullable=False, index=True) numero_pedido = Column(String(40), unique=True, nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
cpf = Column(String(11), ForeignKey("customers.cpf"), nullable=False, index=True) cpf = Column(String(11), ForeignKey("customers.cpf"), nullable=False, index=True)
vehicle_id = Column(Integer, ForeignKey("vehicles.id"), nullable=True, index=True)
modelo_veiculo = Column(String(120), nullable=True)
valor_veiculo = Column(Float, nullable=True)
status = Column(String(20), nullable=False, default="Ativo") status = Column(String(20), nullable=False, default="Ativo")
motivo_cancelamento = Column(Text, nullable=True) motivo_cancelamento = Column(Text, nullable=True)
data_cancelamento = Column(DateTime, nullable=True) data_cancelamento = Column(DateTime, nullable=True)

@ -217,8 +217,9 @@ def get_tools_definitions():
"name": "realizar_pedido", "name": "realizar_pedido",
"description": ( "description": (
"Use esta ferramenta quando o cliente quiser efetivar uma compra/pedido. " "Use esta ferramenta quando o cliente quiser efetivar uma compra/pedido. "
"Ela recebe CPF e valor do veiculo, valida credito e, se aprovado, cria " "Ela recebe CPF e o identificador do veiculo escolhido no estoque, valida "
"um novo pedido com numero unico." "credito com base no preco real do carro e, se aprovado, cria um novo pedido "
"com numero unico."
), ),
"parameters": { "parameters": {
"type": "object", "type": "object",
@ -227,12 +228,12 @@ def get_tools_definitions():
"type": "string", "type": "string",
"description": "CPF do cliente, com ou sem formatacao.", "description": "CPF do cliente, com ou sem formatacao.",
}, },
"valor_veiculo": { "vehicle_id": {
"type": "number", "type": "integer",
"description": "Valor do veiculo em reais (BRL) para gerar o pedido.", "description": "Codigo do veiculo escolhido no estoque.",
}, },
}, },
"required": ["cpf", "valor_veiculo"], "required": ["cpf", "vehicle_id"],
}, },
}, },
{ {

@ -4,7 +4,7 @@ from datetime import datetime, timedelta
from fastapi import HTTPException from fastapi import HTTPException
from app.db.mock_database import SessionMockLocal from app.db.mock_database import SessionMockLocal
from app.db.mock_models import User from app.db.mock_models import User, Vehicle
from app.services.orchestration.orchestrator_config import ( from app.services.orchestration.orchestrator_config import (
CANCEL_ORDER_REQUIRED_FIELDS, CANCEL_ORDER_REQUIRED_FIELDS,
ORDER_REQUIRED_FIELDS, ORDER_REQUIRED_FIELDS,
@ -50,18 +50,6 @@ class OrderFlowMixin:
second_digit = 0 if second_digit >= 10 else second_digit second_digit = 0 if second_digit >= 10 else second_digit
return second_digit == numbers[10] return second_digit == numbers[10]
def _try_prefill_order_value_from_memory(self, user_id: int | None, payload: dict) -> None:
if user_id is None or payload.get("valor_veiculo") is not None:
return
context = self._get_user_context(user_id)
if not context:
return
memory = context.get("generic_memory", {})
budget = memory.get("orcamento_max")
if isinstance(budget, (int, float)) and budget > 0:
payload["valor_veiculo"] = float(budget)
def _try_prefill_order_cpf_from_memory(self, user_id: int | None, payload: dict) -> None: def _try_prefill_order_cpf_from_memory(self, user_id: int | None, payload: dict) -> None:
if user_id is None or payload.get("cpf"): if user_id is None or payload.get("cpf"):
return return
@ -86,14 +74,121 @@ class OrderFlowMixin:
finally: finally:
db.close() db.close()
def _get_last_stock_results(self, user_id: int | None) -> list[dict]:
context = self._get_user_context(user_id)
if not context:
return []
stock_results = context.get("last_stock_results") or []
return stock_results if isinstance(stock_results, list) else []
def _get_selected_vehicle(self, user_id: int | None) -> dict | None:
context = self._get_user_context(user_id)
if not context:
return None
selected_vehicle = context.get("selected_vehicle")
return dict(selected_vehicle) if isinstance(selected_vehicle, dict) else None
def _store_selected_vehicle(self, user_id: int | None, vehicle: dict | None) -> None:
if user_id is None:
return
context = self._get_user_context(user_id)
if not context:
return
context["selected_vehicle"] = dict(vehicle) if isinstance(vehicle, dict) else None
def _vehicle_to_payload(self, vehicle: dict) -> dict:
return {
"vehicle_id": int(vehicle["id"]),
"modelo_veiculo": str(vehicle["modelo"]),
"valor_veiculo": round(float(vehicle["preco"]), 2),
}
def _try_prefill_order_vehicle_from_context(self, user_id: int | None, payload: dict) -> None:
if user_id is None or payload.get("vehicle_id"):
return
selected_vehicle = self._get_selected_vehicle(user_id=user_id)
if selected_vehicle:
payload.update(self._vehicle_to_payload(selected_vehicle))
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:
return None
choice = int(tokens[0])
if 1 <= choice <= len(stock_results):
return stock_results[choice - 1]
return None
def _match_vehicle_from_message_model(self, message: str, stock_results: list[dict]) -> dict | None:
normalized_message = self._normalize_text(message)
matches = []
for item in stock_results:
normalized_model = self._normalize_text(str(item.get("modelo") or ""))
if normalized_model and normalized_model in normalized_message:
matches.append(item)
if len(matches) == 1:
return matches[0]
return None
def _load_vehicle_by_id(self, vehicle_id: int) -> dict | None:
db = SessionMockLocal()
try:
vehicle = db.query(Vehicle).filter(Vehicle.id == vehicle_id).first()
if not vehicle:
return None
return {
"id": int(vehicle.id),
"modelo": str(vehicle.modelo),
"categoria": str(vehicle.categoria),
"preco": float(vehicle.preco),
}
finally:
db.close()
def _try_resolve_order_vehicle(self, message: str, user_id: int | None, payload: dict) -> dict | None:
vehicle_id = payload.get("vehicle_id")
if isinstance(vehicle_id, int) and vehicle_id > 0:
return self._load_vehicle_by_id(vehicle_id)
stock_results = self._get_last_stock_results(user_id=user_id)
selected_from_model = self._match_vehicle_from_message_model(message=message, stock_results=stock_results)
if selected_from_model:
return selected_from_model
selected_from_index = self._match_vehicle_from_message_index(message=message, stock_results=stock_results)
if selected_from_index:
return selected_from_index
normalized_model = self._normalize_text(str(payload.get("modelo_veiculo") or ""))
if normalized_model:
matches = [
item
for item in stock_results
if self._normalize_text(str(item.get("modelo") or "")) == normalized_model
]
if len(matches) == 1:
return matches[0]
return None
def _render_missing_order_fields_prompt(self, missing_fields: list[str]) -> str: def _render_missing_order_fields_prompt(self, missing_fields: list[str]) -> str:
labels = { labels = {
"cpf": "o CPF do cliente", "cpf": "o CPF do cliente",
"valor_veiculo": "o valor do veiculo (R$)", "vehicle_id": "qual veiculo do estoque voce quer comprar",
} }
itens = [f"- {labels[field]}" for field in missing_fields] itens = [f"- {labels[field]}" for field in missing_fields]
return "Para realizar o pedido, preciso dos dados abaixo:\n" + "\n".join(itens) return "Para realizar o pedido, preciso dos dados abaixo:\n" + "\n".join(itens)
def _render_vehicle_selection_from_stock_prompt(self, stock_results: list[dict]) -> str:
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"({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.")
return "\n".join(lines)
def _render_missing_cancel_order_fields_prompt(self, missing_fields: list[str]) -> str: def _render_missing_cancel_order_fields_prompt(self, missing_fields: list[str]) -> str:
labels = { labels = {
"numero_pedido": "o numero do pedido (ex.: PED-20260305123456-ABC123)", "numero_pedido": "o numero do pedido (ex.: PED-20260305123456-ABC123)",
@ -144,7 +239,17 @@ class OrderFlowMixin:
} }
draft["payload"].update(extracted) draft["payload"].update(extracted)
self._try_prefill_order_value_from_memory(user_id=user_id, payload=draft["payload"]) 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"])
resolved_vehicle = self._try_resolve_order_vehicle(
message=message,
user_id=user_id,
payload=draft["payload"],
)
if resolved_vehicle:
self._store_selected_vehicle(user_id=user_id, vehicle=resolved_vehicle)
draft["payload"].update(self._vehicle_to_payload(resolved_vehicle))
cpf_value = draft["payload"].get("cpf") cpf_value = draft["payload"].get("cpf")
if cpf_value and not self._is_valid_cpf(str(cpf_value)): if cpf_value and not self._is_valid_cpf(str(cpf_value)):
@ -162,28 +267,24 @@ class OrderFlowMixin:
self.state.set_entry("pending_order_drafts", user_id, draft) self.state.set_entry("pending_order_drafts", user_id, draft)
return "Para seguir com o pedido, preciso de um CPF valido. Pode me informar novamente?" return "Para seguir com o pedido, preciso de um CPF valido. Pode me informar novamente?"
valor = draft["payload"].get("valor_veiculo")
if valor is not None:
try:
parsed = float(valor)
if parsed <= 0:
draft["payload"].pop("valor_veiculo", None)
else:
draft["payload"]["valor_veiculo"] = round(parsed, 2)
except (TypeError, ValueError):
draft["payload"].pop("valor_veiculo", None)
draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES) draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_ORDER_DRAFT_TTL_MINUTES)
self.state.set_entry("pending_order_drafts", user_id, draft) self.state.set_entry("pending_order_drafts", user_id, draft)
missing = [field for field in ORDER_REQUIRED_FIELDS if field not in draft["payload"]] missing = [field for field in ORDER_REQUIRED_FIELDS if field not in draft["payload"]]
if missing: if missing:
if "vehicle_id" in missing:
stock_results = self._get_last_stock_results(user_id=user_id)
if stock_results:
return self._render_vehicle_selection_from_stock_prompt(stock_results)
return self._render_missing_order_fields_prompt(missing) return self._render_missing_order_fields_prompt(missing)
try: try:
tool_result = await self.registry.execute( tool_result = await self.registry.execute(
"realizar_pedido", "realizar_pedido",
draft["payload"], {
"cpf": draft["payload"]["cpf"],
"vehicle_id": draft["payload"]["vehicle_id"],
},
user_id=user_id, user_id=user_id,
) )
except HTTPException as exc: except HTTPException as exc:
@ -204,10 +305,21 @@ class OrderFlowMixin:
normalized_intents = self._normalize_intents(intents) normalized_intents = self._normalize_intents(intents)
draft = self.state.get_entry("pending_cancel_order_drafts", user_id, expire=True) draft = self.state.get_entry("pending_cancel_order_drafts", user_id, expire=True)
active_order_draft = self.state.get_entry("pending_order_drafts", user_id, expire=True)
extracted = self._normalize_cancel_order_fields(extracted_fields) extracted = self._normalize_cancel_order_fields(extracted_fields)
has_intent = normalized_intents.get("order_cancel", False) has_intent = normalized_intents.get("order_cancel", False)
if (
draft is None
and active_order_draft is not None
and (
not has_intent
or ("numero_pedido" not in extracted and "motivo" not in extracted)
)
):
return None
if ( if (
draft draft
and not has_intent and not has_intent

@ -82,8 +82,8 @@ class ConversationPolicy:
order_fields = self.service.normalizer.normalize_order_fields(entities.get("order_fields")) order_fields = self.service.normalizer.normalize_order_fields(entities.get("order_fields"))
if order_fields.get("cpf") and not seed.get("cpf"): if order_fields.get("cpf") and not seed.get("cpf"):
seed["cpf"] = order_fields["cpf"] seed["cpf"] = order_fields["cpf"]
if order_fields.get("valor_veiculo") and not seed.get("orcamento_max"): if order_fields.get("modelo_veiculo") and not seed.get("modelo_veiculo"):
seed["orcamento_max"] = int(round(order_fields["valor_veiculo"])) seed["modelo_veiculo"] = order_fields["modelo_veiculo"]
return seed return seed
@ -308,6 +308,57 @@ class ConversationPolicy:
return self.contains_any_term(normalized, operational_terms) return self.contains_any_term(normalized, operational_terms)
# Distingue um comando global explicito de cancelamento do fluxo atual de um texto livre
# que deve ser consumido como dado do rascunho aberto.
def is_explicit_flow_cancel_message(self, message: str) -> bool:
normalized = self.service.normalizer.normalize_text(message).strip()
explicit_terms = {
"cancelar fluxo",
"cancela o fluxo",
"cancelar esse fluxo",
"cancela esse fluxo",
"cancelar fluxo atual",
"cancela o fluxo atual",
"encerrar fluxo",
"encerrar esse fluxo",
"parar fluxo",
"parar esse fluxo",
"abandonar fluxo",
"abandonar esse fluxo",
"desistir desse fluxo",
"desistir deste fluxo",
"desistir dessa compra",
"desistir desta compra",
}
return normalized in explicit_terms
# Evita que frases como "desisti" sejam tratadas como comando global quando o sistema
# esta justamente esperando o motivo do cancelamento.
def should_defer_flow_cancellation_control(self, message: str, user_id: int | None) -> bool:
if user_id is None or self.is_explicit_flow_cancel_message(message):
return False
pending_cancel_order = self.service.state.get_entry("pending_cancel_order_drafts", user_id, expire=True)
if pending_cancel_order:
payload = pending_cancel_order.get("payload", {})
if payload.get("numero_pedido") and not payload.get("motivo"):
free_text = str(message or "").strip()
if len(free_text) >= 4:
return True
pending_review_management = self.service.state.get_entry("pending_review_management_drafts", user_id, expire=True)
if pending_review_management:
payload = pending_review_management.get("payload", {})
action = pending_review_management.get("action", "cancel")
if action == "cancel" and payload.get("protocolo") and not payload.get("motivo"):
free_text = str(message or "").strip()
if len(free_text) >= 4 and not self.service._is_affirmative_message(free_text):
return True
return False
# Interpreta a resposta do usuário na etapa de seleção. # Interpreta a resposta do usuário na etapa de seleção.
def detect_selected_order_index(self, message: str, orders: list[dict]) -> tuple[int | None, bool]: def detect_selected_order_index(self, message: str, orders: list[dict]) -> tuple[int | None, bool]:
normalized = self.strip_choice_message(self.service.normalizer.normalize_text(message)) normalized = self.strip_choice_message(self.service.normalizer.normalize_text(message))
@ -649,6 +700,12 @@ class ConversationPolicy:
memory = context.get("generic_memory", {}) memory = context.get("generic_memory", {})
if memory: if memory:
summary.append(f"Memoria generica temporaria: {memory}.") summary.append(f"Memoria generica temporaria: {memory}.")
selected_vehicle = context.get("selected_vehicle")
if isinstance(selected_vehicle, dict) and selected_vehicle.get("modelo"):
summary.append(f"Veiculo selecionado para compra: {selected_vehicle.get('modelo')}.")
stock_results = context.get("last_stock_results") or []
if isinstance(stock_results, list) and stock_results:
summary.append(f"Ultima consulta de estoque com {len(stock_results)} opcao(oes) disponivel(is).")
order_queue = context.get("order_queue", []) order_queue = context.get("order_queue", [])
if order_queue: if order_queue:
summary.append(f"Fila de pedidos pendentes: {len(order_queue)}.") summary.append(f"Fila de pedidos pendentes: {len(order_queue)}.")

@ -1,7 +1,9 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from app.services.orchestration.conversation_state_repository import ConversationStateRepository
class ConversationStateStore:
class ConversationStateStore(ConversationStateRepository):
def __init__(self) -> None: def __init__(self) -> None:
self.user_contexts: dict[int, dict] = {} self.user_contexts: dict[int, dict] = {}
self.pending_review_confirmations: dict[int, dict] = {} self.pending_review_confirmations: dict[int, dict] = {}
@ -27,6 +29,8 @@ class ConversationStateStore:
"order_queue": [], "order_queue": [],
"pending_order_selection": None, "pending_order_selection": None,
"pending_switch": None, "pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
"expires_at": now + timedelta(minutes=ttl_minutes), "expires_at": now + timedelta(minutes=ttl_minutes),
} }

@ -158,12 +158,7 @@ class EntityNormalizer:
def normalize_datetime_connector(self, text: str) -> str: def normalize_datetime_connector(self, text: str) -> str:
compact = " ".join(str(text or "").strip().split()) compact = " ".join(str(text or "").strip().split())
lowered = compact.lower() return re.sub(r"\s+[aàáâã]s\s+", " ", compact, flags=re.IGNORECASE).strip()
marker = " as "
if marker in lowered:
index = lowered.index(marker)
return f"{compact[:index]} {compact[index + len(marker):]}".strip()
return compact
def try_parse_iso_datetime(self, text: str) -> datetime | None: def try_parse_iso_datetime(self, text: str) -> datetime | None:
candidate = str(text or "").strip() candidate = str(text or "").strip()
@ -366,9 +361,12 @@ class EntityNormalizer:
cpf = self.normalize_cpf(data.get("cpf")) cpf = self.normalize_cpf(data.get("cpf"))
if cpf: if cpf:
extracted["cpf"] = cpf extracted["cpf"] = cpf
value = self.normalize_positive_number(data.get("valor_veiculo")) vehicle_id = self.normalize_positive_number(data.get("vehicle_id"))
if value: if vehicle_id:
extracted["valor_veiculo"] = round(value, 2) extracted["vehicle_id"] = int(round(vehicle_id))
model = str(data.get("modelo_veiculo") or data.get("modelo") or "").strip(" ,.;")
if model:
extracted["modelo_veiculo"] = model.title()
return extracted return extracted
def normalize_cancel_order_fields(self, data) -> dict: def normalize_cancel_order_fields(self, data) -> dict:

@ -26,7 +26,7 @@ class MessagePlanner:
' "generic_memory": {"placa": null, "cpf": null, "orcamento_max": null, "perfil_veiculo": []},\n' ' "generic_memory": {"placa": null, "cpf": null, "orcamento_max": null, "perfil_veiculo": []},\n'
' "review_fields": {"placa": null, "data_hora": null, "modelo": null, "ano": null, "km": null, "revisao_previa_concessionaria": null},\n' ' "review_fields": {"placa": null, "data_hora": null, "modelo": null, "ano": null, "km": null, "revisao_previa_concessionaria": null},\n'
' "review_management_fields": {"protocolo": null, "nova_data_hora": null, "motivo": null},\n' ' "review_management_fields": {"protocolo": null, "nova_data_hora": null, "motivo": null},\n'
' "order_fields": {"cpf": null, "valor_veiculo": null},\n' ' "order_fields": {"cpf": null, "vehicle_id": null, "modelo_veiculo": null},\n'
' "cancel_order_fields": {"numero_pedido": null, "motivo": null},\n' ' "cancel_order_fields": {"numero_pedido": null, "motivo": null},\n'
' "intents": {"review_schedule": false, "review_list": false, "review_cancel": false, "review_reschedule": false, "order_create": false, "order_cancel": false}\n' ' "intents": {"review_schedule": false, "review_list": false, "review_cancel": false, "review_reschedule": false, "order_create": false, "order_cancel": false}\n'
" }\n" " }\n"
@ -94,7 +94,8 @@ class MessagePlanner:
" },\n" " },\n"
' "order_fields": {\n' ' "order_fields": {\n'
' "cpf": null,\n' ' "cpf": null,\n'
' "valor_veiculo": null\n' ' "vehicle_id": null,\n'
' "modelo_veiculo": null\n'
" },\n" " },\n"
' "cancel_order_fields": {\n' ' "cancel_order_fields": {\n'
' "numero_pedido": null,\n' ' "numero_pedido": null,\n'

@ -18,7 +18,7 @@ REVIEW_REQUIRED_FIELDS = (
ORDER_REQUIRED_FIELDS = ( ORDER_REQUIRED_FIELDS = (
"cpf", "cpf",
"valor_veiculo", "vehicle_id",
) )
CANCEL_ORDER_REQUIRED_FIELDS = ( CANCEL_ORDER_REQUIRED_FIELDS = (

@ -12,9 +12,10 @@ from app.services.orchestration.orchestrator_config import (
) )
from app.services.ai.llm_service import LLMService from app.services.ai.llm_service import LLMService
from app.services.orchestration.conversation_policy import ConversationPolicy from app.services.orchestration.conversation_policy import ConversationPolicy
from app.services.orchestration.conversation_state_store import ConversationStateStore from app.services.orchestration.conversation_state_repository import ConversationStateRepository
from app.services.orchestration.entity_normalizer import EntityNormalizer from app.services.orchestration.entity_normalizer import EntityNormalizer
from app.services.orchestration.message_planner import MessagePlanner from app.services.orchestration.message_planner import MessagePlanner
from app.services.orchestration.state_repository_factory import get_conversation_state_repository
from app.services.flows.order_flow import OrderFlowMixin from app.services.flows.order_flow import OrderFlowMixin
from app.services.orchestration.prompt_builders import ( from app.services.orchestration.prompt_builders import (
build_force_tool_prompt, build_force_tool_prompt,
@ -29,11 +30,13 @@ logger = logging.getLogger(__name__)
class OrquestradorService(ReviewFlowMixin, OrderFlowMixin): class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
default_state_repository = ConversationStateStore() def __init__(
self,
def __init__(self, db: Session): db: Session,
state_repository: ConversationStateRepository | None = None,
):
"""Inicializa servicos de LLM e registro de tools para a sessao atual.""" """Inicializa servicos de LLM e registro de tools para a sessao atual."""
self.state = self.default_state_repository self.state = state_repository or get_conversation_state_repository()
self.llm = LLMService() self.llm = LLMService()
self.normalizer = EntityNormalizer() self.normalizer = EntityNormalizer()
self.planner = MessagePlanner(llm=self.llm, normalizer=self.normalizer) self.planner = MessagePlanner(llm=self.llm, normalizer=self.normalizer)
@ -218,6 +221,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
user_id=user_id, user_id=user_id,
) )
return await finish(self._http_exception_detail(exc), queue_notice=queue_notice) return await finish(self._http_exception_detail(exc), queue_notice=queue_notice)
self._capture_tool_result_context(
tool_name=tool_name,
tool_result=tool_result,
user_id=user_id,
)
if self._should_use_deterministic_response(tool_name): if self._should_use_deterministic_response(tool_name):
return await finish( return await finish(
@ -271,6 +279,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
tool_call = llm_result.get("tool_call") or {} tool_call = llm_result.get("tool_call") or {}
tool_name = tool_call.get("name") tool_name = tool_call.get("name")
if tool_name in ORCHESTRATION_CONTROL_TOOLS: if tool_name in ORCHESTRATION_CONTROL_TOOLS:
if (
tool_name == "cancelar_fluxo_atual"
and self.policy.should_defer_flow_cancellation_control(message=message, user_id=user_id)
):
return None
try: try:
tool_result = await self.tool_executor.execute( tool_result = await self.tool_executor.execute(
tool_name, tool_name,
@ -306,6 +319,11 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
forced_tool_name = forced_tool_call.get("name") forced_tool_name = forced_tool_call.get("name")
if forced_tool_name not in ORCHESTRATION_CONTROL_TOOLS: if forced_tool_name not in ORCHESTRATION_CONTROL_TOOLS:
return None return None
if (
forced_tool_name == "cancelar_fluxo_atual"
and self.policy.should_defer_flow_cancellation_control(message=message, user_id=user_id)
):
return None
try: try:
tool_result = await self.tool_executor.execute( tool_result = await self.tool_executor.execute(
@ -346,6 +364,8 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
context["order_queue"] = [] context["order_queue"] = []
context["pending_order_selection"] = None context["pending_order_selection"] = None
context["pending_switch"] = None context["pending_switch"] = None
context["last_stock_results"] = []
context["selected_vehicle"] = None
def _clear_pending_order_navigation(self, user_id: int | None) -> int: def _clear_pending_order_navigation(self, user_id: int | None) -> int:
context = self._get_user_context(user_id) context = self._get_user_context(user_id)
@ -499,6 +519,40 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
context["generic_memory"].update(fields) context["generic_memory"].update(fields)
context.setdefault("shared_memory", {}).update(fields) context.setdefault("shared_memory", {}).update(fields)
def _capture_tool_result_context(
self,
tool_name: str,
tool_result,
user_id: int | None,
) -> None:
context = self._get_user_context(user_id)
if not context:
return
if tool_name != "consultar_estoque" or not isinstance(tool_result, list):
return
sanitized: list[dict] = []
for item in tool_result[:20]:
if not isinstance(item, dict):
continue
try:
vehicle_id = int(item.get("id"))
preco = float(item.get("preco") or 0)
except (TypeError, ValueError):
continue
sanitized.append(
{
"id": vehicle_id,
"modelo": str(item.get("modelo") or "").strip(),
"categoria": str(item.get("categoria") or "").strip(),
"preco": preco,
}
)
context["last_stock_results"] = sanitized
if sanitized:
context["selected_vehicle"] = None
def _new_tab_memory(self, user_id: int | None) -> dict: def _new_tab_memory(self, user_id: int | None) -> dict:
context = self._get_user_context(user_id) context = self._get_user_context(user_id)
if not context: if not context:

@ -28,7 +28,8 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str:
modelo = item.get("modelo", "N/A") modelo = item.get("modelo", "N/A")
categoria = item.get("categoria", "N/A") categoria = item.get("categoria", "N/A")
preco = format_currency_br(item.get("preco")) preco = format_currency_br(item.get("preco"))
linhas.append(f"{idx}. {modelo} ({categoria}) - {preco}") codigo = item.get("id", "N/A")
linhas.append(f"{idx}. [{codigo}] {modelo} ({categoria}) - {preco}")
restantes = len(tool_result) - 10 restantes = len(tool_result) - 10
if restantes > 0: if restantes > 0:
linhas.append(f"... e mais {restantes} veiculo(s).") linhas.append(f"... e mais {restantes} veiculo(s).")
@ -46,7 +47,8 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str:
if tool_name == "realizar_pedido" and isinstance(tool_result, dict): if tool_name == "realizar_pedido" and isinstance(tool_result, dict):
numero = tool_result.get("numero_pedido", "N/A") numero = tool_result.get("numero_pedido", "N/A")
valor = format_currency_br(tool_result.get("valor_veiculo")) valor = format_currency_br(tool_result.get("valor_veiculo"))
return f"Pedido criado com sucesso.\nNumero: {numero}\nValor: {valor}" modelo = tool_result.get("modelo_veiculo", "N/A")
return f"Pedido criado com sucesso.\nNumero: {numero}\nVeiculo: {modelo}\nValor: {valor}"
if tool_name == "agendar_revisao" and isinstance(tool_result, dict): if tool_name == "agendar_revisao" and isinstance(tool_result, dict):
placa = tool_result.get("placa", "N/A") placa = tool_result.get("placa", "N/A")

@ -6,6 +6,8 @@ from typing import Any, Dict, List, Optional
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.exc import OperationalError, SQLAlchemyError
from sqlalchemy.sql import text
from app.db.mock_database import SessionMockLocal from app.db.mock_database import SessionMockLocal
from app.db.mock_models import Customer, Order, ReviewSchedule, User, Vehicle from app.db.mock_models import Customer, Order, ReviewSchedule, User, Vehicle
@ -200,7 +202,7 @@ def _parse_data_hora_revisao(value: str) -> datetime:
if not text: if not text:
raise ValueError("data_hora vazia") raise ValueError("data_hora vazia")
normalized = re.sub(r"\s+[aA]s\s+", " ", text) normalized = re.sub(r"\s+[aàáâã]s\s+", " ", text, flags=re.IGNORECASE)
iso_candidates = [text, normalized] iso_candidates = [text, normalized]
for candidate in iso_candidates: for candidate in iso_candidates:
try: try:
@ -627,23 +629,30 @@ async def cancelar_pedido(numero_pedido: str, motivo: str, user_id: Optional[int
db.close() db.close()
async def realizar_pedido(cpf: str, valor_veiculo: float, user_id: Optional[int] = None) -> Dict[str, Any]: async def realizar_pedido(cpf: str, vehicle_id: int, user_id: Optional[int] = None) -> Dict[str, Any]:
"""Cria um novo pedido de compra quando o cliente estiver aprovado para o valor informado.""" """Cria um novo pedido de compra quando o cliente estiver aprovado para o veiculo selecionado."""
cpf_norm = normalize_cpf(cpf) cpf_norm = normalize_cpf(cpf)
await hydrate_mock_customer_from_cpf(cpf=cpf_norm, user_id=user_id)
avaliacao = await validar_cliente_venda(cpf=cpf_norm, valor_veiculo=valor_veiculo)
if not avaliacao.get("aprovado"):
raise HTTPException(
status_code=400,
detail=(
"Cliente nao aprovado para este valor. "
f"Limite disponivel: R$ {avaliacao.get('limite_credito', 0):.2f}."
),
)
numero_pedido = f"PED-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:6].upper()}"
db = SessionMockLocal() db = SessionMockLocal()
try: try:
vehicle = db.query(Vehicle).filter(Vehicle.id == vehicle_id).first()
if not vehicle:
raise HTTPException(status_code=404, detail="Veiculo nao encontrado no estoque.")
valor_veiculo = float(vehicle.preco)
modelo_veiculo = str(vehicle.modelo)
await hydrate_mock_customer_from_cpf(cpf=cpf_norm, user_id=user_id)
avaliacao = await validar_cliente_venda(cpf=cpf_norm, valor_veiculo=valor_veiculo)
if not avaliacao.get("aprovado"):
raise HTTPException(
status_code=400,
detail=(
"Cliente nao aprovado para este valor. "
f"Limite disponivel: R$ {avaliacao.get('limite_credito', 0):.2f}."
),
)
numero_pedido = f"PED-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:6].upper()}"
if user_id is not None: if user_id is not None:
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
if user and user.cpf != cpf_norm: if user and user.cpf != cpf_norm:
@ -653,18 +662,59 @@ async def realizar_pedido(cpf: str, valor_veiculo: float, user_id: Optional[int]
numero_pedido=numero_pedido, numero_pedido=numero_pedido,
user_id=user_id, user_id=user_id,
cpf=cpf_norm, cpf=cpf_norm,
vehicle_id=vehicle.id,
modelo_veiculo=modelo_veiculo,
valor_veiculo=valor_veiculo,
status="Ativo", status="Ativo",
) )
db.add(pedido) db.add(pedido)
db.commit() try:
db.refresh(pedido) db.commit()
db.refresh(pedido)
except (OperationalError, SQLAlchemyError) as exc:
db.rollback()
lowered = str(exc).lower()
legacy_schema_issue = (
"unknown column" in lowered
or "invalid column" in lowered
or "has no column named" in lowered
or "column count doesn't match" in lowered
)
if not legacy_schema_issue:
raise
db.execute(
text(
"INSERT INTO orders (numero_pedido, user_id, cpf, status) "
"VALUES (:numero_pedido, :user_id, :cpf, :status)"
),
{
"numero_pedido": numero_pedido,
"user_id": user_id,
"cpf": cpf_norm,
"status": "Ativo",
},
)
db.commit()
return {
"numero_pedido": numero_pedido,
"user_id": user_id,
"cpf": cpf_norm,
"vehicle_id": vehicle.id,
"modelo_veiculo": modelo_veiculo,
"status": "Ativo",
"valor_veiculo": valor_veiculo,
"aprovado_credito": True,
}
return { return {
"numero_pedido": pedido.numero_pedido, "numero_pedido": pedido.numero_pedido,
"user_id": pedido.user_id, "user_id": pedido.user_id,
"cpf": pedido.cpf, "cpf": pedido.cpf,
"vehicle_id": pedido.vehicle_id,
"modelo_veiculo": pedido.modelo_veiculo,
"status": pedido.status, "status": pedido.status,
"valor_veiculo": valor_veiculo, "valor_veiculo": pedido.valor_veiculo,
"aprovado_credito": True, "aprovado_credito": True,
} }
finally: finally:

@ -0,0 +1,315 @@
import os
import unittest
from datetime import datetime, timedelta
from unittest.mock import patch
os.environ.setdefault("DEBUG", "false")
from app.services.flows.order_flow import OrderFlowMixin
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
class FakeState:
def __init__(self, entries=None, contexts=None):
self.entries = entries or {}
self.contexts = contexts or {}
def get_entry(self, bucket: str, user_id: int | None, *, expire: bool = False):
if user_id is None:
return None
return self.entries.get(bucket, {}).get(user_id)
def set_entry(self, bucket: str, user_id: int | None, value: dict):
if user_id is None:
return
self.entries.setdefault(bucket, {})[user_id] = value
def pop_entry(self, bucket: str, user_id: int | None):
if user_id is None:
return None
return self.entries.get(bucket, {}).pop(user_id, None)
def get_user_context(self, user_id: int | None):
if user_id is None:
return None
return self.contexts.get(user_id)
class FakeService:
def __init__(self, state):
self.state = state
self.normalizer = EntityNormalizer()
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 _get_user_context(self, user_id: int | None):
return self.state.get_user_context(user_id)
class FakeRegistry:
def __init__(self):
self.calls = []
async def execute(self, tool_name: str, arguments: dict, user_id: int | None = None):
self.calls.append((tool_name, arguments, user_id))
if tool_name == "realizar_pedido":
vehicle_map = {
1: ("Honda Civic 2021", 51524.0),
2: ("Toyota Corolla 2020", 58476.0),
}
modelo_veiculo, valor_veiculo = vehicle_map[arguments["vehicle_id"]]
return {
"numero_pedido": "PED-TESTE-123",
"status": "Ativo",
"modelo_veiculo": modelo_veiculo,
"valor_veiculo": valor_veiculo,
}
return {
"numero_pedido": arguments["numero_pedido"],
"status": "Cancelado",
"motivo": arguments["motivo"],
}
class OrderFlowHarness(OrderFlowMixin):
def __init__(self, state, registry):
self.state = state
self.registry = registry
self.normalizer = EntityNormalizer()
def _get_user_context(self, user_id: int | None):
return self.state.get_user_context(user_id)
def _normalize_intents(self, data) -> dict:
return self.normalizer.normalize_intents(data)
def _normalize_cancel_order_fields(self, data) -> dict:
return self.normalizer.normalize_cancel_order_fields(data)
def _normalize_order_fields(self, data) -> dict:
return self.normalizer.normalize_order_fields(data)
def _normalize_text(self, text: str) -> str:
return self.normalizer.normalize_text(text)
def _http_exception_detail(self, exc) -> str:
return str(exc)
def _fallback_format_tool_result(self, tool_name: str, tool_result) -> str:
if tool_name == "realizar_pedido":
return (
f"Pedido criado com sucesso.\n"
f"Numero: {tool_result['numero_pedido']}\n"
f"Veiculo: {tool_result['modelo_veiculo']}\n"
f"Valor: R$ {tool_result['valor_veiculo']:.2f}"
)
return (
f"Pedido {tool_result['numero_pedido']} atualizado.\n"
f"Status: {tool_result['status']}\n"
f"Motivo: {tool_result['motivo']}"
)
def _try_prefill_order_cpf_from_user_profile(self, user_id: int | None, payload: dict) -> None:
return None
def _load_vehicle_by_id(self, vehicle_id: int) -> dict | None:
for context in self.state.contexts.values():
for item in context.get("last_stock_results", []):
if int(item["id"]) == int(vehicle_id):
return dict(item)
return None
class ConversationAdjustmentsTests(unittest.TestCase):
def test_defer_flow_cancel_when_order_cancel_draft_waits_for_reason(self):
state = FakeState(
entries={
"pending_cancel_order_drafts": {
7: {
"payload": {"numero_pedido": "PED-20260305120000-ABC123"},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
}
)
policy = ConversationPolicy(service=FakeService(state))
self.assertTrue(policy.should_defer_flow_cancellation_control("desisti", user_id=7))
self.assertFalse(policy.should_defer_flow_cancellation_control("cancelar fluxo atual", user_id=7))
def test_normalize_datetime_connector_accepts_as_com_acento(self):
normalizer = EntityNormalizer()
self.assertEqual(
normalizer.normalize_datetime_connector("10/03/2026 às 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")
self.assertEqual(parsed, datetime(2026, 3, 10, 9, 0))
class CancelOrderFlowTests(unittest.IsolatedAsyncioTestCase):
async def test_cancel_order_flow_consumes_free_text_reason(self):
state = FakeState(
entries={
"pending_cancel_order_drafts": {
42: {
"payload": {"numero_pedido": "PED-20260305120000-ABC123"},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
}
)
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_cancel_order(
message="desisti",
user_id=42,
extracted_fields={},
intents={},
)
self.assertEqual(len(registry.calls), 1)
tool_name, arguments, tool_user_id = registry.calls[0]
self.assertEqual(tool_name, "cancelar_pedido")
self.assertEqual(tool_user_id, 42)
self.assertEqual(arguments["numero_pedido"], "PED-20260305120000-ABC123")
self.assertEqual(arguments["motivo"], "desisti")
self.assertIn("Status: Cancelado", response)
self.assertIsNone(state.get_entry("pending_cancel_order_drafts", 42))
async def test_cancel_order_flow_still_requests_reason_when_message_is_too_short(self):
state = FakeState(
entries={
"pending_cancel_order_drafts": {
42: {
"payload": {"numero_pedido": "PED-20260305120000-ABC123"},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
}
)
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_cancel_order(
message="ok",
user_id=42,
extracted_fields={},
intents={},
)
self.assertEqual(registry.calls, [])
self.assertIn("o motivo do cancelamento", response)
self.assertIsNotNone(state.get_entry("pending_cancel_order_drafts", 42))
async def test_cancel_order_flow_does_not_override_active_order_creation_draft(self):
state = FakeState(
entries={
"pending_order_drafts": {
42: {
"payload": {},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
}
)
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
response = await flow._try_collect_and_cancel_order(
message="2",
user_id=42,
extracted_fields={},
intents={"order_cancel": True},
)
self.assertIsNone(response)
self.assertEqual(registry.calls, [])
class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
async def test_order_flow_requests_vehicle_selection_from_last_stock_results(self):
state = FakeState(
contexts={
10: {
"generic_memory": {},
"last_stock_results": [
{"id": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 51524.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="Quero fazer um pedido",
user_id=10,
extracted_fields={},
intents={"order_create": True},
)
self.assertIn("escolha primeiro qual veiculo", response.lower())
self.assertIn("Honda Civic 2021", response)
self.assertEqual(registry.calls, [])
async def test_order_flow_creates_order_with_selected_vehicle_from_list_index(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": 1, "modelo": "Honda Civic 2021", "categoria": "sedan", "preco": 51524.0},
{"id": 2, "modelo": "Toyota Corolla 2020", "categoria": "hatch", "preco": 58476.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="2",
user_id=10,
extracted_fields={},
intents={},
)
self.assertEqual(len(registry.calls), 1)
tool_name, arguments, tool_user_id = registry.calls[0]
self.assertEqual(tool_name, "realizar_pedido")
self.assertEqual(tool_user_id, 10)
self.assertEqual(arguments["vehicle_id"], 2)
self.assertEqual(arguments["cpf"], "12345678909")
self.assertIn("Veiculo: Toyota Corolla 2020", response)
if __name__ == "__main__":
unittest.main()
Loading…
Cancel
Save