🚧 feat(rental): blindar follow-ups e comprovantes multimodais

main
parent 0ba1660c20
commit 2c4e1dd688

@ -40,18 +40,7 @@ class LLMService:
if not attachments:
return str(caption or "").strip()
prompt = (
"Voce esta preparando uma mensagem textual curta para um orquestrador de atendimento automotivo e locacao. "
"Analise a imagem enviada pelo usuario e a legenda opcional. "
"Se for comprovante de pagamento de aluguel, responda com uma frase objetiva em portugues no formato: "
"Registrar pagamento de aluguel: contrato <...>; placa <...>; valor <...>; data_pagamento <...>; favorecido <...>; identificador_comprovante <...>; observacoes <...>. "
"Se for multa de transito relacionada a carro alugado, responda com uma frase objetiva em portugues no formato: "
"Registrar multa de aluguel: placa <...>; contrato <...>; auto_infracao <...>; orgao_emissor <...>; valor <...>; data_infracao <...>; vencimento <...>; observacoes <...>. "
"Se for outro documento automotivo util, resuma em uma frase com os dados importantes. "
"Se nao conseguir identificar com seguranca, responda exatamente: Nao consegui identificar os dados da imagem. Descreva o documento ou envie uma foto mais nitida. "
"Use apenas dados observaveis e nao invente informacoes. "
f"Legenda do usuario: {(caption or '').strip() or 'sem legenda'}"
)
prompt = self._build_image_workflow_prompt(caption=caption)
contents: List[Any] = [prompt]
for attachment in attachments:
@ -86,6 +75,24 @@ class LLMService:
payload = self._extract_response_payload(response)
return (payload.get("response") or "").strip() or (caption or "").strip()
def _build_image_workflow_prompt(self, *, caption: str | None) -> str:
normalized_caption = (caption or "").strip() or "sem legenda"
return (
"Voce esta preparando uma mensagem textual curta para um orquestrador de atendimento automotivo e locacao. "
"Analise a imagem enviada pelo usuario e a legenda opcional. "
"Se for comprovante de pagamento de aluguel, responda com uma frase objetiva em portugues no formato: "
"Registrar pagamento de aluguel: contrato <...>; placa <...>; valor <...>; data_pagamento <...>; favorecido <...>; identificador_comprovante <...>; observacoes <...>. "
"Se a data de pagamento incluir hora e minuto visiveis na imagem, preserve a data e a hora no campo data_pagamento no formato DD/MM/AAAA HH:MM. "
"Nao reduza para somente a data quando a hora estiver visivel. "
"Se apenas a data estiver visivel, use somente a data. "
"Se for multa de transito relacionada a carro alugado, responda com uma frase objetiva em portugues no formato: "
"Registrar multa de aluguel: placa <...>; contrato <...>; auto_infracao <...>; orgao_emissor <...>; valor <...>; data_infracao <...>; vencimento <...>; observacoes <...>. "
"Se for outro documento automotivo util, resuma em uma frase com os dados importantes. "
"Se nao conseguir identificar com seguranca, responda exatamente: Nao consegui identificar os dados da imagem. Descreva o documento ou envie uma foto mais nitida. "
"Use apenas dados observaveis e nao invente informacoes. "
f"Legenda do usuario: {normalized_caption}"
)
def build_vertex_tools(self, tools: List[ToolDefinition]) -> Optional[List[Tool]]:
"""Converte tools internas para o formato esperado pelo Vertex AI."""
# Vertex espera uma lista de Tool, com function_declarations agrupadas em um unico Tool.

@ -88,6 +88,62 @@ class RentalFlowMixin:
selected_vehicle = context.get("selected_rental_vehicle")
return dict(selected_vehicle) if isinstance(selected_vehicle, dict) else None
def _sanitize_rental_contract_snapshot(self, payload) -> dict | None:
if not isinstance(payload, dict):
return None
contract_number = str(payload.get("contrato_numero") or "").strip().upper()
plate = technical_normalizer.normalize_plate(payload.get("placa"))
if not contract_number and not plate:
return None
snapshot: dict = {}
if contract_number:
snapshot["contrato_numero"] = contract_number
if plate:
snapshot["placa"] = plate
for field_name in (
"modelo_veiculo",
"categoria",
"status",
"status_veiculo",
"data_inicio",
"data_fim_prevista",
"data_devolucao",
):
value = str(payload.get(field_name) or "").strip()
if value:
snapshot[field_name] = value
for field_name in ("valor_diaria", "valor_previsto", "valor_final"):
number = technical_normalizer.normalize_positive_number(payload.get(field_name))
if number is not None:
snapshot[field_name] = float(number)
return snapshot
def _get_last_rental_contract(self, user_id: int | None) -> dict | None:
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return None
contract = context.get("last_rental_contract")
return dict(contract) if isinstance(contract, dict) else None
def _store_last_rental_contract(self, user_id: int | None, payload) -> None:
if user_id is None:
return
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return
sanitized = self._sanitize_rental_contract_snapshot(payload)
if sanitized is None:
context.pop("last_rental_contract", None)
else:
context["last_rental_contract"] = sanitized
self._save_user_context(user_id=user_id, context=context)
def _remember_rental_results(self, user_id: int | None, rental_results: list[dict] | None) -> None:
context = self._get_user_context(user_id)
if not isinstance(context, dict):
@ -503,5 +559,6 @@ class RentalFlowMixin:
except HTTPException as exc:
return self._http_exception_detail(exc)
self._store_last_rental_contract(user_id=user_id, payload=tool_result)
self._reset_pending_rental_states(user_id=user_id)
return self._fallback_format_tool_result("abrir_locacao_aluguel", tool_result)

@ -181,6 +181,16 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
if queued_followup:
return queued_followup
deterministic_rental_management = await self._try_handle_deterministic_rental_management(
message=message,
user_id=user_id,
queue_notice=None,
finish=finish,
)
if deterministic_rental_management:
return deterministic_rental_management
message_plan = await self._extract_message_plan_with_llm(
message=message,
user_id=user_id,
@ -741,6 +751,285 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
return await finish(response)
return None
def _clean_extracted_rental_value(self, value: str | None) -> str | None:
text = str(value or "").strip(" \t\r\n.;,")
if not text:
return None
if re.fullmatch(r"<[^>]*>", text):
text = text[1:-1].strip(" \t\r\n.;,")
if not text:
return None
normalized = self._normalize_text(text).strip()
if normalized in {
"n/a",
"na",
"nao informado",
"nao informada",
"nao identificado",
"nao identificada",
"desconhecido",
"desconhecida",
"sem informacao",
"null",
"none",
"...",
}:
return None
return text
def _extract_rental_labeled_value(self, text: str, labels: tuple[str, ...]) -> str | None:
if not labels:
return None
label_pattern = "|".join(re.escape(label) for label in labels)
match = re.search(
rf"(?:^|[\s;\n])(?:{label_pattern})\s*[:=]?\s*(?P<value>[^;\n]+)",
str(text or ""),
flags=re.IGNORECASE,
)
if not match:
return None
return self._clean_extracted_rental_value(match.group("value"))
def _extract_rental_contract_number_from_text(self, text: str) -> str | None:
match = re.search(r"\bLOC-[A-Z0-9-]+\b", str(text or ""), flags=re.IGNORECASE)
if match:
return str(match.group(0)).strip().upper()
labeled_value = self._extract_rental_labeled_value(text, ("contrato_numero", "contrato"))
if not labeled_value:
return None
labeled_match = re.search(r"\bLOC-[A-Z0-9-]+\b", labeled_value, flags=re.IGNORECASE)
if labeled_match:
return str(labeled_match.group(0)).strip().upper()
return None
def _extract_rental_plate_from_text(self, text: str) -> str | None:
labeled_value = self._extract_rental_labeled_value(text, ("placa",))
if labeled_value:
labeled_plate = self._normalize_plate(labeled_value)
if labeled_plate:
return labeled_plate
extracted: dict = {}
self._try_capture_rental_fields_from_message(message=text, payload=extracted)
return self._normalize_plate(extracted.get("placa"))
def _merge_last_rental_reference(self, user_id: int | None, arguments: dict) -> dict:
if not isinstance(arguments, dict):
return {}
last_contract = self._get_last_rental_contract(user_id)
if not isinstance(last_contract, dict):
return arguments
if not arguments.get("contrato_numero") and last_contract.get("contrato_numero"):
arguments["contrato_numero"] = str(last_contract["contrato_numero"])
if not arguments.get("placa") and last_contract.get("placa"):
arguments["placa"] = str(last_contract["placa"])
return arguments
def _has_rental_return_management_request(self, message: str, user_id: int | None = None) -> bool:
if not self._has_rental_return_request(message):
return False
normalized_message = self._normalize_text(message).strip()
return bool(
"aluguel" in normalized_message
or "locacao" in normalized_message
or self._get_last_rental_contract(user_id)
or self._extract_rental_contract_number_from_text(message)
or self._extract_rental_plate_from_text(message)
)
def _has_rental_payment_request(self, message: str, user_id: int | None = None) -> bool:
normalized_message = self._normalize_text(message).strip()
if "multa" in normalized_message:
return False
payment_terms = ("pagamento", "comprovante", "pix", "boleto")
if not any(term in normalized_message for term in payment_terms):
return False
return bool(
"aluguel" in normalized_message
or "locacao" in normalized_message
or self._get_last_rental_contract(user_id)
or self._extract_rental_contract_number_from_text(message)
)
def _has_rental_fine_request(self, message: str, user_id: int | None = None) -> bool:
normalized_message = self._normalize_text(message).strip()
if "multa" not in normalized_message:
return False
return bool(
"aluguel" in normalized_message
or "locacao" in normalized_message
or "auto_infracao" in normalized_message
or self._get_last_rental_contract(user_id)
or self._extract_rental_contract_number_from_text(message)
or self._extract_rental_plate_from_text(message)
)
def _is_deterministic_rental_management_candidate(self, message: str, user_id: int | None) -> bool:
has_policy = hasattr(self, "policy") and getattr(self, "policy") is not None
if has_policy and user_id is not None and (
self._has_open_flow(user_id, "sales") or self._has_open_flow(user_id, "review")
):
return False
return bool(
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)
)
def _build_rental_return_arguments_from_message(self, message: str, user_id: int | None) -> dict:
arguments: dict = {}
contract_number = self._extract_rental_contract_number_from_text(message)
if contract_number:
arguments["contrato_numero"] = contract_number
plate = self._extract_rental_plate_from_text(message)
if plate:
arguments["placa"] = plate
date_text = self._extract_rental_labeled_value(message, ("data_devolucao", "data de devolucao"))
if not date_text:
datetimes = self._extract_rental_datetimes_from_text(message)
if datetimes:
date_text = datetimes[-1]
if date_text:
arguments["data_devolucao"] = date_text
return self._merge_last_rental_reference(user_id=user_id, arguments=arguments)
def _build_rental_payment_arguments_from_message(self, message: str, user_id: int | None) -> dict:
arguments: dict = {}
contract_number = self._extract_rental_contract_number_from_text(message)
if contract_number:
arguments["contrato_numero"] = contract_number
plate = self._extract_rental_plate_from_text(message)
if plate:
arguments["placa"] = plate
amount_text = self._extract_rental_labeled_value(message, ("valor_pago", "valor"))
amount = self._normalize_positive_number(amount_text)
if amount is not None:
arguments["valor"] = float(amount)
payment_date = self._extract_rental_labeled_value(message, ("data_pagamento", "data do pagamento"))
if not payment_date:
datetimes = self._extract_rental_datetimes_from_text(message)
if datetimes:
payment_date = datetimes[0]
if payment_date:
arguments["data_pagamento"] = payment_date
favorecido = self._extract_rental_labeled_value(message, ("favorecido",))
if favorecido:
arguments["favorecido"] = favorecido
receipt_id = self._extract_rental_labeled_value(
message,
("identificador_comprovante", "identificador", "nsu"),
)
if receipt_id:
arguments["identificador_comprovante"] = receipt_id
observations = self._extract_rental_labeled_value(message, ("observacoes", "observacao"))
if observations:
arguments["observacoes"] = observations
return self._merge_last_rental_reference(user_id=user_id, arguments=arguments)
def _build_rental_fine_arguments_from_message(self, message: str, user_id: int | None) -> dict:
arguments: dict = {}
contract_number = self._extract_rental_contract_number_from_text(message)
if contract_number:
arguments["contrato_numero"] = contract_number
plate = self._extract_rental_plate_from_text(message)
if plate:
arguments["placa"] = plate
notice_number = self._extract_rental_labeled_value(
message,
("auto_infracao", "auto de infracao", "auto da infracao"),
)
if notice_number:
arguments["auto_infracao"] = notice_number
issuing_agency = self._extract_rental_labeled_value(
message,
("orgao_emissor", "orgao emissor"),
)
if issuing_agency:
arguments["orgao_emissor"] = issuing_agency
amount_text = self._extract_rental_labeled_value(message, ("valor",))
amount = self._normalize_positive_number(amount_text)
if amount is not None:
arguments["valor"] = float(amount)
violation_date = self._extract_rental_labeled_value(message, ("data_infracao", "data da infracao"))
due_date = self._extract_rental_labeled_value(message, ("vencimento", "data_vencimento", "data de vencimento"))
datetimes = self._extract_rental_datetimes_from_text(message)
if not violation_date and datetimes:
violation_date = datetimes[0]
if not due_date and len(datetimes) >= 2:
due_date = datetimes[1]
if violation_date:
arguments["data_infracao"] = violation_date
if due_date:
arguments["vencimento"] = due_date
observations = self._extract_rental_labeled_value(message, ("observacoes", "observacao"))
if observations:
arguments["observacoes"] = observations
return self._merge_last_rental_reference(user_id=user_id, arguments=arguments)
async def _try_handle_deterministic_rental_management(
self,
message: str,
user_id: int | None,
queue_notice: str | None,
finish,
) -> str | None:
if user_id is None or not self._is_deterministic_rental_management_candidate(message, user_id=user_id):
return None
if self._has_rental_return_management_request(message, user_id=user_id):
tool_name = "registrar_devolucao_aluguel"
arguments = self._build_rental_return_arguments_from_message(message=message, user_id=user_id)
missing_response = None
elif self._has_rental_fine_request(message, user_id=user_id):
tool_name = "registrar_multa_aluguel"
arguments = self._build_rental_fine_arguments_from_message(message=message, user_id=user_id)
missing_response = None
if "valor" not in arguments:
missing_response = "Para registrar a multa de aluguel, preciso do valor informado no documento."
elif self._has_rental_payment_request(message, user_id=user_id):
tool_name = "registrar_pagamento_aluguel"
arguments = self._build_rental_payment_arguments_from_message(message=message, user_id=user_id)
missing_response = None
if "valor" not in arguments:
missing_response = "Para registrar o pagamento do aluguel, preciso do valor informado no comprovante."
else:
return None
if missing_response:
return await finish(missing_response, queue_notice=queue_notice)
try:
tool_result = await self._execute_tool_with_trace(
tool_name,
arguments,
user_id=user_id,
)
except HTTPException as exc:
return await finish(self._http_exception_detail(exc), queue_notice=queue_notice)
self._capture_successful_tool_side_effects(
tool_name=tool_name,
arguments=arguments,
tool_result=tool_result,
user_id=user_id,
)
return await finish(
self._fallback_format_tool_result(tool_name, tool_result),
queue_notice=queue_notice,
)
async def _try_handle_active_sales_follow_up(
self,
message: str,
@ -1271,6 +1560,13 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin, RentalFlowMixin):
) -> None:
if tool_name == "agendar_revisao" and isinstance(arguments, dict):
self._store_last_review_package(user_id=user_id, payload=arguments)
if tool_name in {
"abrir_locacao_aluguel",
"registrar_devolucao_aluguel",
"registrar_pagamento_aluguel",
"registrar_multa_aluguel",
} and isinstance(tool_result, dict):
self._store_last_rental_contract(user_id=user_id, payload=tool_result)
self._capture_tool_result_context(
tool_name=tool_name,
tool_result=tool_result,

@ -15,7 +15,8 @@ def build_router_prompt(
return (
"Voce e um assistente de concessionaria. "
"Sempre que a solicitacao depender de dados operacionais (estoque, validacao de cliente, "
"avaliacao de troca, agendamento de revisao, realizacao ou cancelamento de pedido), use a tool correta. "
"avaliacao de troca, agendamento de revisao, realizacao ou cancelamento de pedido, consulta de frota de aluguel, "
"abertura de locacao, devolucao de aluguel, registro de pagamento de aluguel ou registro de multa de aluguel), use a tool correta. "
"Se o usuario pedir para recomecar, esquecer contexto, cancelar fluxo atual, descartar fila pendente "
"ou continuar o proximo pedido, use a tool de orquestracao apropriada. "
"Mensagens de controle da conversa tem prioridade sobre qualquer fluxo em aberto. "
@ -36,6 +37,7 @@ def build_force_tool_prompt(
user_context = _build_user_context_line(user_id)
return (
"Reavalie a mensagem e priorize chamar tool se houver intencao operacional. "
"Considere tambem as operacoes de aluguel (consultar frota, abrir locacao, registrar devolucao, pagamento ou multa). "
"Considere tambem tools de orquestracao para limpar contexto, cancelar fluxo, descartar fila ou continuar o proximo pedido. "
"Mesmo com fluxo incremental ativo, se a mensagem for de controle global da conversa, a tool de orquestracao deve vencer o rascunho atual. "
"Use texto apenas quando faltar dado obrigatorio.\n\n"

@ -197,7 +197,9 @@ def fallback_format_tool_result(tool_name: str, tool_result: Any) -> str:
f"Inicio: {data_inicio}\n"
f"Devolucao prevista: {data_fim}\n"
f"Diaria: {valor_diaria}\n"
f"Valor previsto: {valor_previsto}"
f"Valor previsto: {valor_previsto}\n"
"Pagamento: em aberto\n"
"Quando quiser testar o comprovante, envie a imagem com os dados do pagamento."
)
if tool_name == "registrar_devolucao_aluguel" and isinstance(tool_result, dict):

@ -440,6 +440,24 @@ class ConversationAdjustmentsTests(unittest.TestCase):
self.assertIn("2. REV-2 | XYZ9999 |", response)
self.assertNotIn("\n\n", response)
def test_rental_open_formatter_marks_payment_as_pending(self):
response = fallback_format_tool_result(
"abrir_locacao_aluguel",
{
"contrato_numero": "LOC-20260318-FE69BCF0",
"placa": "RAA1A12",
"modelo_veiculo": "Peugeot 208",
"data_inicio": "2026-03-20T10:00:00",
"data_fim_prevista": "2026-03-23T10:00:00",
"valor_diaria": 149.9,
"valor_previsto": 449.7,
},
)
self.assertIn("Pagamento: em aberto", response)
self.assertIn("testar o comprovante", response)
def test_defer_flow_cancel_when_order_cancel_draft_waits_for_reason(self):
state = FakeState(
entries={

@ -57,3 +57,17 @@ class LLMServiceResponseParsingTests(unittest.TestCase):
payload = service._extract_response_payload(response)
self.assertEqual(payload, {"response": "Resposta simples", "tool_call": None})
class LLMServiceImageWorkflowPromptTests(unittest.TestCase):
def test_build_image_workflow_prompt_preserves_visible_payment_time(self):
service = LLMService.__new__(LLMService)
prompt = service._build_image_workflow_prompt(caption="Segue o comprovante")
self.assertIn(
"preserve a data e a hora no campo data_pagamento no formato DD/MM/AAAA HH:MM",
prompt,
)
self.assertIn("Nao reduza para somente a data quando a hora estiver visivel.", prompt)
self.assertIn("Legenda do usuario: Segue o comprovante", prompt)

@ -2289,6 +2289,318 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("inicio da locacao", response)
async def test_handle_message_short_circuits_for_rental_return_using_last_contract(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-20260318-FE69BCF0",
"placa": "RAA1A12",
},
}
}
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service.policy = ConversationPolicy(service=service)
service.tool_executor = FakeToolExecutor(
result={
"contrato_numero": "LOC-20260318-FE69BCF0",
"placa": "RAA1A12",
"modelo_veiculo": "Peugeot 208",
"data_devolucao": "2026-03-18T15:46:00",
"valor_final": 449.7,
}
)
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._fallback_format_tool_result = lambda tool_name, tool_result: "devolucao ok"
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_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": None,
}
service._extract_turn_decision_with_llm = fake_extract_turn_decision
async def fake_try_handle_immediate_context_reset(**kwargs):
return None
service._try_handle_immediate_context_reset = fake_try_handle_immediate_context_reset
async def fake_try_resolve_pending_order_selection(**kwargs):
return None
service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection
async def fake_try_continue_queued_order(**kwargs):
return None
service._try_continue_queued_order = fake_try_continue_queued_order
async def fake_extract_message_plan(message: str, user_id: int | None):
raise AssertionError("nao deveria consultar o planner para devolucao deterministica de aluguel")
service._extract_message_plan_with_llm = fake_extract_message_plan
response = await service.handle_message(
"devolver a placa RAA1A12",
user_id=1,
)
self.assertEqual(response, "devolucao ok")
self.assertEqual(
service.tool_executor.calls,
[
(
"registrar_devolucao_aluguel",
{"placa": "RAA1A12", "contrato_numero": "LOC-20260318-FE69BCF0"},
1,
)
],
)
async def test_handle_message_short_circuits_for_rental_payment_receipt_text(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-20260318-FE69BCF0",
"placa": "RAA1A12",
},
}
}
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service.policy = ConversationPolicy(service=service)
service.tool_executor = FakeToolExecutor(
result={
"protocolo": "ALP-20260318-ABCD1234",
"contrato_numero": "LOC-20260318-FE69BCF0",
"placa": "RAA1A12",
"valor": 449.7,
"data_pagamento": "2026-03-18T15:47:00",
}
)
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._fallback_format_tool_result = lambda tool_name, tool_result: "pagamento ok"
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_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": None,
}
service._extract_turn_decision_with_llm = fake_extract_turn_decision
async def fake_try_handle_immediate_context_reset(**kwargs):
return None
service._try_handle_immediate_context_reset = fake_try_handle_immediate_context_reset
async def fake_try_resolve_pending_order_selection(**kwargs):
return None
service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection
async def fake_try_continue_queued_order(**kwargs):
return None
service._try_continue_queued_order = fake_try_continue_queued_order
async def fake_extract_message_plan(message: str, user_id: int | None):
raise AssertionError("nao deveria consultar o planner para pagamento deterministico de aluguel")
service._extract_message_plan_with_llm = fake_extract_message_plan
response = await service.handle_message(
"Registrar pagamento de aluguel: valor 449,70; data_pagamento 18/03/2026 15:47; favorecido Locadora XPTO; identificador_comprovante NSU123.",
user_id=1,
)
self.assertEqual(response, "pagamento ok")
self.assertEqual(
service.tool_executor.calls,
[
(
"registrar_pagamento_aluguel",
{
"valor": 449.7,
"data_pagamento": "18/03/2026 15:47",
"favorecido": "Locadora XPTO",
"identificador_comprovante": "NSU123",
"contrato_numero": "LOC-20260318-FE69BCF0",
"placa": "RAA1A12",
},
1,
)
],
)
async def test_handle_message_short_circuits_for_rental_payment_receipt_text_with_angle_brackets(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-20260318-4B85490F",
"placa": "RAA1A22",
},
}
}
)
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service.policy = ConversationPolicy(service=service)
service.tool_executor = FakeToolExecutor(
result={
"protocolo": "ALP-20260318-ABCD1234",
"contrato_numero": "LOC-20260318-4B85490F",
"placa": "RAA1A22",
"valor": 479.7,
"data_pagamento": "2026-03-18T16:10:00",
}
)
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._fallback_format_tool_result = lambda tool_name, tool_result: "pagamento ok"
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_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": None,
}
service._extract_turn_decision_with_llm = fake_extract_turn_decision
async def fake_try_handle_immediate_context_reset(**kwargs):
return None
service._try_handle_immediate_context_reset = fake_try_handle_immediate_context_reset
async def fake_try_resolve_pending_order_selection(**kwargs):
return None
service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection
async def fake_try_continue_queued_order(**kwargs):
return None
service._try_continue_queued_order = fake_try_continue_queued_order
async def fake_extract_message_plan(message: str, user_id: int | None):
raise AssertionError("nao deveria consultar o planner para pagamento deterministico de aluguel")
service._extract_message_plan_with_llm = fake_extract_message_plan
response = await service.handle_message(
"[imagem recebida no telegram]\nDados extraidos da imagem: Registrar pagamento de aluguel: contrato <LOC-20260318-4B85490F>; placa <nao informada>; valor <R$ 479,70>; data_pagamento <18/03/2026 16:10>; favorecido <Locadora XPTO>; identificador_comprovante <NSU123456>; observacoes <pagamento da locacao>.",
user_id=1,
)
self.assertEqual(response, "pagamento ok")
self.assertEqual(
service.tool_executor.calls,
[
(
"registrar_pagamento_aluguel",
{
"contrato_numero": "LOC-20260318-4B85490F",
"valor": 479.7,
"data_pagamento": "18/03/2026 16:10",
"favorecido": "Locadora XPTO",
"identificador_comprovante": "NSU123456",
"observacoes": "pagamento da locacao",
"placa": "RAA1A22",
},
1,
)
],
)
async def test_handle_message_keeps_sales_flow_when_cpf_follow_up_is_misclassified_as_review(self):
state = FakeState(
entries={

Loading…
Cancel
Save