From a6f1358c2837c63054aa9789e9cbf129ba4c3498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vitor=20Hugo=20Belorio=20Sim=C3=A3o?= Date: Thu, 5 Mar 2026 17:49:29 -0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=A0=20feat(orquestrador):=20fortalecer?= =?UTF-8?q?=20contexto=20multiassunto=20e=20gest=C3=A3o=20de=20revis=C3=A3?= =?UTF-8?q?o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/services/orchestrator_config.py | 1 + app/services/orquestrador_service.py | 522 ++++++++++++++++++++++++--- 2 files changed, 479 insertions(+), 44 deletions(-) diff --git a/app/services/orchestrator_config.py b/app/services/orchestrator_config.py index 9204ea7..afd219b 100644 --- a/app/services/orchestrator_config.py +++ b/app/services/orchestrator_config.py @@ -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 diff --git a/app/services/orquestrador_service.py b/app/services/orquestrador_service.py index 2fbbc79..1d98d0a 100644 --- a/app/services/orquestrador_service.py +++ b/app/services/orquestrador_service.py @@ -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: