🛡️ fix(orchestration): endurecer retomada de fluxos e stress local

- prioriza selecoes e refinamentos corretos em mensagens multiassunto

- adiciona retries defensivos no envio do Telegram sem derrubar o worker

- reidrata o ultimo contrato de locacao apos restart e responde consultas informativas

- restringe a heuristica de informacoes de aluguel para nao sequestrar compra e troca

- preserva expires_at no backend memory e ajusta o stress smoke para usar CPF mock valido

- adiciona regressões automatizadas e valida com 230 testes mais stress smoke pesado
main
parent 765108c904
commit d0b11e368a

@ -32,6 +32,8 @@ TELEGRAM_IDEMPOTENCY_CACHE_LIMIT = 100
TELEGRAM_RUNTIME_BUCKET = "telegram_runtime_state" TELEGRAM_RUNTIME_BUCKET = "telegram_runtime_state"
TELEGRAM_RUNTIME_OWNER_ID = 0 TELEGRAM_RUNTIME_OWNER_ID = 0
TELEGRAM_RUNTIME_CURSOR_TTL_DAYS = 30 TELEGRAM_RUNTIME_CURSOR_TTL_DAYS = 30
TELEGRAM_SEND_MESSAGE_MAX_ATTEMPTS = 3
TELEGRAM_SEND_MESSAGE_RETRY_BASE_SECONDS = 1.0
def _split_telegram_text(text: str, limit: int = TELEGRAM_MESSAGE_SAFE_LIMIT) -> List[str]: def _split_telegram_text(text: str, limit: int = TELEGRAM_MESSAGE_SAFE_LIMIT) -> List[str]:
@ -438,7 +440,7 @@ class TelegramSatelliteService:
chat_id, chat_id,
self._build_update_idempotency_key(update), self._build_update_idempotency_key(update),
) )
await self._send_message(session=session, chat_id=chat_id, text=cached_answer) await self._deliver_message(session=session, chat_id=chat_id, text=cached_answer)
return return
image_attachments = await self._extract_image_attachments(session=session, message=message) image_attachments = await self._extract_image_attachments(session=session, message=message)
@ -464,7 +466,20 @@ class TelegramSatelliteService:
update_id = update.get("update_id") update_id = update.get("update_id")
if isinstance(update_id, int): if isinstance(update_id, int):
self._persist_last_processed_update_id(update_id) self._persist_last_processed_update_id(update_id)
await self._send_message(session=session, chat_id=chat_id, text=answer) await self._deliver_message(session=session, chat_id=chat_id, text=answer)
async def _deliver_message(
self,
*,
session: aiohttp.ClientSession,
chat_id: int,
text: str,
) -> None:
"""Entrega a resposta ao Telegram sem deixar falhas de transporte derrubarem o worker."""
try:
await self._send_message(session=session, chat_id=chat_id, text=text)
except Exception:
logger.exception("Falha inesperada ao entregar mensagem ao Telegram. chat_id=%s", chat_id)
async def _send_message( async def _send_message(
self, self,
@ -473,15 +488,39 @@ class TelegramSatelliteService:
text: str, text: str,
) -> None: ) -> None:
"""Envia mensagem de texto para o chat informado no Telegram.""" """Envia mensagem de texto para o chat informado no Telegram."""
for chunk in _split_telegram_text(text): for chunk_index, chunk in enumerate(_split_telegram_text(text), start=1):
payload = { payload = {
"chat_id": chat_id, "chat_id": chat_id,
"text": chunk, "text": chunk,
} }
async with session.post(f"{self.base_url}/sendMessage", json=payload) as response: for attempt in range(1, TELEGRAM_SEND_MESSAGE_MAX_ATTEMPTS + 1):
data = await response.json() try:
if not data.get("ok"): async with session.post(f"{self.base_url}/sendMessage", json=payload) as response:
logger.warning("Falha em sendMessage: %s", data) data = await response.json()
if not data.get("ok"):
logger.warning("Falha em sendMessage: %s", data)
break
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as exc:
if attempt >= TELEGRAM_SEND_MESSAGE_MAX_ATTEMPTS:
logger.warning(
"Falha de transporte ao enviar mensagem ao Telegram apos %s tentativa(s). chat_id=%s chunk=%s erro=%s",
TELEGRAM_SEND_MESSAGE_MAX_ATTEMPTS,
chat_id,
chunk_index,
exc,
)
break
delay_seconds = TELEGRAM_SEND_MESSAGE_RETRY_BASE_SECONDS * attempt
logger.warning(
"Falha temporaria ao enviar mensagem ao Telegram. chat_id=%s chunk=%s tentativa=%s/%s retry_em=%.1fs erro=%s",
chat_id,
chunk_index,
attempt,
TELEGRAM_SEND_MESSAGE_MAX_ATTEMPTS,
delay_seconds,
exc,
)
await asyncio.sleep(delay_seconds)
# Processa uma mensagem do Telegram e injeta o texto extraido de imagens quando houver. # Processa uma mensagem do Telegram e injeta o texto extraido de imagens quando houver.
async def _process_message( async def _process_message(

@ -2,7 +2,11 @@ from __future__ import annotations
from datetime import timedelta from datetime import timedelta
from sqlalchemy import or_
from app.core.time_utils import utc_now from app.core.time_utils import utc_now
from app.db.mock_database import SessionMockLocal
from app.db.mock_models import RentalContract, RentalFine, RentalPayment
from app.services.flows.flow_state_support import FlowStateSupport from app.services.flows.flow_state_support import FlowStateSupport
from app.services.orchestration import technical_normalizer from app.services.orchestration import technical_normalizer
from app.services.orchestration.orchestrator_config import PENDING_RENTAL_SELECTION_TTL_MINUTES from app.services.orchestration.orchestrator_config import PENDING_RENTAL_SELECTION_TTL_MINUTES
@ -11,6 +15,94 @@ from app.services.orchestration.orchestrator_config import PENDING_RENTAL_SELECT
class RentalFlowStateSupport(FlowStateSupport): class RentalFlowStateSupport(FlowStateSupport):
"""Concentra estado e contexto incremental do fluxo de locacao.""" """Concentra estado e contexto incremental do fluxo de locacao."""
def _load_last_rental_contract_snapshot(self, user_id: int | None) -> dict | None:
if user_id is None:
return None
db = None
try:
db = SessionMockLocal()
base_query = db.query(RentalContract).filter(RentalContract.user_id == user_id)
contract = (
base_query.filter(RentalContract.status == "ativa")
.order_by(RentalContract.created_at.desc())
.first()
)
if contract is None:
contract = base_query.order_by(RentalContract.created_at.desc()).first()
if contract is None:
return None
payload = {
"contrato_numero": contract.contrato_numero,
"placa": contract.placa,
"modelo_veiculo": contract.modelo_veiculo,
"categoria": contract.categoria,
"data_inicio": contract.data_inicio.isoformat() if contract.data_inicio else None,
"data_fim_prevista": contract.data_fim_prevista.isoformat() if contract.data_fim_prevista else None,
"data_devolucao": contract.data_devolucao.isoformat() if contract.data_devolucao else None,
"valor_diaria": contract.valor_diaria,
"valor_previsto": contract.valor_previsto,
"valor_final": contract.valor_final,
"status": contract.status,
}
latest_payment = (
db.query(RentalPayment)
.filter(
or_(
RentalPayment.rental_contract_id == contract.id,
RentalPayment.contrato_numero == contract.contrato_numero,
)
)
.order_by(RentalPayment.created_at.desc())
.first()
)
if latest_payment is not None:
payload.update(
{
"valor": latest_payment.valor,
"data_pagamento": latest_payment.data_pagamento.isoformat()
if latest_payment.data_pagamento
else None,
"favorecido": latest_payment.favorecido,
"status": "registrado",
}
)
latest_fine = (
db.query(RentalFine)
.filter(
or_(
RentalFine.rental_contract_id == contract.id,
RentalFine.contrato_numero == contract.contrato_numero,
)
)
.order_by(RentalFine.created_at.desc())
.first()
)
if latest_fine is not None:
payload.update(
{
"auto_infracao": latest_fine.auto_infracao,
"data_infracao": latest_fine.data_infracao.isoformat()
if latest_fine.data_infracao
else None,
"vencimento": latest_fine.vencimento.isoformat() if latest_fine.vencimento else None,
}
)
if latest_fine.valor is not None:
payload["valor_multa"] = float(latest_fine.valor)
return self.sanitize_rental_contract_snapshot(payload)
except Exception:
return None
finally:
if db is not None:
try:
db.close()
except Exception:
pass
def sanitize_rental_results(self, rental_results: list[dict] | None) -> list[dict]: def sanitize_rental_results(self, rental_results: list[dict] | None) -> list[dict]:
sanitized: list[dict] = [] sanitized: list[dict] = []
for item in rental_results or []: for item in rental_results or []:
@ -104,7 +196,6 @@ class RentalFlowStateSupport(FlowStateSupport):
for field_name in ( for field_name in (
"modelo_veiculo", "modelo_veiculo",
"categoria", "categoria",
"status",
"status_veiculo", "status_veiculo",
"data_inicio", "data_inicio",
"data_fim_prevista", "data_fim_prevista",
@ -114,19 +205,57 @@ class RentalFlowStateSupport(FlowStateSupport):
if value: if value:
snapshot[field_name] = value snapshot[field_name] = value
status_value = str(payload.get("status") or "").strip()
if status_value:
if payload.get("data_pagamento"):
snapshot["status_pagamento"] = status_value
else:
snapshot["status"] = status_value
for field_name in ("valor_diaria", "valor_previsto", "valor_final"): for field_name in ("valor_diaria", "valor_previsto", "valor_final"):
number = technical_normalizer.normalize_positive_number(payload.get(field_name)) number = technical_normalizer.normalize_positive_number(payload.get(field_name))
if number is not None: if number is not None:
snapshot[field_name] = float(number) snapshot[field_name] = float(number)
payment_date = str(payload.get("data_pagamento") or "").strip()
if payment_date:
snapshot["data_pagamento"] = payment_date
payment_value = technical_normalizer.normalize_positive_number(payload.get("valor"))
if payment_value is not None:
snapshot["valor_pagamento"] = float(payment_value)
favorecido = str(payload.get("favorecido") or "").strip()
if favorecido:
snapshot["favorecido"] = favorecido
snapshot.setdefault("status_pagamento", "registrado")
violation_date = str(payload.get("data_infracao") or "").strip()
if violation_date:
snapshot["data_infracao"] = violation_date
due_date = str(payload.get("vencimento") or "").strip()
if due_date:
snapshot["vencimento"] = due_date
infraction_notice = str(payload.get("auto_infracao") or "").strip()
if infraction_notice:
snapshot["auto_infracao"] = infraction_notice
if violation_date or infraction_notice:
fine_value = technical_normalizer.normalize_positive_number(payload.get("valor"))
if fine_value is not None:
snapshot["valor_multa"] = float(fine_value)
return snapshot return snapshot
def get_last_rental_contract(self, user_id: int | None) -> dict | None: def get_last_rental_contract(self, user_id: int | None) -> dict | None:
context = self.service._get_user_context(user_id) context = self.service._get_user_context(user_id)
if not isinstance(context, dict): if isinstance(context, dict):
return None contract = context.get("last_rental_contract")
contract = context.get("last_rental_contract") if isinstance(contract, dict):
return dict(contract) if isinstance(contract, dict) else None return dict(contract)
snapshot = self._load_last_rental_contract_snapshot(user_id=user_id)
if snapshot and isinstance(context, dict):
context["last_rental_contract"] = dict(snapshot)
self.service._save_user_context(user_id=user_id, context=context)
return dict(snapshot) if isinstance(snapshot, dict) else None
def store_last_rental_contract(self, user_id: int | None, payload) -> None: def store_last_rental_contract(self, user_id: int | None, payload) -> None:
if user_id is None: if user_id is None:
@ -138,7 +267,14 @@ class RentalFlowStateSupport(FlowStateSupport):
if sanitized is None: if sanitized is None:
context.pop("last_rental_contract", None) context.pop("last_rental_contract", None)
else: else:
context["last_rental_contract"] = sanitized existing = context.get("last_rental_contract")
merged = dict(existing) if isinstance(existing, dict) else {}
merged.update(sanitized)
if merged.get("data_pagamento") and not merged.get("status_pagamento"):
merged["status_pagamento"] = "registrado"
elif merged.get("contrato_numero") and not merged.get("data_devolucao") and not merged.get("status_pagamento"):
merged["status_pagamento"] = "em aberto"
context["last_rental_contract"] = merged
self.service._save_user_context(user_id=user_id, context=context) self.service._save_user_context(user_id=user_id, context=context)
def remember_rental_results(self, user_id: int | None, rental_results: list[dict] | None) -> None: def remember_rental_results(self, user_id: int | None, rental_results: list[dict] | None) -> None:

@ -80,6 +80,8 @@ ACTIVE_TASK_LABELS = {
"rental_create": "abertura de locacao", "rental_create": "abertura de locacao",
} }
ACTIONABLE_ORDER_DOMAINS = {"review", "sales", "rental"}
# essa classe é responsável por controlar qual o assunto está ativo na conversa, se existe fluxo aberto, se o usuário mandou dois pedidos ao mesmo tempo... # essa classe é responsável por controlar qual o assunto está ativo na conversa, se existe fluxo aberto, se o usuário mandou dois pedidos ao mesmo tempo...
class ConversationPolicy: class ConversationPolicy:
def __init__(self, service: "OrquestradorService"): def __init__(self, service: "OrquestradorService"):
@ -132,20 +134,21 @@ class ConversationPolicy:
domain: str, domain: str,
order_message: str, order_message: str,
memory_seed: dict | None = None, memory_seed: dict | None = None,
) -> None: ) -> bool:
context = self.service._get_user_context(user_id) context = self.service._get_user_context(user_id)
if not context or domain == "general": if not context or domain == "general":
return return False
queue = context.setdefault("order_queue", []) queue = context.setdefault("order_queue", [])
queue.append( queue.append(
{ {
"domain": domain, "domain": domain,
"message": (order_message or "").strip(), "message": self.build_order_execution_message(domain, order_message),
"memory_seed": dict(memory_seed or self.service._new_tab_memory(user_id=user_id)), "memory_seed": dict(memory_seed or self.service._new_tab_memory(user_id=user_id)),
"created_at": utc_now().isoformat(), "created_at": utc_now().isoformat(),
} }
) )
self._save_context(user_id=user_id, context=context) self._save_context(user_id=user_id, context=context)
return True
# Transforma as entidades extraídas de um pedido em uma memória temporária pronta para usar quando esse pedido for processado. # Transforma as entidades extraídas de um pedido em uma memória temporária pronta para usar quando esse pedido for processado.
@ -210,7 +213,7 @@ class ConversationPolicy:
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
domain = str(item.get("domain") or "general").strip().lower() domain = str(item.get("domain") or "general").strip().lower()
if domain not in {"review", "sales", "general"}: if domain not in ACTIONABLE_ORDER_DOMAINS | {"general"}:
domain = "general" domain = "general"
segment = str(item.get("message") or "").strip() segment = str(item.get("message") or "").strip()
if segment: if segment:
@ -223,14 +226,19 @@ class ConversationPolicy:
) )
if not extracted_orders: if not extracted_orders:
extracted_orders = [{"domain": "general", "message": (message or "").strip()}] extracted_orders = [{"domain": "general", "message": (message or "").strip()}]
extracted_orders = self.augment_actionable_orders_from_message(
message=message,
extracted_orders=extracted_orders,
)
actionable_orders = [order for order in extracted_orders if order["domain"] in ACTIONABLE_ORDER_DOMAINS]
if ( if (
len(extracted_orders) == 2 len(actionable_orders) >= 2
and all(order["domain"] != "general" for order in extracted_orders)
and not self.has_open_flow(user_id=user_id, domain=active_domain) and not self.has_open_flow(user_id=user_id, domain=active_domain)
): ):
self.store_pending_order_selection(user_id=user_id, orders=extracted_orders) self.store_pending_order_selection(user_id=user_id, orders=actionable_orders)
return message, None, self.render_order_selection_prompt(extracted_orders) return message, None, self.render_order_selection_prompt(actionable_orders)
if len(extracted_orders) <= 1: if len(extracted_orders) <= 1:
inferred = extracted_orders[0]["domain"] inferred = extracted_orders[0]["domain"]
@ -247,29 +255,33 @@ class ConversationPolicy:
if self.has_open_flow(user_id=user_id, domain=active_domain): if self.has_open_flow(user_id=user_id, domain=active_domain):
queued_count = 0 queued_count = 0
for queued in extracted_orders: for queued in actionable_orders:
if queued["domain"] != active_domain: if queued["domain"] != active_domain:
self.queue_order_with_memory_seed( queued_count += int(
user_id=user_id, self.queue_order_with_memory_seed(
domain=queued["domain"], user_id=user_id,
order_message=queued["message"], domain=queued["domain"],
memory_seed=self.build_order_memory_seed(user_id=user_id, order=queued), order_message=queued["message"],
memory_seed=self.build_order_memory_seed(user_id=user_id, order=queued),
)
) )
queued_count += 1
queue_hint = self.render_queue_notice(queued_count) queue_hint = self.render_queue_notice(queued_count)
prompt = self.render_open_flow_prompt(user_id=user_id, domain=active_domain) prompt = self.render_open_flow_prompt(user_id=user_id, domain=active_domain)
return message, None, f"{prompt}\n{queue_hint}" if queue_hint else prompt return message, None, f"{prompt}\n{queue_hint}" if queue_hint else prompt
first = extracted_orders[0] first = actionable_orders[0] if actionable_orders else extracted_orders[0]
queued_count = 0 queued_count = 0
for queued in extracted_orders[1:]: for queued in actionable_orders:
self.queue_order_with_memory_seed( if queued is first:
user_id=user_id, continue
domain=queued["domain"], queued_count += int(
order_message=queued["message"], self.queue_order_with_memory_seed(
memory_seed=self.build_order_memory_seed(user_id=user_id, order=queued), user_id=user_id,
domain=queued["domain"],
order_message=queued["message"],
memory_seed=self.build_order_memory_seed(user_id=user_id, order=queued),
)
) )
queued_count += 1
context["active_domain"] = first["domain"] context["active_domain"] = first["domain"]
context["generic_memory"] = self.build_order_memory_seed(user_id=user_id, order=first) context["generic_memory"] = self.build_order_memory_seed(user_id=user_id, order=first)
self._save_context(user_id=user_id, context=context) self._save_context(user_id=user_id, context=context)
@ -297,9 +309,10 @@ class ConversationPolicy:
{ {
"domain": order["domain"], "domain": order["domain"],
"message": order["message"], "message": order["message"],
"seed_message": self.build_order_execution_message(order["domain"], order["message"]),
"memory_seed": self.build_order_memory_seed(user_id=user_id, order=order), "memory_seed": self.build_order_memory_seed(user_id=user_id, order=order),
} }
for order in orders[:2] for order in orders
], ],
"expires_at": utc_now() + timedelta(minutes=PENDING_ORDER_SELECTION_TTL_MINUTES), "expires_at": utc_now() + timedelta(minutes=PENDING_ORDER_SELECTION_TTL_MINUTES),
} }
@ -310,15 +323,68 @@ class ConversationPolicy:
def render_order_selection_prompt(self, orders: list[dict]) -> str: def render_order_selection_prompt(self, orders: list[dict]) -> str:
if len(orders) < 2: if len(orders) < 2:
return "Qual das acoes voce quer iniciar primeiro?" return "Qual das acoes voce quer iniciar primeiro?"
first_label = self.describe_order_selection_option(orders[0]) enumerated_orders = "\n".join(
second_label = self.describe_order_selection_option(orders[1]) f"{index}. {self.describe_order_selection_option(order)}"
for index, order in enumerate(orders, start=1)
)
return ( return (
"Identifiquei duas acoes na sua mensagem:\n" f"Identifiquei {len(orders)} acoes na sua mensagem:\n"
f"1. {first_label}\n" f"{enumerated_orders}\n"
f"2. {second_label}\n"
"Qual delas voce quer iniciar primeiro? Se for indiferente, eu escolho." "Qual delas voce quer iniciar primeiro? Se for indiferente, eu escolho."
) )
def build_order_execution_message(self, domain: str, order_message: str | None) -> str:
raw_message = str(order_message or "").strip()
normalized = self.service.normalizer.normalize_text(raw_message).strip()
if domain == "sales" and normalized in {"compra", "comprar", "venda", "pedido"}:
return "quero comprar um veiculo"
if domain == "review" and normalized in {"revisao", "agendamento", "agendar", "marcar revisao"}:
return "quero agendar revisao"
if domain == "rental" and normalized in {"aluguel", "alugar", "locacao", "locar"}:
return "quero alugar um carro"
return raw_message
def augment_actionable_orders_from_message(self, message: str, extracted_orders: list[dict]) -> list[dict]:
normalized = self.service.normalizer.normalize_text(message).strip()
if not normalized:
return extracted_orders
existing_domains = {
str(order.get("domain") or "general")
for order in extracted_orders
if isinstance(order, dict)
}
domain_hints = (
("sales", {"compra", "comprar", "venda", "pedido"}, "compra"),
("review", {"revisao", "agendamento", "agendar", "remarcar"}, "revisao"),
("rental", {"aluguel", "alugar", "locacao", "locar"}, "aluguel"),
)
augmented = list(extracted_orders)
for domain, terms, label in domain_hints:
if domain in existing_domains:
continue
if any(term in normalized for term in terms):
augmented.append(
{
"domain": domain,
"message": label,
"entities": self.service.normalizer.empty_extraction_payload(),
}
)
return augmented
def render_multi_order_clarification_prompt(self, orders: list[dict]) -> str:
if not orders:
return "Identifiquei mais de um assunto. Me diga qual voce quer iniciar primeiro."
options = "\n".join(
f"- {self.describe_order_selection_option(order)}"
for order in orders[:3]
)
return (
"Identifiquei mais de um assunto na sua mensagem:\n"
f"{options}\n"
"Para eu nao misturar os fluxos, me diga qual deles voce quer comecar primeiro."
)
# Formata o rótulo do pedido para exibição. # Formata o rótulo do pedido para exibição.
def describe_order_selection_option(self, order: dict) -> str: def describe_order_selection_option(self, order: dict) -> str:
@ -429,6 +495,139 @@ class ConversationPolicy:
} }
return self.contains_any_term(normalized, operational_terms) return self.contains_any_term(normalized, operational_terms)
def is_explicit_pending_order_selection_message(
self,
message: str,
turn_decision: dict | None = None,
) -> bool:
if self._decision_selection_index(turn_decision) is not None:
return True
normalized = self.strip_choice_message(self.service.normalizer.normalize_text(message))
if not normalized:
return False
indifferent_tokens = {
"tanto faz",
"indiferente",
"qualquer um",
"qualquer uma",
"voce escolhe",
"pode escolher",
"fica a seu criterio",
}
if normalized in indifferent_tokens:
return True
if re.fullmatch(r"(?:opcao|acao|pedido)?\s*(\d+)", normalized):
return True
explicit_selection_messages = {
"compra",
"comprar",
"quero comprar",
"quero comprar um veiculo",
"venda",
"pedido",
"revisao",
"agendamento",
"agendar",
"agendar revisao",
"quero agendar revisao",
"aluguel",
"alugar",
"quero alugar",
"quero alugar um carro",
"locacao",
"locar",
}
return normalized in explicit_selection_messages
def derive_operational_task_key(
self,
*,
message: str,
turn_decision: dict | None = None,
fallback_domain: str | None = None,
) -> str | None:
normalized = self.service.normalizer.normalize_text(message).strip()
domain = self._decision_domain(turn_decision) or str(fallback_domain or "").strip().lower()
intent = self._decision_intent(turn_decision)
tool_name = str((turn_decision or {}).get("tool_name") or "").strip().lower()
if domain == "sales":
if intent == "order_list" or tool_name == "listar_pedidos" or "quais pedidos" in normalized:
return "sales:list"
if intent == "order_cancel" or tool_name == "cancelar_pedido" or ("cancel" in normalized and "pedido" in normalized):
return "sales:cancel"
if tool_name == "avaliar_veiculo_troca" or ("avali" in normalized and "troca" in normalized):
return "sales:trade_in"
if (
intent in {"order_create", "inventory_search"}
or tool_name in {"consultar_estoque", "realizar_pedido"}
or self.contains_any_term(normalized, {"compra", "comprar", "venda", "carro", "veiculo"})
):
return "sales:create"
if domain == "review":
if intent == "review_list" or tool_name == "listar_agendamentos_revisao" or "agendamentos" in normalized:
return "review:list"
if intent == "review_cancel" or ("cancel" in normalized and "revis" in normalized):
return "review:cancel"
if intent == "review_reschedule" or "remarc" in normalized:
return "review:reschedule"
if (
intent == "review_schedule"
or tool_name == "agendar_revisao"
or self.contains_any_term(normalized, {"revisao", "agendar", "agendamento"})
):
return "review:schedule"
if domain == "rental":
if intent == "rental_list" or tool_name == "consultar_frota_aluguel" or "frota" in normalized:
return "rental:list"
if tool_name == "registrar_devolucao_aluguel" or "devol" in normalized:
return "rental:return"
if tool_name == "registrar_pagamento_aluguel" or "comprovante" in normalized or "pagamento" in normalized:
return "rental:payment"
if tool_name == "registrar_multa_aluguel" or "multa" in normalized:
return "rental:fine"
if (
intent == "rental_create"
or self.contains_any_term(normalized, {"aluguel", "alugar", "locacao", "locar"})
):
return "rental:create"
return None
def derive_pending_order_task_key(self, order: dict) -> str | None:
return self.derive_operational_task_key(
message=str(order.get("seed_message") or order.get("message") or ""),
fallback_domain=str(order.get("domain") or "general"),
)
def queue_pending_orders_for_later(
self,
*,
user_id: int | None,
orders: list[dict],
skip_task_key: str | None = None,
) -> int:
queued_count = 0
skipped_matching_task = False
for order in orders:
if skip_task_key and not skipped_matching_task and self.derive_pending_order_task_key(order) == skip_task_key:
skipped_matching_task = True
continue
queued_count += int(
self.queue_order_with_memory_seed(
user_id=user_id,
domain=order["domain"],
order_message=order["message"],
memory_seed=order.get("memory_seed"),
)
)
return queued_count
# Distingue um comando global explicito de cancelamento do fluxo atual de um texto livre # Distingue um comando global explicito de cancelamento do fluxo atual de um texto livre
# que deve ser consumido como dado do rascunho aberto. # que deve ser consumido como dado do rascunho aberto.
@ -518,26 +717,36 @@ class ConversationPolicy:
} }
if normalized in indifferent_tokens: if normalized in indifferent_tokens:
return 0, True return 0, True
numeric_match = re.fullmatch(r"(?:opcao|acao|pedido)?\s*(\d+)", normalized)
if numeric_match:
candidate = int(numeric_match.group(1)) - 1
if 0 <= candidate < len(orders):
return candidate, False
if normalized in {"1", "primeiro", "primeira", "opcao 1", "acao 1", "pedido 1"}: if normalized in {"1", "primeiro", "primeira", "opcao 1", "acao 1", "pedido 1"}:
return 0, False return 0, False
if normalized in {"2", "segundo", "segunda", "opcao 2", "acao 2", "pedido 2"}: if normalized in {"2", "segundo", "segunda", "opcao 2", "acao 2", "pedido 2"}:
return 1, False return 1, False
if normalized in {"3", "terceiro", "terceira", "opcao 3", "acao 3", "pedido 3"}:
return (2, False) if len(orders) >= 3 else (None, False)
decision_domain = self._decision_domain(turn_decision) decision_domain = self._decision_domain(turn_decision)
if len(orders) >= 2 and decision_domain in {"review", "sales"}: if len(orders) >= 2 and decision_domain in ACTIONABLE_ORDER_DOMAINS:
matches = [index for index, order in enumerate(orders) if order.get("domain") == decision_domain] matches = [index for index, order in enumerate(orders) if order.get("domain") == decision_domain]
if len(matches) == 1: if len(matches) == 1:
return matches[0], False return matches[0], False
review_matches = [index for index, order in enumerate(orders) if order.get("domain") == "review"] review_matches = [index for index, order in enumerate(orders) if order.get("domain") == "review"]
sales_matches = [index for index, order in enumerate(orders) if order.get("domain") == "sales"] sales_matches = [index for index, order in enumerate(orders) if order.get("domain") == "sales"]
rental_matches = [index for index, order in enumerate(orders) if order.get("domain") == "rental"]
has_review_signal = self.contains_any_term(normalized, {"revisao", "agendamento", "agendar", "remarcar", "pos venda"}) has_review_signal = self.contains_any_term(normalized, {"revisao", "agendamento", "agendar", "remarcar", "pos venda"})
has_sales_signal = self.contains_any_term(normalized, {"venda", "compra", "comprar", "pedido", "cancelamento", "cancelar", "carro", "veiculo"}) has_sales_signal = self.contains_any_term(normalized, {"venda", "compra", "comprar", "pedido", "cancelamento", "cancelar", "carro", "veiculo"})
has_rental_signal = self.contains_any_term(normalized, {"aluguel", "locacao", "alugar", "locar", "devolucao", "frota"})
if len(review_matches) == 1 and has_review_signal and not has_sales_signal: if len(review_matches) == 1 and has_review_signal and not has_sales_signal:
return review_matches[0], False return review_matches[0], False
if len(sales_matches) == 1 and has_sales_signal and not has_review_signal: if len(sales_matches) == 1 and has_sales_signal and not has_review_signal:
return sales_matches[0], False return sales_matches[0], False
if len(rental_matches) == 1 and has_rental_signal and not has_review_signal and not has_sales_signal:
return rental_matches[0], False
return None, False return None, False
@ -572,26 +781,59 @@ class ConversationPolicy:
return "Tudo bem. Limpei o contexto atual. Pode me dizer o que voce quer fazer agora?" return "Tudo bem. Limpei o contexto atual. Pode me dizer o que voce quer fazer agora?"
return await self.service.handle_message(cleaned_message, user_id=user_id) return await self.service.handle_message(cleaned_message, user_id=user_id)
if (
self.looks_like_fresh_operational_request(message, turn_decision=turn_decision)
and not self.is_explicit_pending_order_selection_message(message, turn_decision=turn_decision)
):
current_task_key = self.derive_operational_task_key(
message=message,
turn_decision=turn_decision,
)
matching_indexes = [
index
for index, order in enumerate(orders)
if current_task_key and self.derive_pending_order_task_key(order) == current_task_key
]
if len(matching_indexes) == 1:
selected_index = matching_indexes[0]
selected_order = orders[selected_index]
context["pending_order_selection"] = None
self.queue_pending_orders_for_later(
user_id=user_id,
orders=[order for index, order in enumerate(orders) if index != selected_index],
)
intro = f"Perfeito. Vou comecar por: {self.describe_order_selection_option(selected_order)}"
selected_memory = dict(selected_order.get("memory_seed") or {})
context["active_domain"] = selected_order.get("domain") or context.get("active_domain", "general")
if selected_memory:
context["generic_memory"] = selected_memory
self._save_context(user_id=user_id, context=context)
next_response = await self.service.handle_message(message, user_id=user_id)
return f"{intro}\n{next_response}"
context["pending_order_selection"] = None
self._save_context(user_id=user_id, context=context)
self.queue_pending_orders_for_later(
user_id=user_id,
orders=orders,
skip_task_key=current_task_key,
)
return None
selected_index, auto_selected = self.detect_selected_order_index( selected_index, auto_selected = self.detect_selected_order_index(
message=message, message=message,
orders=orders, orders=orders,
turn_decision=turn_decision, turn_decision=turn_decision,
) )
if selected_index is None: if selected_index is None:
if self.looks_like_fresh_operational_request(message, turn_decision=turn_decision):
context["pending_order_selection"] = None
self._save_context(user_id=user_id, context=context)
return None
return self.render_order_selection_prompt(orders) return self.render_order_selection_prompt(orders)
selected_order = orders[selected_index] selected_order = orders[selected_index]
remaining_order = orders[1 - selected_index]
context["pending_order_selection"] = None context["pending_order_selection"] = None
self.queue_order_with_memory_seed( self.queue_pending_orders_for_later(
user_id=user_id, user_id=user_id,
domain=remaining_order["domain"], orders=[order for index, order in enumerate(orders) if index != selected_index],
order_message=remaining_order["message"],
memory_seed=remaining_order.get("memory_seed"),
) )
intro = ( intro = (
@ -600,10 +842,12 @@ class ConversationPolicy:
else f"Perfeito. Vou comecar por: {self.describe_order_selection_option(selected_order)}" else f"Perfeito. Vou comecar por: {self.describe_order_selection_option(selected_order)}"
) )
selected_memory = dict(selected_order.get("memory_seed") or {}) selected_memory = dict(selected_order.get("memory_seed") or {})
context["active_domain"] = selected_order.get("domain") or context.get("active_domain", "general")
if selected_memory: if selected_memory:
context["generic_memory"] = selected_memory context["generic_memory"] = selected_memory
self._save_context(user_id=user_id, context=context) self._save_context(user_id=user_id, context=context)
next_response = await self.service.handle_message(str(selected_order.get("message") or ""), user_id=user_id) selected_message = str(selected_order.get("seed_message") or selected_order.get("message") or "")
next_response = await self.service.handle_message(selected_message, user_id=user_id)
return f"{intro}\n{next_response}" return f"{intro}\n{next_response}"

@ -67,7 +67,14 @@ class ConversationStateStore(ConversationStateRepository):
if user_id is None or not isinstance(context, dict): if user_id is None or not isinstance(context, dict):
return return
with self._lock: with self._lock:
self.user_contexts[user_id] = context stored_context = dict(context)
if "expires_at" not in stored_context:
existing = self.user_contexts.get(user_id)
if isinstance(existing, dict) and existing.get("expires_at") is not None:
stored_context["expires_at"] = existing["expires_at"]
else:
stored_context["expires_at"] = utc_now() + timedelta(minutes=60)
self.user_contexts[user_id] = stored_context
def get_entry(self, bucket: str, user_id: int | None, *, expire: bool = False) -> dict | None: def get_entry(self, bucket: str, user_id: int | None, *, expire: bool = False) -> dict | None:
if user_id is None: if user_id is None:

@ -35,6 +35,7 @@ from app.services.orchestration.prompt_builders import (
from app.services.flows.review_flow import ReviewFlowMixin from app.services.flows.review_flow import ReviewFlowMixin
from app.services.orchestration.tool_executor import ToolExecutor from app.services.orchestration.tool_executor import ToolExecutor
from app.services.tools.tool_registry import ToolRegistry from app.services.tools.tool_registry import ToolRegistry
from app.services.orchestration.response_formatter import format_currency_br, format_datetime_for_chat
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -86,12 +87,18 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
turn_started_at = utc_now() turn_started_at = utc_now()
turn_started_perf = perf_counter() turn_started_perf = perf_counter()
turn_history_persisted = False turn_history_persisted = False
self._turn_trace = { turn_trace = {
"request_id": str(uuid4()), "request_id": str(uuid4()),
"conversation_id": f"user:{user_id}" if user_id is not None else "anonymous", "conversation_id": f"user:{user_id}" if user_id is not None else "anonymous",
"user_id": user_id, "user_id": user_id,
"started_at": turn_started_at, "started_at": turn_started_at,
} }
turn_trace_stack = getattr(self, "_turn_trace_stack", None)
if not isinstance(turn_trace_stack, list):
turn_trace_stack = []
self._turn_trace_stack = turn_trace_stack
turn_trace_stack.append(turn_trace)
self._turn_trace = turn_trace
self._log_turn_event("turn_received", message=message) self._log_turn_event("turn_received", message=message)
async def finish(response: str, queue_notice: str | None = None) -> str: async def finish(response: str, queue_notice: str | None = None) -> str:
@ -105,7 +112,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
base_response=composed, base_response=composed,
user_id=user_id, user_id=user_id,
) )
self._turn_trace["elapsed_ms"] = round((perf_counter() - turn_started_perf) * 1000, 2) turn_trace["elapsed_ms"] = round((perf_counter() - turn_started_perf) * 1000, 2)
self._log_turn_event("turn_completed", response=final_response) self._log_turn_event("turn_completed", response=final_response)
if not turn_history_persisted: if not turn_history_persisted:
self._finalize_turn_history( self._finalize_turn_history(
@ -171,6 +178,13 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
) )
if active_review_follow_up: if active_review_follow_up:
return active_review_follow_up return active_review_follow_up
current_rental_info = await self._try_handle_current_rental_info_request(
message=message,
user_id=user_id,
finish=finish,
)
if current_rental_info:
return current_rental_info
# Faz uma leitura inicial do turno para ajudar a policy # Faz uma leitura inicial do turno para ajudar a policy
# com fila, troca de contexto e comandos globais. # com fila, troca de contexto e comandos globais.
early_turn_decision = await self._extract_turn_decision_with_llm( early_turn_decision = await self._extract_turn_decision_with_llm(
@ -537,7 +551,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
return await finish(text, queue_notice=queue_notice) return await finish(text, queue_notice=queue_notice)
except Exception as exc: except Exception as exc:
if not turn_history_persisted: if not turn_history_persisted:
self._turn_trace["elapsed_ms"] = round((perf_counter() - turn_started_perf) * 1000, 2) turn_trace["elapsed_ms"] = round((perf_counter() - turn_started_perf) * 1000, 2)
self._finalize_turn_history( self._finalize_turn_history(
user_message=message, user_message=message,
assistant_response=None, assistant_response=None,
@ -546,6 +560,17 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
) )
turn_history_persisted = True turn_history_persisted = True
raise raise
finally:
current_stack = getattr(self, "_turn_trace_stack", None)
if isinstance(current_stack, list):
if current_stack and current_stack[-1] is turn_trace:
current_stack.pop()
else:
try:
current_stack.remove(turn_trace)
except ValueError:
pass
self._turn_trace = current_stack[-1] if current_stack else None
async def _try_execute_orchestration_control_tool( async def _try_execute_orchestration_control_tool(
self, self,
@ -885,6 +910,213 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
arguments["placa"] = str(last_contract["placa"]) arguments["placa"] = str(last_contract["placa"])
return arguments return arguments
def _has_current_rental_info_request(self, message: str, user_id: int | None = None) -> bool:
if user_id is None:
return False
last_contract = self._get_last_rental_contract(user_id)
if not isinstance(last_contract, dict):
return False
has_policy = hasattr(self, "policy") and getattr(self, "policy") is not None
if has_policy and (self._has_open_flow(user_id, "sales") or self._has_open_flow(user_id, "review")):
return False
normalized_message = self._normalize_text(message).strip()
if not normalized_message:
return False
if (
self._has_rental_return_management_request(message, user_id=user_id)
or self._has_rental_payment_request(message, user_id=user_id)
or self._has_rental_fine_request(message, user_id=user_id)
):
return False
if (
self._has_explicit_order_request(message)
or self._has_stock_listing_request(message)
or self._has_order_listing_request(message)
or self._has_trade_in_evaluation_request(message)
or self._has_rental_listing_request(message)
or self._has_explicit_rental_request(message)
):
return False
question_terms = (
"qual",
"quais",
"quando",
"quanto",
"status",
"dados",
"informacoes",
"me diga",
"me informa",
"me lembra",
)
has_question_shape = normalized_message.endswith("?") or any(
term in normalized_message for term in question_terms
)
if not has_question_shape:
return False
rental_anchor_terms = (
"aluguel",
"locacao",
"contrato",
"devolucao",
"diaria",
"pagamento",
"comprovante",
)
if any(term in normalized_message for term in rental_anchor_terms):
return True
active_domain = str(((self._get_user_context(user_id) or {}).get("active_domain") or "")).strip().lower()
if active_domain != "rental":
return False
contextual_detail_terms = (
"placa",
"veiculo",
"carro",
"modelo",
"inicio",
"valor",
)
return any(term in normalized_message for term in contextual_detail_terms)
def _build_current_rental_info_response(self, message: str, user_id: int | None) -> str | None:
snapshot = self._get_last_rental_contract(user_id)
if not isinstance(snapshot, dict):
return None
normalized_message = self._normalize_text(message).strip()
contract_number = str(snapshot.get("contrato_numero") or "").strip()
plate = str(snapshot.get("placa") or "").strip()
vehicle_model = str(snapshot.get("modelo_veiculo") or "").strip()
start_at = format_datetime_for_chat(snapshot.get("data_inicio")) if snapshot.get("data_inicio") else ""
due_at = format_datetime_for_chat(snapshot.get("data_fim_prevista")) if snapshot.get("data_fim_prevista") else ""
returned_at = format_datetime_for_chat(snapshot.get("data_devolucao")) if snapshot.get("data_devolucao") else ""
daily_rate = format_currency_br(snapshot.get("valor_diaria")) if snapshot.get("valor_diaria") is not None else ""
expected_total = format_currency_br(snapshot.get("valor_previsto")) if snapshot.get("valor_previsto") is not None else ""
final_total = format_currency_br(snapshot.get("valor_final")) if snapshot.get("valor_final") is not None else ""
payment_status = str(snapshot.get("status_pagamento") or "").strip()
payment_at = format_datetime_for_chat(snapshot.get("data_pagamento")) if snapshot.get("data_pagamento") else ""
payment_amount = format_currency_br(snapshot.get("valor_pagamento")) if snapshot.get("valor_pagamento") is not None else ""
if (
"devolucao" in normalized_message
or "devolver" in normalized_message
or "data de devolucao" in normalized_message
or "data da devolucao" in normalized_message
):
if returned_at:
lines = [f"A devolucao do seu aluguel foi registrada em {returned_at}."]
elif due_at:
lines = [f"A devolucao prevista do seu aluguel e {due_at}."]
else:
lines = ["Nao encontrei a data de devolucao prevista do seu aluguel atual."]
if contract_number:
lines.append(f"Contrato: {contract_number}")
if vehicle_model:
lines.append(f"Veiculo: {vehicle_model}")
if plate:
lines.append(f"Placa: {plate}")
return "\n".join(lines)
if "pagamento" in normalized_message or "comprovante" in normalized_message or "paguei" in normalized_message or "quitado" in normalized_message or "pago" in normalized_message:
if payment_status == "registrado" and payment_at and payment_amount:
lines = [f"O pagamento mais recente do seu aluguel ja foi registrado em {payment_at}, no valor de {payment_amount}."]
elif payment_status == "registrado" and payment_at:
lines = [f"O pagamento mais recente do seu aluguel ja foi registrado em {payment_at}."]
elif payment_status:
lines = [f"O status do pagamento do seu aluguel atual e: {payment_status}."]
else:
lines = ["O pagamento do seu aluguel atual esta em aberto."]
if contract_number:
lines.append(f"Contrato: {contract_number}")
if plate:
lines.append(f"Placa: {plate}")
return "\n".join(lines)
if "diaria" in normalized_message:
if not daily_rate:
return "Nao encontrei a diaria do seu aluguel atual no contexto recente."
lines = [f"A diaria atual do seu aluguel e {daily_rate}."]
if contract_number:
lines.append(f"Contrato: {contract_number}")
if vehicle_model:
lines.append(f"Veiculo: {vehicle_model}")
return "\n".join(lines)
if "valor" in normalized_message or "quanto" in normalized_message:
if final_total:
lines = [f"O valor final do seu aluguel esta em {final_total}."]
elif expected_total:
lines = [f"O valor previsto do seu aluguel esta em {expected_total}."]
else:
lines = ["Nao encontrei o valor do seu aluguel atual no contexto recente."]
if contract_number:
lines.append(f"Contrato: {contract_number}")
return "\n".join(lines)
if "placa" in normalized_message:
if not plate:
return "Nao encontrei a placa do seu aluguel atual no contexto recente."
return f"A placa do seu aluguel atual e {plate}."
if "contrato" in normalized_message:
if not contract_number:
return "Nao encontrei o numero do contrato do seu aluguel atual no contexto recente."
return f"O numero do contrato do seu aluguel atual e {contract_number}."
if "inicio" in normalized_message or "retirada" in normalized_message:
if not start_at:
return "Nao encontrei a data de inicio do seu aluguel atual no contexto recente."
lines = [f"O inicio do seu aluguel foi em {start_at}."]
if contract_number:
lines.append(f"Contrato: {contract_number}")
return "\n".join(lines)
lines = ["Resumo do seu aluguel atual:"]
if contract_number:
lines.append(f"Contrato: {contract_number}")
if vehicle_model:
lines.append(f"Veiculo: {vehicle_model}")
if plate:
lines.append(f"Placa: {plate}")
if start_at:
lines.append(f"Inicio: {start_at}")
if returned_at:
lines.append(f"Devolucao registrada: {returned_at}")
elif due_at:
lines.append(f"Devolucao prevista: {due_at}")
if daily_rate:
lines.append(f"Diaria: {daily_rate}")
if final_total:
lines.append(f"Valor final: {final_total}")
elif expected_total:
lines.append(f"Valor previsto: {expected_total}")
if payment_status == "registrado" and payment_at:
payment_line = f"Pagamento: registrado em {payment_at}"
if payment_amount:
payment_line += f" ({payment_amount})"
lines.append(payment_line)
elif payment_status:
lines.append(f"Pagamento: {payment_status}")
return "\n".join(lines)
async def _try_handle_current_rental_info_request(
self,
message: str,
user_id: int | None,
finish,
) -> str | None:
if user_id is None or not self._has_current_rental_info_request(message, user_id=user_id):
return None
response = self._build_current_rental_info_response(message=message, user_id=user_id)
if not response:
return None
return await finish(response)
# Evita tratar perguntas sobre devolucao como se fossem um encerramento real. # Evita tratar perguntas sobre devolucao como se fossem um encerramento real.
def _looks_like_rental_return_question(self, message: str) -> bool: def _looks_like_rental_return_question(self, message: str) -> bool:
normalized_message = self._normalize_text(message).strip() normalized_message = self._normalize_text(message).strip()

@ -162,5 +162,5 @@ if __name__ == "__main__":
parser.add_argument("--order-cycles", type=int, default=10) parser.add_argument("--order-cycles", type=int, default=10)
parser.add_argument("--race-attempts", type=int, default=5) parser.add_argument("--race-attempts", type=int, default=5)
parser.add_argument("--user-base", type=int, default=990000) parser.add_argument("--user-base", type=int, default=990000)
parser.add_argument("--cpf", default="10000000001") parser.add_argument("--cpf", default="11144477735")
asyncio.run(main(parser.parse_args())) asyncio.run(main(parser.parse_args()))

@ -0,0 +1,39 @@
import unittest
from app.services.orchestration.conversation_state_store import ConversationStateStore
class ConversationStateStoreTests(unittest.TestCase):
def test_save_user_context_preserves_existing_expiration_when_missing(self):
store = ConversationStateStore()
store.upsert_user_context(1, ttl_minutes=30)
original_expires_at = store.get_user_context(1)["expires_at"]
store.save_user_context(
1,
{
"active_domain": "sales",
"active_task": "order_create",
"generic_memory": {},
"shared_memory": {},
"collected_slots": {},
"flow_snapshots": {},
"last_tool_result": None,
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
"last_rental_results": [],
"selected_rental_vehicle": None,
},
)
stored_context = store.get_user_context(1)
self.assertEqual(stored_context["active_domain"], "sales")
self.assertEqual(stored_context["active_task"], "order_create")
self.assertEqual(stored_context["expires_at"], original_expires_at)
if __name__ == "__main__":
unittest.main()

@ -3,6 +3,7 @@ import asyncio
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import aiohttp
from fastapi import HTTPException from fastapi import HTTPException
from app.integrations.telegram_satellite_service import ( from app.integrations.telegram_satellite_service import (
@ -42,6 +43,36 @@ class _FakeTelegramSession:
return _FakeTelegramResponse(self.payload) return _FakeTelegramResponse(self.payload)
class _FlakyTelegramResponse:
def __init__(self, outcome):
self.outcome = outcome
async def __aenter__(self):
if isinstance(self.outcome, BaseException):
raise self.outcome
return self
async def __aexit__(self, exc_type, exc, tb):
return None
async def json(self):
return self.outcome
class _FlakyTelegramSession:
def __init__(self, outcomes):
self.outcomes = list(outcomes)
self.calls = []
def post(self, url, json):
self.calls.append((url, json))
if self.outcomes:
outcome = self.outcomes.pop(0)
else:
outcome = {"ok": True}
return _FlakyTelegramResponse(outcome)
class TelegramMultimodalTests(unittest.IsolatedAsyncioTestCase): class TelegramMultimodalTests(unittest.IsolatedAsyncioTestCase):
def _build_service(self) -> TelegramSatelliteService: def _build_service(self) -> TelegramSatelliteService:
service = TelegramSatelliteService( service = TelegramSatelliteService(
@ -320,6 +351,47 @@ class TelegramMultimodalTests(unittest.IsolatedAsyncioTestCase):
entry = service.state.get_entry(TELEGRAM_RUNTIME_BUCKET, TELEGRAM_RUNTIME_OWNER_ID) entry = service.state.get_entry(TELEGRAM_RUNTIME_BUCKET, TELEGRAM_RUNTIME_OWNER_ID)
self.assertEqual(entry["last_update_id"], 14) self.assertEqual(entry["last_update_id"], 14)
async def test_send_message_retries_transient_transport_failure(self):
service = self._build_service()
session = _FlakyTelegramSession(
[
asyncio.TimeoutError(),
aiohttp.ClientConnectionError("falha temporaria"),
{"ok": True},
]
)
with patch("app.integrations.telegram_satellite_service.asyncio.sleep", AsyncMock()) as sleep_mock:
await service._send_message(session=session, chat_id=99, text="resposta teste")
self.assertEqual(len(session.calls), 3)
self.assertEqual(sleep_mock.await_count, 2)
async def test_handle_update_swallows_unexpected_delivery_failure(self):
service = self._build_service()
update = {
"update_id": 15,
"message": {
"message_id": 89,
"chat": {"id": 99},
"from": {"id": 99},
"text": "status do pedido",
},
}
with patch.object(service, "_extract_image_attachments", AsyncMock(return_value=[])), patch.object(
service,
"_process_message",
AsyncMock(return_value="Pedido encontrado."),
), patch.object(
service,
"_send_message",
AsyncMock(side_effect=RuntimeError("falha inesperada de entrega")),
), patch("app.integrations.telegram_satellite_service.logger.exception") as logger_exception:
await service._handle_update(session=SimpleNamespace(), update=update)
self.assertTrue(logger_exception.called)
async def test_persist_last_processed_update_id_keeps_highest_seen_value(self): async def test_persist_last_processed_update_id_keeps_highest_seen_value(self):
service = self._build_service() service = self._build_service()

@ -3,10 +3,16 @@ import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
os.environ.setdefault("DEBUG", "false") os.environ.setdefault("DEBUG", "false")
from datetime import datetime, timedelta from datetime import datetime, timedelta
from app.core.time_utils import utc_now from app.core.time_utils import utc_now
from app.db.mock_database import MockBase
from app.db.mock_models import RentalContract, RentalPayment, RentalVehicle
from app.services.orchestration.conversation_policy import ConversationPolicy from app.services.orchestration.conversation_policy import ConversationPolicy
from app.services.orchestration.entity_normalizer import EntityNormalizer from app.services.orchestration.entity_normalizer import EntityNormalizer
@ -1306,6 +1312,109 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(history_calls[0]["turn_status"], "completed") self.assertEqual(history_calls[0]["turn_status"], "completed")
self.assertEqual(history_calls[0]["intent"], "general") self.assertEqual(history_calls[0]["intent"], "general")
async def test_handle_message_restores_outer_turn_trace_after_nested_call(self):
state = FakeState(
contexts={
1: {
"active_domain": "general",
"generic_memory": {},
"shared_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
}
}
)
history_calls = []
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service.policy = ConversationPolicy(service=service)
service.history_service = SimpleNamespace(record_turn=lambda **kwargs: history_calls.append(kwargs))
service._empty_extraction_payload = service.normalizer.empty_extraction_payload
service._log_turn_event = lambda *args, **kwargs: None
service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response
service._upsert_user_context = lambda user_id: None
service._get_user_context = lambda user_id: state.get_user_context(user_id)
service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context)
async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None):
if base_response == "resposta externa":
nested_response = await service.handle_message("mensagem interna", user_id=user_id)
return f"{base_response}\n{nested_response}"
return base_response
async def fake_extract_turn_decision(message: str, user_id: int | None):
return {
"intent": "general",
"domain": "general",
"action": "answer_user",
"entities": service.normalizer.empty_extraction_payload(),
"missing_fields": [],
"selection_index": None,
"tool_name": None,
"tool_arguments": {},
"response_to_user": "resposta interna" if message == "mensagem interna" else "resposta externa",
}
async def fake_extract_message_plan(message: str, user_id: int | None):
return {"orders": [{"domain": "general", "message": message}]}
service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order
service._extract_turn_decision_with_llm = fake_extract_turn_decision
service._extract_message_plan_with_llm = fake_extract_message_plan
service._prepare_message_for_single_order = lambda message, user_id, routing_plan=None: (message, None, None)
service._resolve_entities_for_message_plan = lambda message_plan, routed_message: service.normalizer.empty_extraction_payload()
async def fake_try_handle_immediate_context_reset(**kwargs):
return None
async def fake_try_resolve_pending_order_selection(**kwargs):
return None
async def fake_try_continue_queued_order(**kwargs):
return None
async def fake_try_execute_orchestration_control_tool(**kwargs):
return None
async def fake_try_execute_business_tool_from_turn_decision(**kwargs):
return None
service._try_handle_immediate_context_reset = fake_try_handle_immediate_context_reset
service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection
service._try_continue_queued_order = fake_try_continue_queued_order
service._try_execute_orchestration_control_tool = fake_try_execute_orchestration_control_tool
service._try_execute_business_tool_from_turn_decision = fake_try_execute_business_tool_from_turn_decision
service._handle_context_switch = lambda **kwargs: None
service._update_active_domain = lambda **kwargs: None
async def fake_extract_entities_with_llm(message: str, user_id: int | None):
return service.normalizer.empty_extraction_payload()
async def fake_extract_missing_sales_search_context_with_llm(**kwargs):
return {}
service._extract_entities_with_llm = fake_extract_entities_with_llm
service._extract_missing_sales_search_context_with_llm = fake_extract_missing_sales_search_context_with_llm
service._domain_from_intents = lambda intents: "general"
response = await service.handle_message("mensagem externa", user_id=1)
self.assertEqual(response, "resposta externa\nresposta interna")
self.assertEqual(len(history_calls), 2)
self.assertEqual(
{call["user_message"] for call in history_calls},
{"mensagem externa", "mensagem interna"},
)
self.assertEqual(
{call["assistant_response"] for call in history_calls},
{"resposta externa\nresposta interna", "resposta interna"},
)
self.assertEqual(len({call["request_id"] for call in history_calls}), 2)
async def test_handle_message_persists_failed_turn_history(self): async def test_handle_message_persists_failed_turn_history(self):
state = FakeState( state = FakeState(
contexts={ contexts={
@ -2721,6 +2830,275 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
) )
async def test_handle_message_short_circuits_for_current_rental_info_question(self):
state = FakeState(
contexts={
1: {
"active_domain": "general",
"generic_memory": {},
"shared_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
"last_rental_results": [],
"selected_rental_vehicle": None,
"last_rental_contract": {
"contrato_numero": "LOC-20260323-CAEECA1C",
"placa": "RAA1A02",
"modelo_veiculo": "Fiat Pulse",
"data_inicio": "2026-03-19T10:00:00",
"data_fim_prevista": "2026-03-21T10:00:00",
"valor_diaria": 189.9,
"valor_previsto": 379.8,
"status": "ativa",
"status_pagamento": "registrado",
"data_pagamento": "2026-03-23T15:47:00",
"valor_pagamento": 379.8,
},
}
}
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service.policy = ConversationPolicy(service=service)
service._empty_extraction_payload = service.normalizer.empty_extraction_payload
service._log_turn_event = lambda *args, **kwargs: None
service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response
service._get_user_context = lambda user_id: state.get_user_context(user_id)
service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context)
async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None):
return base_response
service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order
service._upsert_user_context = lambda user_id: None
async def fake_try_handle_pending_stock_selection_follow_up(**kwargs):
return None
async def fake_try_handle_active_sales_follow_up(**kwargs):
return None
async def fake_try_handle_pending_rental_selection_follow_up(**kwargs):
return None
async def fake_try_handle_active_rental_follow_up(**kwargs):
return None
async def fake_try_handle_active_review_follow_up(**kwargs):
return None
service._try_handle_pending_stock_selection_follow_up = fake_try_handle_pending_stock_selection_follow_up
service._try_handle_active_sales_follow_up = fake_try_handle_active_sales_follow_up
service._try_handle_pending_rental_selection_follow_up = fake_try_handle_pending_rental_selection_follow_up
service._try_handle_active_rental_follow_up = fake_try_handle_active_rental_follow_up
service._try_handle_active_review_follow_up = fake_try_handle_active_review_follow_up
async def fake_extract_turn_decision(message: str, user_id: int | None):
raise AssertionError("nao deveria consultar o LLM para consulta informativa do aluguel atual")
service._extract_turn_decision_with_llm = fake_extract_turn_decision
response = await service.handle_message(
"qual a data de devolucao do meu aluguel?",
user_id=1,
)
self.assertIn("A devolucao prevista do seu aluguel e 21/03/2026 10:00.", response)
self.assertIn("Contrato: LOC-20260323-CAEECA1C", response)
self.assertIn("Veiculo: Fiat Pulse", response)
async def test_handle_message_rehydrates_current_rental_info_from_db_after_restart(self):
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
MockBase.metadata.create_all(bind=engine)
self.addCleanup(engine.dispose)
db = SessionLocal()
try:
vehicle = RentalVehicle(
placa="RAA1A02",
modelo="Fiat Pulse",
categoria="suv",
ano=2024,
valor_diaria=189.9,
status="disponivel",
)
db.add(vehicle)
db.commit()
db.refresh(vehicle)
contract = RentalContract(
contrato_numero="LOC-20260323-CAEECA1C",
user_id=1,
rental_vehicle_id=vehicle.id,
placa=vehicle.placa,
modelo_veiculo=vehicle.modelo,
categoria=vehicle.categoria,
data_inicio=datetime(2026, 3, 19, 10, 0),
data_fim_prevista=datetime(2026, 3, 21, 10, 0),
data_devolucao=None,
valor_diaria=189.9,
valor_previsto=379.8,
valor_final=None,
status="ativa",
)
db.add(contract)
db.commit()
db.refresh(contract)
payment = RentalPayment(
protocolo="ALP-20260323-0B41DD0D",
user_id=1,
rental_contract_id=contract.id,
contrato_numero=contract.contrato_numero,
placa=contract.placa,
valor=379.8,
data_pagamento=datetime(2026, 3, 23, 15, 47),
favorecido="Locadora XPTO",
identificador_comprovante="NSU123456",
observacoes="pagamento da locacao",
)
db.add(payment)
db.commit()
finally:
db.close()
state = FakeState(
contexts={
1: {
"active_domain": "general",
"generic_memory": {},
"shared_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
"last_rental_results": [],
"selected_rental_vehicle": None,
}
}
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service.policy = ConversationPolicy(service=service)
service._empty_extraction_payload = service.normalizer.empty_extraction_payload
service._log_turn_event = lambda *args, **kwargs: None
service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response
service._get_user_context = lambda user_id: state.get_user_context(user_id)
service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context)
async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None):
return base_response
service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order
service._upsert_user_context = lambda user_id: None
async def fake_try_handle_pending_stock_selection_follow_up(**kwargs):
return None
async def fake_try_handle_active_sales_follow_up(**kwargs):
return None
async def fake_try_handle_pending_rental_selection_follow_up(**kwargs):
return None
async def fake_try_handle_active_rental_follow_up(**kwargs):
return None
async def fake_try_handle_active_review_follow_up(**kwargs):
return None
service._try_handle_pending_stock_selection_follow_up = fake_try_handle_pending_stock_selection_follow_up
service._try_handle_active_sales_follow_up = fake_try_handle_active_sales_follow_up
service._try_handle_pending_rental_selection_follow_up = fake_try_handle_pending_rental_selection_follow_up
service._try_handle_active_rental_follow_up = fake_try_handle_active_rental_follow_up
service._try_handle_active_review_follow_up = fake_try_handle_active_review_follow_up
async def fake_extract_turn_decision(message: str, user_id: int | None):
raise AssertionError("nao deveria consultar o LLM para consulta informativa do aluguel apos restart")
service._extract_turn_decision_with_llm = fake_extract_turn_decision
with patch("app.services.flows.rental_flow_support.SessionMockLocal", SessionLocal):
response = await service.handle_message(
"qual a data de devolucao do meu aluguel?",
user_id=1,
)
self.assertIn("A devolucao prevista do seu aluguel e 21/03/2026 10:00.", response)
self.assertIn("Contrato: LOC-20260323-CAEECA1C", response)
self.assertIn("Veiculo: Fiat Pulse", response)
snapshot = state.get_user_context(1)["last_rental_contract"]
self.assertEqual(snapshot["contrato_numero"], "LOC-20260323-CAEECA1C")
self.assertEqual(snapshot["status_pagamento"], "registrado")
self.assertEqual(snapshot["data_fim_prevista"], "2026-03-21T10:00:00")
def test_store_last_rental_contract_preserves_contract_snapshot_after_payment_update(self):
state = FakeState(
contexts={
1: {
"active_domain": "general",
"generic_memory": {},
"shared_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
"last_rental_results": [],
"selected_rental_vehicle": None,
}
}
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service._get_user_context = lambda user_id: state.get_user_context(user_id)
service._save_user_context = lambda user_id, context: state.save_user_context(user_id, context)
service._store_last_rental_contract(
user_id=1,
payload={
"contrato_numero": "LOC-20260323-CAEECA1C",
"placa": "RAA1A02",
"modelo_veiculo": "Fiat Pulse",
"data_inicio": "2026-03-19T10:00:00",
"data_fim_prevista": "2026-03-21T10:00:00",
"valor_diaria": 189.9,
"valor_previsto": 379.8,
"status": "ativa",
},
)
service._store_last_rental_contract(
user_id=1,
payload={
"contrato_numero": "LOC-20260323-CAEECA1C",
"placa": "RAA1A02",
"valor": 379.8,
"data_pagamento": "2026-03-23T15:47:00",
"favorecido": "Locadora XPTO",
"status": "registrado",
},
)
snapshot = state.get_user_context(1)["last_rental_contract"]
self.assertEqual(snapshot["modelo_veiculo"], "Fiat Pulse")
self.assertEqual(snapshot["data_fim_prevista"], "2026-03-21T10:00:00")
self.assertEqual(snapshot["status"], "ativa")
self.assertEqual(snapshot["status_pagamento"], "registrado")
self.assertEqual(snapshot["data_pagamento"], "2026-03-23T15:47:00")
self.assertEqual(snapshot["valor_pagamento"], 379.8)
def test_has_rental_return_management_request_ignores_return_question_even_with_last_contract(self): def test_has_rental_return_management_request_ignores_return_question_even_with_last_contract(self):
state = FakeState( state = FakeState(
contexts={ contexts={
@ -3715,6 +4093,73 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("Vou comecar por: Venda: fazer pedido", response) self.assertIn("Vou comecar por: Venda: fazer pedido", response)
async def test_pending_order_selection_promotes_new_operational_request_before_previous_options(self):
state = FakeState(
contexts={
9: {
"pending_order_selection": {
"orders": [
{"domain": "sales", "message": "compra", "seed_message": "quero comprar um veiculo", "memory_seed": {}},
{"domain": "review", "message": "revisao", "seed_message": "quero agendar revisao", "memory_seed": {}},
{"domain": "rental", "message": "aluguel", "seed_message": "quero alugar um carro", "memory_seed": {}},
],
"expires_at": utc_now() + timedelta(minutes=15),
},
"order_queue": [],
"active_domain": "general",
"generic_memory": {},
}
}
)
service = FakePolicyService(state)
policy = ConversationPolicy(service=service)
response = await policy.try_resolve_pending_order_selection(
message="quais pedidos eu tenho?",
user_id=9,
turn_decision={"domain": "sales", "intent": "order_list", "action": "call_tool", "tool_name": "listar_pedidos"},
)
self.assertIsNone(response)
context = state.get_user_context(9)
self.assertIsNone(context["pending_order_selection"])
self.assertEqual([item["domain"] for item in context["order_queue"]], ["sales", "review", "rental"])
self.assertEqual(context["order_queue"][0]["message"], "quero comprar um veiculo")
async def test_pending_order_selection_skips_duplicate_base_task_when_new_request_is_more_specific(self):
state = FakeState(
contexts={
9: {
"pending_order_selection": {
"orders": [
{"domain": "sales", "message": "compra", "seed_message": "quero comprar um veiculo", "memory_seed": {}},
{"domain": "review", "message": "revisao", "seed_message": "quero agendar revisao", "memory_seed": {}},
{"domain": "rental", "message": "aluguel", "seed_message": "quero alugar um carro", "memory_seed": {}},
],
"expires_at": utc_now() + timedelta(minutes=15),
},
"order_queue": [],
"active_domain": "general",
"generic_memory": {},
}
}
)
service = FakePolicyService(state)
policy = ConversationPolicy(service=service)
response = await policy.try_resolve_pending_order_selection(
message="quero comprar um suv ate 95 mil",
user_id=9,
turn_decision={"domain": "sales", "intent": "order_create", "action": "collect_order_create"},
)
self.assertIn("Perfeito. Vou comecar por: Venda: compra", response)
self.assertIn("handled:quero comprar um suv ate 95 mil", response)
context = state.get_user_context(9)
self.assertIsNone(context["pending_order_selection"])
self.assertEqual(context["active_domain"], "sales")
self.assertEqual([item["domain"] for item in context["order_queue"]], ["review", "rental"])
async def test_try_continue_queue_prefers_turn_decision_action(self): async def test_try_continue_queue_prefers_turn_decision_action(self):
state = FakeState( state = FakeState(
contexts={ contexts={
@ -3812,6 +4257,111 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(early_response) self.assertIsNone(early_response)
self.assertEqual(service._get_user_context(9).get("order_queue"), []) self.assertEqual(service._get_user_context(9).get("order_queue"), [])
def test_prepare_message_for_single_order_requests_clarification_for_three_actionable_domains(self):
state = FakeState(
contexts={
9: {
"active_domain": "general",
"generic_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
}
},
)
service = FakePolicyService(state)
policy = ConversationPolicy(service=service)
routed_message, queue_notice, early_response = policy.prepare_message_for_single_order(
message="oi, pode me ajudar com compra, revisao e aluguel?",
user_id=9,
routing_plan={
"orders": [
{"domain": "sales", "message": "compra"},
{"domain": "review", "message": "revisao"},
]
},
)
self.assertEqual(routed_message, "oi, pode me ajudar com compra, revisao e aluguel?")
self.assertIsNone(queue_notice)
self.assertIn("Identifiquei 3 acoes", early_response)
self.assertIn("3. Locacao: aluguel", early_response)
pending = state.get_user_context(9)["pending_order_selection"]
self.assertEqual(len(pending["orders"]), 3)
def test_prepare_message_for_single_order_counts_only_orders_effectively_queued(self):
state = FakeState(
entries={
"pending_review_drafts": {
9: {
"payload": {"placa": "ABC1234"},
"expires_at": utc_now() + timedelta(minutes=15),
}
}
},
contexts={
9: {
"active_domain": "review",
"generic_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
}
},
)
service = FakePolicyService(state)
policy = ConversationPolicy(service=service)
routed_message, queue_notice, early_response = policy.prepare_message_for_single_order(
message="quero continuar a revisao e tambem ver aluguel",
user_id=9,
routing_plan={
"orders": [
{"domain": "review", "message": "quero continuar a revisao"},
{"domain": "general", "message": "oi"},
{"domain": "rental", "message": "quero ver aluguel"},
]
},
)
self.assertEqual(routed_message, "quero continuar a revisao e tambem ver aluguel")
self.assertIn("Anotei mais 1 pedido", early_response)
self.assertEqual(len(state.get_user_context(9)["order_queue"]), 1)
self.assertEqual(state.get_user_context(9)["order_queue"][0]["domain"], "rental")
self.assertEqual(state.get_user_context(9)["order_queue"][0]["message"], "quero ver aluguel")
async def test_pending_order_selection_uses_canonical_seed_message_for_selected_domain(self):
state = FakeState(
contexts={
9: {
"active_domain": "general",
"generic_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
}
}
)
service = FakePolicyService(state)
policy = ConversationPolicy(service=service)
policy.store_pending_order_selection(
user_id=9,
orders=[
{"domain": "sales", "message": "compra", "entities": service.normalizer.empty_extraction_payload()},
{"domain": "review", "message": "revisao", "entities": service.normalizer.empty_extraction_payload()},
{"domain": "rental", "message": "aluguel", "entities": service.normalizer.empty_extraction_payload()},
],
)
response = await policy.try_resolve_pending_order_selection(message="1", user_id=9)
self.assertIn("Perfeito. Vou comecar por: Venda: compra", response)
self.assertIn("handled:quero comprar um veiculo", response)
context = state.get_user_context(9)
self.assertEqual(context["active_domain"], "sales")
self.assertEqual([item["domain"] for item in context["order_queue"]], ["review", "rental"])
async def test_tool_continuar_proximo_pedido_reports_empty_queue(self): async def test_tool_continuar_proximo_pedido_reports_empty_queue(self):
state = FakeState( state = FakeState(

Loading…
Cancel
Save