🧠 feat(orquestrador): fortalecer contexto multiassunto e gestão de revisão

- unifica plano de mensagem via LLM (roteamento + entidades por pedido)
- adiciona fallback de extração separada apenas quando o plano vier sem dados úteis
- reduz reprocessamento no mesmo turno com fila explícita e avanço por 'continuar'
- melhora UX de fila com aviso determinístico de pedidos enfileirados
- amplia intents de revisão: listagem, cancelamento e remarcação
- adiciona campos dedicados para gestão de revisão (protocolo, nova_data_hora, motivo)
- implementa slot filling para cancelar/remarcar revisão com prompts de faltantes
- reforça regras de troca de contexto quando há fluxo de revisão aberto
- adiciona memória curta do último pacote de revisão com TTL (20 min)
- implementa confirmação de reuso de pacote para novo agendamento
- quando reuso confirmado, coleta apenas data/hora e mantém demais dados
- preserva arquitetura: LLM decide e extrai; sistema normaliza, valida, mantém estado e executa tools

 Benefícios:
- maior robustez em cenários ambíguos de revisão
- melhor continuidade entre assuntos sem perder contexto
- menor atrito no reagendamento de revisões semelhantes
main
parent 3bc23e63d1
commit a6f1358c28

@ -2,6 +2,7 @@ USER_CONTEXT_TTL_MINUTES = 60
PENDING_REVIEW_TTL_MINUTES = 30
PENDING_REVIEW_DRAFT_TTL_MINUTES = 30
LAST_REVIEW_PACKAGE_TTL_MINUTES = 20
PENDING_ORDER_DRAFT_TTL_MINUTES = 30
PENDING_CANCEL_ORDER_DRAFT_TTL_MINUTES = 30

@ -11,6 +11,7 @@ from app.services.orchestrator_config import (
CANCEL_ORDER_REQUIRED_FIELDS,
DETERMINISTIC_RESPONSE_TOOLS,
LOW_VALUE_RESPONSES,
LAST_REVIEW_PACKAGE_TTL_MINUTES,
ORDER_REQUIRED_FIELDS,
PENDING_CANCEL_ORDER_DRAFT_TTL_MINUTES,
PENDING_ORDER_DRAFT_TTL_MINUTES,
@ -32,6 +33,9 @@ class OrquestradorService:
PENDING_REVIEW_CONFIRMATIONS: dict[int, dict] = {}
# Rascunho por usuario para juntar dados de revisao enviados em mensagens separadas.
PENDING_REVIEW_DRAFTS: dict[int, dict] = {}
PENDING_REVIEW_MANAGEMENT_DRAFTS: dict[int, dict] = {}
LAST_REVIEW_PACKAGES: dict[int, dict] = {}
PENDING_REVIEW_REUSE_CONFIRMATIONS: dict[int, dict] = {}
PENDING_ORDER_DRAFTS: dict[int, dict] = {}
PENDING_CANCEL_ORDER_DRAFTS: dict[int, dict] = {}
@ -54,8 +58,23 @@ class OrquestradorService:
)
self._upsert_user_context(user_id=user_id)
queued_followup = await self._try_continue_queued_order(message=message, user_id=user_id)
if queued_followup:
return queued_followup
routing_plan = await self._extract_routing_with_llm(message=message, user_id=user_id)
message_plan = await self._extract_message_plan_with_llm(
message=message,
user_id=user_id,
)
routing_plan = {
"orders": [
{
"domain": item.get("domain", "general"),
"message": item.get("message", ""),
}
for item in message_plan.get("orders", [])
]
}
(
routing_message,
@ -69,10 +88,15 @@ class OrquestradorService:
if queue_early_response:
return await finish(queue_early_response, queue_notice=queue_notice)
extracted_entities = await self._extract_entities_with_llm(
message=routing_message,
user_id=user_id,
extracted_entities = self._resolve_entities_for_message_plan(
message_plan=message_plan,
routed_message=routing_message,
)
if not self._has_useful_extraction(extracted_entities):
extracted_entities = await self._extract_entities_with_llm(
message=routing_message,
user_id=user_id,
)
self._capture_generic_memory(
user_id=user_id,
llm_generic_fields=extracted_entities.get("generic_memory", {}),
@ -92,6 +116,7 @@ class OrquestradorService:
review_management_response = await self._try_handle_review_management(
message=routing_message,
user_id=user_id,
extracted_fields=extracted_entities.get("review_management_fields", {}),
intents=extracted_entities.get("intents", {}),
)
if review_management_response:
@ -142,7 +167,13 @@ class OrquestradorService:
tools=tools,
)
if not llm_result["tool_call"] and self._has_operational_intent(extracted_entities):
first_pass_text = (llm_result.get("response") or "").strip()
should_force_tool = (
not llm_result["tool_call"]
and self._has_operational_intent(extracted_entities)
and self._is_low_value_response(first_pass_text)
)
if should_force_tool:
llm_result = await self.llm.generate_response(
message=self._build_force_tool_prompt(user_message=routing_message, user_id=user_id),
tools=tools,
@ -207,6 +238,8 @@ class OrquestradorService:
return
self.PENDING_REVIEW_DRAFTS.pop(user_id, None)
self.PENDING_REVIEW_CONFIRMATIONS.pop(user_id, None)
self.PENDING_REVIEW_MANAGEMENT_DRAFTS.pop(user_id, None)
self.PENDING_REVIEW_REUSE_CONFIRMATIONS.pop(user_id, None)
def _reset_pending_order_states(self, user_id: int | None) -> None:
if user_id is None:
@ -293,11 +326,54 @@ class OrquestradorService:
return {
"generic_memory": {},
"review_fields": {},
"review_management_fields": {},
"order_fields": {},
"cancel_order_fields": {},
"intents": {},
}
def _empty_message_plan(self, message: str) -> dict:
return {
"orders": [
{
"domain": "general",
"message": (message or "").strip(),
"entities": self._empty_extraction_payload(),
}
]
}
def _coerce_message_plan(self, payload, message: str) -> dict:
default = self._empty_message_plan(message=message)
if not isinstance(payload, dict):
return default
raw_orders = payload.get("orders")
if not isinstance(raw_orders, list):
return default
normalized_orders: list[dict] = []
for item in raw_orders:
if not isinstance(item, dict):
continue
domain = str(item.get("domain") or "general").strip().lower()
if domain not in {"review", "sales", "general"}:
domain = "general"
segment = str(item.get("message") or "").strip()
if not segment:
continue
normalized_orders.append(
{
"domain": domain,
"message": segment,
"entities": self._coerce_extraction_contract(item.get("entities")),
}
)
if not normalized_orders:
return default
return {"orders": normalized_orders}
def _coerce_extraction_contract(self, payload) -> dict:
if not isinstance(payload, dict):
return self._empty_extraction_payload()
@ -309,14 +385,25 @@ class OrquestradorService:
logger.info("Extracao sem secao '%s'; usando vazio.", key)
return contract
async def _extract_routing_with_llm(self, message: str, user_id: int | None) -> dict:
async def _extract_message_plan_with_llm(self, message: str, user_id: int | None) -> dict:
prompt = (
"Analise a mensagem e retorne APENAS JSON valido para roteamento multiassunto.\n"
"Analise a mensagem e retorne APENAS JSON valido com roteamento e entidades por pedido.\n"
"Sem markdown e sem texto extra.\n\n"
"Formato:\n"
"{\n"
' "orders": [\n'
' {"domain": "review|sales|general", "message": "trecho literal do pedido"}\n'
" {\n"
' "domain": "review|sales|general",\n'
' "message": "trecho literal do pedido",\n'
' "entities": {\n'
' "generic_memory": {"placa": null, "cpf": null, "orcamento_max": null, "perfil_veiculo": []},\n'
' "review_fields": {"placa": null, "data_hora": null, "modelo": null, "ano": null, "km": null, "revisao_previa_concessionaria": null},\n'
' "review_management_fields": {"protocolo": null, "nova_data_hora": null, "motivo": null},\n'
' "order_fields": {"cpf": null, "valor_veiculo": null},\n'
' "cancel_order_fields": {"numero_pedido": null, "motivo": null},\n'
' "intents": {"review_schedule": false, "review_list": false, "review_cancel": false, "review_reschedule": false, "order_create": false, "order_cancel": false}\n'
" }\n"
" }\n"
" ]\n"
"}\n\n"
"Regras:\n"
@ -326,34 +413,31 @@ class OrquestradorService:
f"Contexto: user_id={user_id if user_id is not None else 'anonimo'}\n"
f"Mensagem do usuario: {message}"
)
default = {"orders": [{"domain": "general", "message": (message or "").strip()}]}
default = self._empty_message_plan(message=message)
try:
result = await self.llm.generate_response(message=prompt, tools=[])
text = (result.get("response") or "").strip()
payload = self._parse_json_object(text)
if not isinstance(payload, dict):
logger.warning("Roteamento invalido (nao JSON objeto). user_id=%s", user_id)
logger.warning("Plano de mensagem invalido (nao JSON objeto). user_id=%s", user_id)
return default
orders = payload.get("orders")
if not isinstance(orders, list):
return default
normalized: list[dict] = []
for item in orders:
if not isinstance(item, dict):
continue
domain = str(item.get("domain") or "general").strip().lower()
if domain not in {"review", "sales", "general"}:
domain = "general"
segment = str(item.get("message") or "").strip()
if segment:
normalized.append({"domain": domain, "message": segment})
if not normalized:
return default
return {"orders": normalized}
return self._coerce_message_plan(payload=payload, message=message)
except Exception:
logger.exception("Falha ao rotear multiassunto com LLM. user_id=%s", user_id)
logger.exception("Falha ao extrair plano da mensagem com LLM. user_id=%s", user_id)
return default
async def _extract_routing_with_llm(self, message: str, user_id: int | None) -> dict:
plan = await self._extract_message_plan_with_llm(message=message, user_id=user_id)
return {
"orders": [
{
"domain": item.get("domain", "general"),
"message": item.get("message", ""),
}
for item in plan.get("orders", [])
]
}
async def _extract_entities_with_llm(self, message: str, user_id: int | None) -> dict:
user_context = f"user_id={user_id}" if user_id is not None else "user_id=anonimo"
prompt = (
@ -376,6 +460,11 @@ class OrquestradorService:
' "km": null,\n'
' "revisao_previa_concessionaria": null\n'
" },\n"
' "review_management_fields": {\n'
' "protocolo": null,\n'
' "nova_data_hora": null,\n'
' "motivo": null\n'
" },\n"
' "order_fields": {\n'
' "cpf": null,\n'
' "valor_veiculo": null\n'
@ -387,6 +476,8 @@ class OrquestradorService:
' "intents": {\n'
' "review_schedule": false,\n'
' "review_list": false,\n'
' "review_cancel": false,\n'
' "review_reschedule": false,\n'
' "order_create": false,\n'
' "order_cancel": false\n'
" }\n"
@ -410,6 +501,7 @@ class OrquestradorService:
return {
"generic_memory": self._normalize_generic_fields(coerced.get("generic_memory")),
"review_fields": self._normalize_review_fields(coerced.get("review_fields")),
"review_management_fields": self._normalize_review_management_fields(coerced.get("review_management_fields")),
"order_fields": self._normalize_order_fields(coerced.get("order_fields")),
"cancel_order_fields": self._normalize_cancel_order_fields(coerced.get("cancel_order_fields")),
"intents": self._normalize_intents(coerced.get("intents")),
@ -418,6 +510,44 @@ class OrquestradorService:
logger.exception("Falha ao extrair entidades com LLM. user_id=%s", user_id)
return default
def _resolve_entities_for_message_plan(self, message_plan: dict, routed_message: str) -> dict:
default = self._empty_extraction_payload()
if not isinstance(message_plan, dict):
return default
target = (routed_message or "").strip()
raw_orders = message_plan.get("orders")
if not isinstance(raw_orders, list):
return default
for item in raw_orders:
if not isinstance(item, dict):
continue
segment = str(item.get("message") or "").strip()
if segment != target:
continue
entities = self._coerce_extraction_contract(item.get("entities"))
return {
"generic_memory": self._normalize_generic_fields(entities.get("generic_memory")),
"review_fields": self._normalize_review_fields(entities.get("review_fields")),
"review_management_fields": self._normalize_review_management_fields(entities.get("review_management_fields")),
"order_fields": self._normalize_order_fields(entities.get("order_fields")),
"cancel_order_fields": self._normalize_cancel_order_fields(entities.get("cancel_order_fields")),
"intents": self._normalize_intents(entities.get("intents")),
}
return default
def _has_useful_extraction(self, extraction: dict | None) -> bool:
if not isinstance(extraction, dict):
return False
intents = self._normalize_intents(extraction.get("intents"))
if any(intents.values()):
return True
return any(
bool(extraction.get(key))
for key in ("generic_memory", "review_fields", "review_management_fields", "order_fields", "cancel_order_fields")
)
def _parse_json_object(self, text: str):
candidate = (text or "").strip()
if not candidate:
@ -573,6 +703,35 @@ class OrquestradorService:
extracted["revisao_previa_concessionaria"] = reviewed
return extracted
def _extract_review_protocol_from_text(self, text: str) -> str | None:
match = re.search(r"\bREV-[A-Z0-9\-]+\b", str(text or "").upper())
if not match:
return None
return match.group(0)
def _normalize_review_management_fields(self, data) -> dict:
if not isinstance(data, dict):
return {}
extracted: dict = {}
raw_protocol = (
data.get("protocolo")
or data.get("numero_protocolo")
or data.get("codigo")
)
protocol = self._extract_review_protocol_from_text(str(raw_protocol or ""))
if protocol:
extracted["protocolo"] = protocol
new_datetime = self._normalize_review_datetime_text(data.get("nova_data_hora"))
if new_datetime:
extracted["nova_data_hora"] = new_datetime
reason = str(data.get("motivo") or "").strip(" .;")
if reason:
extracted["motivo"] = reason
return extracted
def _normalize_order_fields(self, data) -> dict:
if not isinstance(data, dict):
return {}
@ -603,6 +762,8 @@ class OrquestradorService:
return {
"review_schedule": bool(self._normalize_bool(data.get("review_schedule"))),
"review_list": bool(self._normalize_bool(data.get("review_list"))),
"review_cancel": bool(self._normalize_bool(data.get("review_cancel"))),
"review_reschedule": bool(self._normalize_bool(data.get("review_reschedule"))),
"order_create": bool(self._normalize_bool(data.get("order_create"))),
"order_cancel": bool(self._normalize_bool(data.get("order_cancel"))),
}
@ -615,7 +776,7 @@ class OrquestradorService:
return True
return any(
bool(extracted_entities.get(key))
for key in ("review_fields", "order_fields", "cancel_order_fields")
for key in ("review_fields", "review_management_fields", "order_fields", "cancel_order_fields")
)
def _try_prefill_review_fields_from_memory(self, user_id: int | None, payload: dict) -> None:
@ -691,29 +852,43 @@ class OrquestradorService:
and self._has_open_flow(user_id=user_id, domain=active_domain)
):
self._queue_order(user_id=user_id, domain=inferred, order_message=message)
queue_hint = self._render_queue_notice(1)
return (
message,
None,
self._render_open_flow_prompt(user_id=user_id, domain=active_domain),
(
f"{self._render_open_flow_prompt(user_id=user_id, domain=active_domain)}\n{queue_hint}"
if queue_hint
else self._render_open_flow_prompt(user_id=user_id, domain=active_domain)
),
)
return message, None, None
if self._has_open_flow(user_id=user_id, domain=active_domain):
queued_count = 0
for queued in extracted_orders:
if queued["domain"] != active_domain:
self._queue_order(user_id=user_id, domain=queued["domain"], order_message=queued["message"])
queued_count += 1
queue_hint = self._render_queue_notice(queued_count)
return (
message,
None,
self._render_open_flow_prompt(user_id=user_id, domain=active_domain),
(
f"{self._render_open_flow_prompt(user_id=user_id, domain=active_domain)}\n{queue_hint}"
if queue_hint
else self._render_open_flow_prompt(user_id=user_id, domain=active_domain)
),
)
first = extracted_orders[0]
queued_count = 0
for queued in extracted_orders[1:]:
self._queue_order(user_id=user_id, domain=queued["domain"], order_message=queued["message"])
queued_count += 1
context["active_domain"] = first["domain"]
queue_notice = None
queue_notice = self._render_queue_notice(queued_count)
return first["message"], queue_notice, None
def _compose_order_aware_response(self, response: str, user_id: int | None, queue_notice: str | None = None) -> str:
@ -723,6 +898,13 @@ class OrquestradorService:
lines.append(response)
return "\n".join(lines)
def _render_queue_notice(self, queued_count: int) -> str | None:
if queued_count <= 0:
return None
if queued_count == 1:
return "Anotei mais 1 pedido e sigo nele quando voce disser 'continuar'."
return f"Anotei mais {queued_count} pedidos e sigo neles conforme voce for dizendo 'continuar'."
def _render_open_flow_prompt(self, user_id: int | None, domain: str) -> str:
if domain == "review" and user_id is not None:
draft = self.PENDING_REVIEW_DRAFTS.get(user_id)
@ -731,9 +913,25 @@ class OrquestradorService:
if missing:
return self._render_missing_review_fields_prompt(missing)
management_draft = self.PENDING_REVIEW_MANAGEMENT_DRAFTS.get(user_id)
if management_draft:
action = management_draft.get("action", "cancel")
payload = management_draft.get("payload", {})
if action == "reschedule":
missing = [field for field in ("protocolo", "nova_data_hora") if field not in payload]
if missing:
return self._render_missing_review_reschedule_fields_prompt(missing)
else:
missing = [field for field in ("protocolo",) if field not in payload]
if missing:
return self._render_missing_review_cancel_fields_prompt(missing)
pending = self.PENDING_REVIEW_CONFIRMATIONS.get(user_id)
if pending:
return "Antes de mudar de assunto, me confirme se podemos concluir seu agendamento de revisao."
reuse_pending = self.PENDING_REVIEW_REUSE_CONFIRMATIONS.get(user_id)
if reuse_pending:
return self._render_review_reuse_question()
if domain == "sales" and user_id is not None:
draft = self.PENDING_ORDER_DRAFTS.get(user_id)
if draft:
@ -771,16 +969,28 @@ class OrquestradorService:
if not next_order:
return base_response
context["active_domain"] = next_order["domain"]
context["generic_memory"] = dict(next_order.get("memory_seed") or self._new_tab_memory(user_id=user_id))
context["pending_switch"] = None
next_response = await self.handle_message(next_order["message"], user_id=user_id)
context["pending_switch"] = {
"source_domain": context.get("active_domain", "general"),
"target_domain": next_order["domain"],
"queued_message": next_order["message"],
"memory_seed": dict(next_order.get("memory_seed") or self._new_tab_memory(user_id=user_id)),
"expires_at": datetime.utcnow() + timedelta(minutes=15),
}
transition = self._build_next_order_transition(next_order["domain"])
return f"{base_response}\n\n{transition}\n{next_response}"
return (
f"{base_response}\n\n"
f"{transition}\n"
"Tenho um proximo pedido na fila. Quando quiser, diga 'continuar' para eu seguir nele."
)
def _domain_from_intents(self, intents: dict | None) -> str:
normalized = self._normalize_intents(intents)
review_score = int(normalized.get("review_schedule", False)) + int(normalized.get("review_list", False))
review_score = (
int(normalized.get("review_schedule", False))
+ int(normalized.get("review_list", False))
+ int(normalized.get("review_cancel", False))
+ int(normalized.get("review_reschedule", False))
)
sales_score = int(normalized.get("order_create", False)) + int(normalized.get("order_cancel", False))
if review_score > sales_score and review_score > 0:
return "review"
@ -791,6 +1001,43 @@ class OrquestradorService:
def _is_context_switch_confirmation(self, message: str) -> bool:
return self._is_affirmative_message(message) or self._is_negative_message(message)
def _is_continue_queue_message(self, message: str) -> bool:
normalized = self._normalize_text(message).strip()
normalized = re.sub(r"[.!?,;:]+$", "", normalized)
return normalized in {"continuar", "pode continuar", "seguir", "pode seguir", "proximo", "segue"}
async def _try_continue_queued_order(self, message: str, user_id: int | None) -> str | None:
context = self._get_user_context(user_id)
if not context:
return None
pending_switch = context.get("pending_switch")
if not isinstance(pending_switch, dict):
return None
if pending_switch.get("expires_at") and pending_switch["expires_at"] < datetime.utcnow():
context["pending_switch"] = None
return None
queued_message = str(pending_switch.get("queued_message") or "").strip()
if not queued_message:
return None
if self._is_negative_message(message):
context["pending_switch"] = None
return "Tudo bem. Mantive o proximo pedido fora da fila por enquanto."
if not (self._is_continue_queue_message(message) or self._is_affirmative_message(message)):
return None
target_domain = str(pending_switch.get("target_domain") or "general")
memory_seed = dict(pending_switch.get("memory_seed") or {})
self._apply_domain_switch(user_id=user_id, target_domain=target_domain)
refreshed = self._get_user_context(user_id)
if refreshed is not None:
refreshed["generic_memory"] = memory_seed
transition = self._build_next_order_transition(target_domain)
next_response = await self.handle_message(queued_message, user_id=user_id)
return f"{transition}\n{next_response}"
def _has_open_flow(self, user_id: int | None, domain: str) -> bool:
if user_id is None:
return False
@ -798,6 +1045,8 @@ class OrquestradorService:
return bool(
self.PENDING_REVIEW_DRAFTS.get(user_id)
or self.PENDING_REVIEW_CONFIRMATIONS.get(user_id)
or self.PENDING_REVIEW_MANAGEMENT_DRAFTS.get(user_id)
or self.PENDING_REVIEW_REUSE_CONFIRMATIONS.get(user_id)
)
if domain == "sales":
return bool(
@ -913,25 +1162,107 @@ class OrquestradorService:
self,
message: str,
user_id: int | None,
extracted_fields: dict | None = None,
intents: dict | None = None,
) -> str | None:
if user_id is None:
return None
normalized_intents = self._normalize_intents(intents)
if not normalized_intents.get("review_list", False):
draft = self.PENDING_REVIEW_MANAGEMENT_DRAFTS.get(user_id)
if draft and draft["expires_at"] < datetime.utcnow():
self.PENDING_REVIEW_MANAGEMENT_DRAFTS.pop(user_id, None)
draft = None
has_list_intent = normalized_intents.get("review_list", False)
has_cancel_intent = normalized_intents.get("review_cancel", False)
has_reschedule_intent = normalized_intents.get("review_reschedule", False)
if has_list_intent:
# Listagem e acao terminal; limpa rascunhos de revisao para evitar conflito de contexto.
self._reset_pending_review_states(user_id=user_id)
try:
tool_result = await self.registry.execute(
"listar_agendamentos_revisao",
{"limite": 20},
user_id=user_id,
)
except HTTPException as exc:
return self._http_exception_detail(exc)
return self._fallback_format_tool_result("listar_agendamentos_revisao", tool_result)
if not has_cancel_intent and not has_reschedule_intent and draft is None:
return None
# Se o usuario pediu listagem, encerramos coleta pendente para nao competir com o fluxo.
self._reset_pending_review_states(user_id=user_id)
if draft is None:
action = "reschedule" if has_reschedule_intent else "cancel"
draft = {
"action": action,
"payload": {},
"expires_at": datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES),
}
else:
if has_reschedule_intent:
draft["action"] = "reschedule"
elif has_cancel_intent:
draft["action"] = "cancel"
extracted = self._normalize_review_management_fields(extracted_fields)
if "protocolo" not in extracted:
inferred_protocol = self._extract_review_protocol_from_text(message)
if inferred_protocol:
extracted["protocolo"] = inferred_protocol
action = draft.get("action", "cancel")
if (
action == "cancel"
and "motivo" not in extracted
and draft["payload"].get("protocolo")
and not has_cancel_intent
):
free_text = str(message or "").strip()
if free_text and len(free_text) >= 4 and not self._is_affirmative_message(free_text):
extracted["motivo"] = free_text
draft["payload"].update(extracted)
draft["expires_at"] = datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES)
self.PENDING_REVIEW_MANAGEMENT_DRAFTS[user_id] = draft
if action == "reschedule":
missing = [field for field in ("protocolo", "nova_data_hora") if field not in draft["payload"]]
if missing:
return self._render_missing_review_reschedule_fields_prompt(missing)
try:
tool_result = await self.registry.execute(
"editar_data_revisao",
{
"protocolo": draft["payload"]["protocolo"],
"nova_data_hora": draft["payload"]["nova_data_hora"],
},
user_id=user_id,
)
except HTTPException as exc:
return self._http_exception_detail(exc)
finally:
self.PENDING_REVIEW_MANAGEMENT_DRAFTS.pop(user_id, None)
return self._fallback_format_tool_result("editar_data_revisao", tool_result)
missing = [field for field in ("protocolo",) if field not in draft["payload"]]
if missing:
return self._render_missing_review_cancel_fields_prompt(missing)
try:
tool_result = await self.registry.execute(
"listar_agendamentos_revisao",
{"limite": 20},
"cancelar_agendamento_revisao",
{
"protocolo": draft["payload"]["protocolo"],
"motivo": draft["payload"].get("motivo"),
},
user_id=user_id,
)
except HTTPException as exc:
return self._http_exception_detail(exc)
return self._fallback_format_tool_result("listar_agendamentos_revisao", tool_result)
finally:
self.PENDING_REVIEW_MANAGEMENT_DRAFTS.pop(user_id, None)
return self._fallback_format_tool_result("cancelar_agendamento_revisao", tool_result)
def _render_missing_review_fields_prompt(self, missing_fields: list[str]) -> str:
labels = {
@ -945,6 +1276,58 @@ class OrquestradorService:
itens = [f"- {labels[field]}" for field in missing_fields]
return "Para agendar sua revisao, preciso dos dados abaixo:\n" + "\n".join(itens)
def _render_missing_review_cancel_fields_prompt(self, missing_fields: list[str]) -> str:
labels = {
"protocolo": "o protocolo da revisao (ex.: REV-20260310-ABC12345)",
}
itens = [f"- {labels[field]}" for field in missing_fields]
return "Para cancelar o agendamento de revisao, preciso dos dados abaixo:\n" + "\n".join(itens)
def _render_missing_review_reschedule_fields_prompt(self, missing_fields: list[str]) -> str:
labels = {
"protocolo": "o protocolo da revisao (ex.: REV-20260310-ABC12345)",
"nova_data_hora": "a nova data e hora desejada para a revisao",
}
itens = [f"- {labels[field]}" for field in missing_fields]
return "Para remarcar sua revisao, preciso dos dados abaixo:\n" + "\n".join(itens)
def _render_review_reuse_question(self) -> str:
return (
"Deseja usar os mesmos dados do ultimo veiculo e informar so a data/hora da revisao? "
"(sim/nao)"
)
def _store_last_review_package(self, user_id: int | None, payload: dict | None) -> None:
if user_id is None or not isinstance(payload, dict):
return
package = {
"placa": payload.get("placa"),
"modelo": payload.get("modelo"),
"ano": payload.get("ano"),
"km": payload.get("km"),
"revisao_previa_concessionaria": payload.get("revisao_previa_concessionaria"),
}
sanitized = {k: v for k, v in package.items() if v is not None}
required = {"placa", "modelo", "ano", "km", "revisao_previa_concessionaria"}
if not required.issubset(sanitized.keys()):
return
self.LAST_REVIEW_PACKAGES[user_id] = {
"payload": sanitized,
"expires_at": datetime.utcnow() + timedelta(minutes=LAST_REVIEW_PACKAGE_TTL_MINUTES),
}
def _get_last_review_package(self, user_id: int | None) -> dict | None:
if user_id is None:
return None
cached = self.LAST_REVIEW_PACKAGES.get(user_id)
if not cached:
return None
if cached["expires_at"] < datetime.utcnow():
self.LAST_REVIEW_PACKAGES.pop(user_id, None)
return None
payload = cached.get("payload")
return dict(payload) if isinstance(payload, dict) else None
def _is_valid_cpf(self, cpf: str) -> bool:
digits = re.sub(r"\D", "", cpf or "")
if len(digits) != 11:
@ -1008,13 +1391,18 @@ class OrquestradorService:
normalized_intents = self._normalize_intents(intents)
has_intent = normalized_intents.get("review_schedule", False)
has_management_intent = normalized_intents.get("review_list", False)
has_management_intent = (
normalized_intents.get("review_list", False)
or normalized_intents.get("review_cancel", False)
or normalized_intents.get("review_reschedule", False)
)
# Nao inicia slot-filling quando a intencao atual nao e de agendamento.
if has_management_intent:
# Se o usuario mudou para gerenciamento de revisao, encerra
# qualquer coleta pendente de novo agendamento.
self.PENDING_REVIEW_DRAFTS.pop(user_id, None)
self.PENDING_REVIEW_REUSE_CONFIRMATIONS.pop(user_id, None)
return None
# Reaproveita rascunho anterior do usuario, se ainda estiver valido.
@ -1024,6 +1412,45 @@ class OrquestradorService:
draft = None
extracted = self._normalize_review_fields(extracted_fields)
pending_reuse = self.PENDING_REVIEW_REUSE_CONFIRMATIONS.get(user_id)
if pending_reuse and pending_reuse["expires_at"] < datetime.utcnow():
self.PENDING_REVIEW_REUSE_CONFIRMATIONS.pop(user_id, None)
pending_reuse = None
if pending_reuse:
should_reuse = False
if self._is_negative_message(message):
self.PENDING_REVIEW_REUSE_CONFIRMATIONS.pop(user_id, None)
pending_reuse = None
elif self._is_affirmative_message(message) or "data_hora" in extracted:
should_reuse = True
else:
return self._render_review_reuse_question()
if should_reuse:
seed_payload = dict(pending_reuse.get("payload") or {})
if draft is None:
draft = {
"payload": seed_payload,
"expires_at": datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES),
}
else:
for key, value in seed_payload.items():
draft["payload"].setdefault(key, value)
self.PENDING_REVIEW_REUSE_CONFIRMATIONS.pop(user_id, None)
pending_reuse = None
if "data_hora" not in extracted:
self.PENDING_REVIEW_DRAFTS[user_id] = draft
return "Perfeito. Me informe apenas a data e hora desejada para a revisao."
if has_intent and draft is None and not extracted:
last_package = self._get_last_review_package(user_id=user_id)
if last_package:
self.PENDING_REVIEW_REUSE_CONFIRMATIONS[user_id] = {
"payload": last_package,
"expires_at": datetime.utcnow() + timedelta(minutes=PENDING_REVIEW_DRAFT_TTL_MINUTES),
}
return self._render_review_reuse_question()
# Se houver rascunho de revisao, mas o usuario mudou para outra
# intencao operacional (ex.: compra/estoque), descarta o rascunho.
@ -1088,6 +1515,7 @@ class OrquestradorService:
# Limpa o rascunho apos tentativa final para evitar estado sujo.
self.PENDING_REVIEW_DRAFTS.pop(user_id, None)
self._store_last_review_package(user_id=user_id, payload=draft["payload"])
return self._fallback_format_tool_result("agendar_revisao", tool_result)
async def _try_collect_and_create_order(
@ -1115,6 +1543,8 @@ class OrquestradorService:
and (
normalized_intents.get("review_schedule", False)
or normalized_intents.get("review_list", False)
or normalized_intents.get("review_cancel", False)
or normalized_intents.get("review_reschedule", False)
or normalized_intents.get("order_cancel", False)
)
and not extracted
@ -1196,6 +1626,8 @@ class OrquestradorService:
and (
normalized_intents.get("review_schedule", False)
or normalized_intents.get("review_list", False)
or normalized_intents.get("review_cancel", False)
or normalized_intents.get("review_reschedule", False)
or normalized_intents.get("order_create", False)
)
and not extracted
@ -1339,6 +1771,7 @@ class OrquestradorService:
return self._http_exception_detail(exc)
self.PENDING_REVIEW_CONFIRMATIONS.pop(user_id, None)
self._store_last_review_package(user_id=user_id, payload=payload)
return self._fallback_format_tool_result("agendar_revisao", tool_result)
if not self._is_affirmative_message(message):
@ -1358,6 +1791,7 @@ class OrquestradorService:
return self._http_exception_detail(exc)
self.PENDING_REVIEW_CONFIRMATIONS.pop(user_id, None)
self._store_last_review_package(user_id=user_id, payload=pending.get("payload"))
return self._fallback_format_tool_result("agendar_revisao", tool_result)
def _build_router_prompt(self, user_message: str, user_id: int | None) -> str:

Loading…
Cancel
Save