🧠 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_TTL_MINUTES = 30
PENDING_REVIEW_DRAFT_TTL_MINUTES = 30 PENDING_REVIEW_DRAFT_TTL_MINUTES = 30
LAST_REVIEW_PACKAGE_TTL_MINUTES = 20
PENDING_ORDER_DRAFT_TTL_MINUTES = 30 PENDING_ORDER_DRAFT_TTL_MINUTES = 30
PENDING_CANCEL_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, CANCEL_ORDER_REQUIRED_FIELDS,
DETERMINISTIC_RESPONSE_TOOLS, DETERMINISTIC_RESPONSE_TOOLS,
LOW_VALUE_RESPONSES, LOW_VALUE_RESPONSES,
LAST_REVIEW_PACKAGE_TTL_MINUTES,
ORDER_REQUIRED_FIELDS, ORDER_REQUIRED_FIELDS,
PENDING_CANCEL_ORDER_DRAFT_TTL_MINUTES, PENDING_CANCEL_ORDER_DRAFT_TTL_MINUTES,
PENDING_ORDER_DRAFT_TTL_MINUTES, PENDING_ORDER_DRAFT_TTL_MINUTES,
@ -32,6 +33,9 @@ class OrquestradorService:
PENDING_REVIEW_CONFIRMATIONS: dict[int, dict] = {} PENDING_REVIEW_CONFIRMATIONS: dict[int, dict] = {}
# Rascunho por usuario para juntar dados de revisao enviados em mensagens separadas. # Rascunho por usuario para juntar dados de revisao enviados em mensagens separadas.
PENDING_REVIEW_DRAFTS: dict[int, dict] = {} 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_ORDER_DRAFTS: dict[int, dict] = {}
PENDING_CANCEL_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) 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, routing_message,
@ -69,10 +88,15 @@ class OrquestradorService:
if queue_early_response: if queue_early_response:
return await finish(queue_early_response, queue_notice=queue_notice) return await finish(queue_early_response, queue_notice=queue_notice)
extracted_entities = await self._extract_entities_with_llm( extracted_entities = self._resolve_entities_for_message_plan(
message=routing_message, message_plan=message_plan,
user_id=user_id, 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( self._capture_generic_memory(
user_id=user_id, user_id=user_id,
llm_generic_fields=extracted_entities.get("generic_memory", {}), llm_generic_fields=extracted_entities.get("generic_memory", {}),
@ -92,6 +116,7 @@ class OrquestradorService:
review_management_response = await self._try_handle_review_management( review_management_response = await self._try_handle_review_management(
message=routing_message, message=routing_message,
user_id=user_id, user_id=user_id,
extracted_fields=extracted_entities.get("review_management_fields", {}),
intents=extracted_entities.get("intents", {}), intents=extracted_entities.get("intents", {}),
) )
if review_management_response: if review_management_response:
@ -142,7 +167,13 @@ class OrquestradorService:
tools=tools, 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( llm_result = await self.llm.generate_response(
message=self._build_force_tool_prompt(user_message=routing_message, user_id=user_id), message=self._build_force_tool_prompt(user_message=routing_message, user_id=user_id),
tools=tools, tools=tools,
@ -207,6 +238,8 @@ class OrquestradorService:
return return
self.PENDING_REVIEW_DRAFTS.pop(user_id, None) self.PENDING_REVIEW_DRAFTS.pop(user_id, None)
self.PENDING_REVIEW_CONFIRMATIONS.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: def _reset_pending_order_states(self, user_id: int | None) -> None:
if user_id is None: if user_id is None:
@ -293,11 +326,54 @@ class OrquestradorService:
return { return {
"generic_memory": {}, "generic_memory": {},
"review_fields": {}, "review_fields": {},
"review_management_fields": {},
"order_fields": {}, "order_fields": {},
"cancel_order_fields": {}, "cancel_order_fields": {},
"intents": {}, "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: def _coerce_extraction_contract(self, payload) -> dict:
if not isinstance(payload, dict): if not isinstance(payload, dict):
return self._empty_extraction_payload() return self._empty_extraction_payload()
@ -309,14 +385,25 @@ class OrquestradorService:
logger.info("Extracao sem secao '%s'; usando vazio.", key) logger.info("Extracao sem secao '%s'; usando vazio.", key)
return contract 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 = ( 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" "Sem markdown e sem texto extra.\n\n"
"Formato:\n" "Formato:\n"
"{\n" "{\n"
' "orders": [\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\n" "}\n\n"
"Regras:\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"Contexto: user_id={user_id if user_id is not None else 'anonimo'}\n"
f"Mensagem do usuario: {message}" f"Mensagem do usuario: {message}"
) )
default = {"orders": [{"domain": "general", "message": (message or "").strip()}]} default = self._empty_message_plan(message=message)
try: try:
result = await self.llm.generate_response(message=prompt, tools=[]) result = await self.llm.generate_response(message=prompt, tools=[])
text = (result.get("response") or "").strip() text = (result.get("response") or "").strip()
payload = self._parse_json_object(text) payload = self._parse_json_object(text)
if not isinstance(payload, dict): 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 return default
orders = payload.get("orders") return self._coerce_message_plan(payload=payload, message=message)
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}
except Exception: 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 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: 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" user_context = f"user_id={user_id}" if user_id is not None else "user_id=anonimo"
prompt = ( prompt = (
@ -376,6 +460,11 @@ class OrquestradorService:
' "km": null,\n' ' "km": null,\n'
' "revisao_previa_concessionaria": null\n' ' "revisao_previa_concessionaria": null\n'
" },\n" " },\n"
' "review_management_fields": {\n'
' "protocolo": null,\n'
' "nova_data_hora": null,\n'
' "motivo": null\n'
" },\n"
' "order_fields": {\n' ' "order_fields": {\n'
' "cpf": null,\n' ' "cpf": null,\n'
' "valor_veiculo": null\n' ' "valor_veiculo": null\n'
@ -387,6 +476,8 @@ class OrquestradorService:
' "intents": {\n' ' "intents": {\n'
' "review_schedule": false,\n' ' "review_schedule": false,\n'
' "review_list": false,\n' ' "review_list": false,\n'
' "review_cancel": false,\n'
' "review_reschedule": false,\n'
' "order_create": false,\n' ' "order_create": false,\n'
' "order_cancel": false\n' ' "order_cancel": false\n'
" }\n" " }\n"
@ -410,6 +501,7 @@ class OrquestradorService:
return { return {
"generic_memory": self._normalize_generic_fields(coerced.get("generic_memory")), "generic_memory": self._normalize_generic_fields(coerced.get("generic_memory")),
"review_fields": self._normalize_review_fields(coerced.get("review_fields")), "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")), "order_fields": self._normalize_order_fields(coerced.get("order_fields")),
"cancel_order_fields": self._normalize_cancel_order_fields(coerced.get("cancel_order_fields")), "cancel_order_fields": self._normalize_cancel_order_fields(coerced.get("cancel_order_fields")),
"intents": self._normalize_intents(coerced.get("intents")), "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) logger.exception("Falha ao extrair entidades com LLM. user_id=%s", user_id)
return default 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): def _parse_json_object(self, text: str):
candidate = (text or "").strip() candidate = (text or "").strip()
if not candidate: if not candidate:
@ -573,6 +703,35 @@ class OrquestradorService:
extracted["revisao_previa_concessionaria"] = reviewed extracted["revisao_previa_concessionaria"] = reviewed
return extracted 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: def _normalize_order_fields(self, data) -> dict:
if not isinstance(data, dict): if not isinstance(data, dict):
return {} return {}
@ -603,6 +762,8 @@ class OrquestradorService:
return { return {
"review_schedule": bool(self._normalize_bool(data.get("review_schedule"))), "review_schedule": bool(self._normalize_bool(data.get("review_schedule"))),
"review_list": bool(self._normalize_bool(data.get("review_list"))), "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_create": bool(self._normalize_bool(data.get("order_create"))),
"order_cancel": bool(self._normalize_bool(data.get("order_cancel"))), "order_cancel": bool(self._normalize_bool(data.get("order_cancel"))),
} }
@ -615,7 +776,7 @@ class OrquestradorService:
return True return True
return any( return any(
bool(extracted_entities.get(key)) 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: 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) and self._has_open_flow(user_id=user_id, domain=active_domain)
): ):
self._queue_order(user_id=user_id, domain=inferred, order_message=message) self._queue_order(user_id=user_id, domain=inferred, order_message=message)
queue_hint = self._render_queue_notice(1)
return ( return (
message, message,
None, 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 return message, None, None
if self._has_open_flow(user_id=user_id, domain=active_domain): if self._has_open_flow(user_id=user_id, domain=active_domain):
queued_count = 0
for queued in extracted_orders: for queued in extracted_orders:
if queued["domain"] != active_domain: if queued["domain"] != active_domain:
self._queue_order(user_id=user_id, domain=queued["domain"], order_message=queued["message"]) 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 ( return (
message, message,
None, 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] first = extracted_orders[0]
queued_count = 0
for queued in extracted_orders[1:]: for queued in extracted_orders[1:]:
self._queue_order(user_id=user_id, domain=queued["domain"], order_message=queued["message"]) self._queue_order(user_id=user_id, domain=queued["domain"], order_message=queued["message"])
queued_count += 1
context["active_domain"] = first["domain"] context["active_domain"] = first["domain"]
queue_notice = None queue_notice = self._render_queue_notice(queued_count)
return first["message"], queue_notice, None return first["message"], queue_notice, None
def _compose_order_aware_response(self, response: str, user_id: int | None, queue_notice: str | None = None) -> str: 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) lines.append(response)
return "\n".join(lines) 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: def _render_open_flow_prompt(self, user_id: int | None, domain: str) -> str:
if domain == "review" and user_id is not None: if domain == "review" and user_id is not None:
draft = self.PENDING_REVIEW_DRAFTS.get(user_id) draft = self.PENDING_REVIEW_DRAFTS.get(user_id)
@ -731,9 +913,25 @@ class OrquestradorService:
if missing: if missing:
return self._render_missing_review_fields_prompt(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) pending = self.PENDING_REVIEW_CONFIRMATIONS.get(user_id)
if pending: if pending:
return "Antes de mudar de assunto, me confirme se podemos concluir seu agendamento de revisao." 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: if domain == "sales" and user_id is not None:
draft = self.PENDING_ORDER_DRAFTS.get(user_id) draft = self.PENDING_ORDER_DRAFTS.get(user_id)
if draft: if draft:
@ -771,16 +969,28 @@ class OrquestradorService:
if not next_order: if not next_order:
return base_response return base_response
context["active_domain"] = next_order["domain"] context["pending_switch"] = {
context["generic_memory"] = dict(next_order.get("memory_seed") or self._new_tab_memory(user_id=user_id)) "source_domain": context.get("active_domain", "general"),
context["pending_switch"] = None "target_domain": next_order["domain"],
next_response = await self.handle_message(next_order["message"], user_id=user_id) "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"]) 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: def _domain_from_intents(self, intents: dict | None) -> str:
normalized = self._normalize_intents(intents) 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)) sales_score = int(normalized.get("order_create", False)) + int(normalized.get("order_cancel", False))
if review_score > sales_score and review_score > 0: if review_score > sales_score and review_score > 0:
return "review" return "review"
@ -791,6 +1001,43 @@ class OrquestradorService:
def _is_context_switch_confirmation(self, message: str) -> bool: def _is_context_switch_confirmation(self, message: str) -> bool:
return self._is_affirmative_message(message) or self._is_negative_message(message) 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: def _has_open_flow(self, user_id: int | None, domain: str) -> bool:
if user_id is None: if user_id is None:
return False return False
@ -798,6 +1045,8 @@ class OrquestradorService:
return bool( return bool(
self.PENDING_REVIEW_DRAFTS.get(user_id) self.PENDING_REVIEW_DRAFTS.get(user_id)
or self.PENDING_REVIEW_CONFIRMATIONS.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": if domain == "sales":
return bool( return bool(
@ -913,25 +1162,107 @@ class OrquestradorService:
self, self,
message: str, message: str,
user_id: int | None, user_id: int | None,
extracted_fields: dict | None = None,
intents: dict | None = None, intents: dict | None = None,
) -> str | None: ) -> str | None:
if user_id is None: if user_id is None:
return None return None
normalized_intents = self._normalize_intents(intents) 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 return None
# Se o usuario pediu listagem, encerramos coleta pendente para nao competir com o fluxo. if draft is None:
self._reset_pending_review_states(user_id=user_id) 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: try:
tool_result = await self.registry.execute( tool_result = await self.registry.execute(
"listar_agendamentos_revisao", "cancelar_agendamento_revisao",
{"limite": 20}, {
"protocolo": draft["payload"]["protocolo"],
"motivo": draft["payload"].get("motivo"),
},
user_id=user_id, user_id=user_id,
) )
except HTTPException as exc: except HTTPException as exc:
return self._http_exception_detail(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: def _render_missing_review_fields_prompt(self, missing_fields: list[str]) -> str:
labels = { labels = {
@ -945,6 +1276,58 @@ class OrquestradorService:
itens = [f"- {labels[field]}" for field in missing_fields] itens = [f"- {labels[field]}" for field in missing_fields]
return "Para agendar sua revisao, preciso dos dados abaixo:\n" + "\n".join(itens) 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: def _is_valid_cpf(self, cpf: str) -> bool:
digits = re.sub(r"\D", "", cpf or "") digits = re.sub(r"\D", "", cpf or "")
if len(digits) != 11: if len(digits) != 11:
@ -1008,13 +1391,18 @@ class OrquestradorService:
normalized_intents = self._normalize_intents(intents) normalized_intents = self._normalize_intents(intents)
has_intent = normalized_intents.get("review_schedule", False) 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. # Nao inicia slot-filling quando a intencao atual nao e de agendamento.
if has_management_intent: if has_management_intent:
# Se o usuario mudou para gerenciamento de revisao, encerra # Se o usuario mudou para gerenciamento de revisao, encerra
# qualquer coleta pendente de novo agendamento. # qualquer coleta pendente de novo agendamento.
self.PENDING_REVIEW_DRAFTS.pop(user_id, None) self.PENDING_REVIEW_DRAFTS.pop(user_id, None)
self.PENDING_REVIEW_REUSE_CONFIRMATIONS.pop(user_id, None)
return None return None
# Reaproveita rascunho anterior do usuario, se ainda estiver valido. # Reaproveita rascunho anterior do usuario, se ainda estiver valido.
@ -1024,6 +1412,45 @@ class OrquestradorService:
draft = None draft = None
extracted = self._normalize_review_fields(extracted_fields) 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 # Se houver rascunho de revisao, mas o usuario mudou para outra
# intencao operacional (ex.: compra/estoque), descarta o rascunho. # intencao operacional (ex.: compra/estoque), descarta o rascunho.
@ -1088,6 +1515,7 @@ class OrquestradorService:
# Limpa o rascunho apos tentativa final para evitar estado sujo. # Limpa o rascunho apos tentativa final para evitar estado sujo.
self.PENDING_REVIEW_DRAFTS.pop(user_id, None) 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) return self._fallback_format_tool_result("agendar_revisao", tool_result)
async def _try_collect_and_create_order( async def _try_collect_and_create_order(
@ -1115,6 +1543,8 @@ class OrquestradorService:
and ( and (
normalized_intents.get("review_schedule", False) normalized_intents.get("review_schedule", False)
or normalized_intents.get("review_list", 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) or normalized_intents.get("order_cancel", False)
) )
and not extracted and not extracted
@ -1196,6 +1626,8 @@ class OrquestradorService:
and ( and (
normalized_intents.get("review_schedule", False) normalized_intents.get("review_schedule", False)
or normalized_intents.get("review_list", 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) or normalized_intents.get("order_create", False)
) )
and not extracted and not extracted
@ -1339,6 +1771,7 @@ class OrquestradorService:
return self._http_exception_detail(exc) return self._http_exception_detail(exc)
self.PENDING_REVIEW_CONFIRMATIONS.pop(user_id, None) 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) return self._fallback_format_tool_result("agendar_revisao", tool_result)
if not self._is_affirmative_message(message): if not self._is_affirmative_message(message):
@ -1358,6 +1791,7 @@ class OrquestradorService:
return self._http_exception_detail(exc) return self._http_exception_detail(exc)
self.PENDING_REVIEW_CONFIRMATIONS.pop(user_id, None) 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) return self._fallback_format_tool_result("agendar_revisao", tool_result)
def _build_router_prompt(self, user_message: str, user_id: int | None) -> str: def _build_router_prompt(self, user_message: str, user_id: int | None) -> str:

Loading…
Cancel
Save