You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
800 lines
36 KiB
Python
800 lines
36 KiB
Python
import re
|
|
from datetime import datetime, timedelta
|
|
from typing import TYPE_CHECKING
|
|
|
|
from app.services.orchestration.orchestrator_config import (
|
|
CANCEL_ORDER_REQUIRED_FIELDS,
|
|
ORDER_REQUIRED_FIELDS,
|
|
PENDING_ORDER_SELECTION_TTL_MINUTES,
|
|
REVIEW_REQUIRED_FIELDS,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from app.services.orchestration.orquestrador_service import OrquestradorService
|
|
|
|
# essa classe é responsável por controlar qual o assunto está ativo na conversa, se existe fluxo aberto, se o usuário mandou dois pedidos ao mesmo tempo...
|
|
class ConversationPolicy:
|
|
def __init__(self, service: "OrquestradorService"):
|
|
self.service = service
|
|
|
|
def _save_context(self, user_id: int | None, context: dict | None) -> None:
|
|
if user_id is None or not isinstance(context, dict):
|
|
return
|
|
self.service._save_user_context(user_id=user_id, context=context)
|
|
|
|
def _decision_action(self, turn_decision: dict | None) -> str:
|
|
return str((turn_decision or {}).get("action") or "").strip().lower()
|
|
|
|
def _decision_intent(self, turn_decision: dict | None) -> str:
|
|
return str((turn_decision or {}).get("intent") or "").strip().lower()
|
|
|
|
def _decision_domain(self, turn_decision: dict | None) -> str:
|
|
return str((turn_decision or {}).get("domain") or "").strip().lower()
|
|
|
|
def _decision_selection_index(self, turn_decision: dict | None) -> int | None:
|
|
value = (turn_decision or {}).get("selection_index")
|
|
return value if isinstance(value, int) and value >= 0 else None
|
|
|
|
# Essa função serve para reaproveitar informações já informadas antes, evitando pedir novamente ao usuário.
|
|
def try_prefill_review_fields_from_memory(self, user_id: int | None, payload: dict) -> None:
|
|
if user_id is None:
|
|
return
|
|
context = self.service._get_user_context(user_id)
|
|
if not context:
|
|
return
|
|
memory = context.get("generic_memory", {})
|
|
if payload.get("placa") is None:
|
|
plate = self.service.normalizer.normalize_plate(memory.get("placa"))
|
|
if plate:
|
|
payload["placa"] = plate
|
|
|
|
# Essa função coloca um pedido na fila
|
|
def queue_order(self, user_id: int | None, domain: str, order_message: str) -> None:
|
|
self.queue_order_with_memory_seed(
|
|
user_id=user_id,
|
|
domain=domain,
|
|
order_message=order_message,
|
|
memory_seed=self.service._new_tab_memory(user_id=user_id),
|
|
)
|
|
|
|
# Enfileira um próximo assunto para ser tratado depois, preservando dados úteis daquele pedido
|
|
def queue_order_with_memory_seed(
|
|
self,
|
|
user_id: int | None,
|
|
domain: str,
|
|
order_message: str,
|
|
memory_seed: dict | None = None,
|
|
) -> None:
|
|
context = self.service._get_user_context(user_id)
|
|
if not context or domain == "general":
|
|
return
|
|
queue = context.setdefault("order_queue", [])
|
|
queue.append(
|
|
{
|
|
"domain": domain,
|
|
"message": (order_message or "").strip(),
|
|
"memory_seed": dict(memory_seed or self.service._new_tab_memory(user_id=user_id)),
|
|
"created_at": datetime.utcnow().isoformat(),
|
|
}
|
|
)
|
|
self._save_context(user_id=user_id, context=context)
|
|
|
|
|
|
# Transforma as entidades extraídas de um pedido em uma memória temporária pronta para usar quando esse pedido for processado.
|
|
def build_order_memory_seed(self, user_id: int | None, order: dict | None = None) -> dict:
|
|
seed = dict(self.service._new_tab_memory(user_id=user_id))
|
|
if not isinstance(order, dict):
|
|
return seed
|
|
|
|
entities = order.get("entities")
|
|
if not isinstance(entities, dict):
|
|
return seed
|
|
|
|
generic_memory = self.service.normalizer.normalize_generic_fields(entities.get("generic_memory"))
|
|
if generic_memory:
|
|
seed.update(generic_memory)
|
|
|
|
review_fields = self.service.normalizer.normalize_review_fields(entities.get("review_fields"))
|
|
if review_fields.get("placa") and not seed.get("placa"):
|
|
seed["placa"] = review_fields["placa"]
|
|
|
|
order_fields = self.service.normalizer.normalize_order_fields(entities.get("order_fields"))
|
|
if order_fields.get("cpf") and not seed.get("cpf"):
|
|
seed["cpf"] = order_fields["cpf"]
|
|
if order_fields.get("modelo_veiculo") and not seed.get("modelo_veiculo"):
|
|
seed["modelo_veiculo"] = order_fields["modelo_veiculo"]
|
|
|
|
return seed
|
|
|
|
|
|
# Pega o próximo assunto pendente do usuário.
|
|
def pop_next_order(self, user_id: int | None) -> dict | None:
|
|
context = self.service._get_user_context(user_id)
|
|
if not context:
|
|
return None
|
|
queue = context.setdefault("order_queue", [])
|
|
if not queue:
|
|
return None
|
|
popped = queue.pop(0)
|
|
self._save_context(user_id=user_id, context=context)
|
|
return popped
|
|
|
|
|
|
|
|
# Decide qual pedido deve ser processado agora, qual vai para fila, e se o usuário precisa escolher entre dois pedidos.
|
|
def prepare_message_for_single_order(
|
|
self,
|
|
message: str,
|
|
user_id: int | None,
|
|
routing_plan: dict | None = None,
|
|
) -> tuple[str, str | None, str | None]:
|
|
context = self.service._get_user_context(user_id)
|
|
if not context:
|
|
return message, None, None
|
|
|
|
queue_notice = None
|
|
active_domain = context.get("active_domain", "general")
|
|
|
|
orders_raw = (routing_plan or {}).get("orders") if isinstance(routing_plan, dict) else None
|
|
extracted_orders: list[dict] = []
|
|
if isinstance(orders_raw, list):
|
|
for item in orders_raw:
|
|
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:
|
|
extracted_orders.append(
|
|
{
|
|
"domain": domain,
|
|
"message": segment,
|
|
"entities": self.service._coerce_extraction_contract(item.get("entities")),
|
|
}
|
|
)
|
|
if not extracted_orders:
|
|
extracted_orders = [{"domain": "general", "message": (message or "").strip()}]
|
|
|
|
if (
|
|
len(extracted_orders) == 2
|
|
and all(order["domain"] != "general" for order in extracted_orders)
|
|
and not self.has_open_flow(user_id=user_id, domain=active_domain)
|
|
):
|
|
self.store_pending_order_selection(user_id=user_id, orders=extracted_orders)
|
|
return message, None, self.render_order_selection_prompt(extracted_orders)
|
|
|
|
if len(extracted_orders) <= 1:
|
|
inferred = extracted_orders[0]["domain"]
|
|
if (
|
|
inferred != "general"
|
|
and inferred != 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)
|
|
queue_hint = self.render_queue_notice(1)
|
|
prompt = self.render_open_flow_prompt(user_id=user_id, domain=active_domain)
|
|
return message, None, f"{prompt}\n{queue_hint}" if queue_hint else prompt
|
|
return message, None, 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_with_memory_seed(
|
|
user_id=user_id,
|
|
domain=queued["domain"],
|
|
order_message=queued["message"],
|
|
memory_seed=self.build_order_memory_seed(user_id=user_id, order=queued),
|
|
)
|
|
queued_count += 1
|
|
queue_hint = self.render_queue_notice(queued_count)
|
|
prompt = self.render_open_flow_prompt(user_id=user_id, domain=active_domain)
|
|
return message, None, f"{prompt}\n{queue_hint}" if queue_hint else prompt
|
|
|
|
first = extracted_orders[0]
|
|
queued_count = 0
|
|
for queued in extracted_orders[1:]:
|
|
self.queue_order_with_memory_seed(
|
|
user_id=user_id,
|
|
domain=queued["domain"],
|
|
order_message=queued["message"],
|
|
memory_seed=self.build_order_memory_seed(user_id=user_id, order=queued),
|
|
)
|
|
queued_count += 1
|
|
context["active_domain"] = first["domain"]
|
|
context["generic_memory"] = self.build_order_memory_seed(user_id=user_id, order=first)
|
|
self._save_context(user_id=user_id, context=context)
|
|
|
|
queue_notice = self.render_queue_notice(queued_count)
|
|
return first["message"], queue_notice, None
|
|
|
|
|
|
# Serve para concatenar mensagens auxiliares com a resposta principal.
|
|
def compose_order_aware_response(self, response: str, queue_notice: str | None = None) -> str:
|
|
lines = []
|
|
if queue_notice:
|
|
lines.append(queue_notice)
|
|
lines.append(response)
|
|
return "\n".join(lines)
|
|
|
|
|
|
# Armazena uma “decisão pendente” de qual pedido começar.
|
|
def store_pending_order_selection(self, user_id: int | None, orders: list[dict]) -> None:
|
|
context = self.service._get_user_context(user_id)
|
|
if not context:
|
|
return
|
|
context["pending_order_selection"] = {
|
|
"orders": [
|
|
{
|
|
"domain": order["domain"],
|
|
"message": order["message"],
|
|
"memory_seed": self.build_order_memory_seed(user_id=user_id, order=order),
|
|
}
|
|
for order in orders[:2]
|
|
],
|
|
"expires_at": datetime.utcnow() + timedelta(minutes=PENDING_ORDER_SELECTION_TTL_MINUTES),
|
|
}
|
|
self._save_context(user_id=user_id, context=context)
|
|
|
|
|
|
# Cria o texto de escolha para o usuário.
|
|
def render_order_selection_prompt(self, orders: list[dict]) -> str:
|
|
if len(orders) < 2:
|
|
return "Qual das acoes voce quer iniciar primeiro?"
|
|
first_label = self.describe_order_selection_option(orders[0])
|
|
second_label = self.describe_order_selection_option(orders[1])
|
|
return (
|
|
"Identifiquei duas acoes na sua mensagem:\n"
|
|
f"1. {first_label}\n"
|
|
f"2. {second_label}\n"
|
|
"Qual delas voce quer iniciar primeiro? Se for indiferente, eu escolho."
|
|
)
|
|
|
|
|
|
# Formata o rótulo do pedido para exibição.
|
|
def describe_order_selection_option(self, order: dict) -> str:
|
|
domain = str(order.get("domain") or "general")
|
|
message = str(order.get("message") or "").strip()
|
|
domain_prefix = {
|
|
"review": "Revisao",
|
|
"sales": "Venda",
|
|
"general": "Atendimento",
|
|
}.get(domain, "Atendimento")
|
|
return f"{domain_prefix}: {message}"
|
|
|
|
|
|
# É um helper simples para busca de palavras-chave
|
|
def contains_any_term(self, text: str, terms: set[str]) -> bool:
|
|
return any(term in text for term in terms)
|
|
|
|
|
|
# Limpa a mensagem para facilitar a detecção de escolha
|
|
def strip_choice_message(self, text: str) -> str:
|
|
cleaned = (text or "").strip()
|
|
while cleaned and cleaned[-1] in ".!?,;:":
|
|
cleaned = cleaned[:-1].rstrip()
|
|
return cleaned
|
|
|
|
|
|
# Se o usuário disser algo como: "esquece tudo e quero agendar revisão" a função remove a parte de reset e devolve só o novo pedido.
|
|
def remove_order_selection_reset_prefix(self, message: str) -> str:
|
|
raw = (message or "").strip()
|
|
normalized = self.service.normalizer.normalize_text(raw)
|
|
prefixes = ("esqueca tudo agora", "esqueca tudo", "esquece tudo agora", "esquece tudo")
|
|
for prefix in prefixes:
|
|
if normalized.startswith(prefix):
|
|
return raw[len(prefix):].lstrip(" ,.:;-")
|
|
return raw
|
|
|
|
|
|
# Detecta intenção de abandonar o contexto atual
|
|
def is_order_selection_reset_message(self, message: str) -> bool:
|
|
normalized = self.service.normalizer.normalize_text(message).strip()
|
|
reset_terms = {
|
|
"esqueca tudo",
|
|
"esqueca tudo agora",
|
|
"esquece tudo",
|
|
"esquece tudo agora",
|
|
"ignora isso",
|
|
"ignore isso",
|
|
"deixa isso",
|
|
"deixa pra la",
|
|
"deixa para la",
|
|
"novo assunto",
|
|
"muda de assunto",
|
|
"vamos comecar de novo",
|
|
"comecar de novo",
|
|
"reiniciar",
|
|
"resetar conversa",
|
|
}
|
|
return self.contains_any_term(normalized, reset_terms)
|
|
|
|
|
|
# Ajuda a perceber quando o usuário talvez tenha mudado de assunto sem responder à pergunta de escolha
|
|
def looks_like_fresh_operational_request(self, message: str, turn_decision: dict | None = None) -> bool:
|
|
decision_domain = self._decision_domain(turn_decision)
|
|
decision_intent = self._decision_intent(turn_decision)
|
|
if decision_domain in {"review", "sales"} or decision_intent not in {"", "general"}:
|
|
return True
|
|
normalized = self.service.normalizer.normalize_text(message).strip()
|
|
if len(normalized) < 15:
|
|
return False
|
|
operational_terms = {
|
|
"agendar",
|
|
"revisao",
|
|
"cancelar",
|
|
"pedido",
|
|
"comprar",
|
|
"compra",
|
|
"carro",
|
|
"veiculo",
|
|
"remarcar",
|
|
"tambem",
|
|
}
|
|
return self.contains_any_term(normalized, operational_terms)
|
|
|
|
|
|
# Distingue um comando global explicito de cancelamento do fluxo atual de um texto livre
|
|
# que deve ser consumido como dado do rascunho aberto.
|
|
def is_explicit_flow_cancel_message(self, message: str) -> bool:
|
|
normalized = self.service.normalizer.normalize_text(message).strip()
|
|
explicit_terms = {
|
|
"cancelar fluxo",
|
|
"cancela o fluxo",
|
|
"cancelar esse fluxo",
|
|
"cancela esse fluxo",
|
|
"cancelar fluxo atual",
|
|
"cancela o fluxo atual",
|
|
"encerrar fluxo",
|
|
"encerrar esse fluxo",
|
|
"parar fluxo",
|
|
"parar esse fluxo",
|
|
"abandonar fluxo",
|
|
"abandonar esse fluxo",
|
|
"desistir desse fluxo",
|
|
"desistir deste fluxo",
|
|
"desistir dessa compra",
|
|
"desistir desta compra",
|
|
}
|
|
return normalized in explicit_terms
|
|
|
|
|
|
# Evita que frases como "desisti" sejam tratadas como comando global quando o sistema
|
|
# esta justamente esperando o motivo do cancelamento.
|
|
def should_defer_flow_cancellation_control(self, message: str, user_id: int | None) -> bool:
|
|
if user_id is None or self.is_explicit_flow_cancel_message(message):
|
|
return False
|
|
|
|
pending_cancel_order = self.service.state.get_entry("pending_cancel_order_drafts", user_id, expire=True)
|
|
if pending_cancel_order:
|
|
payload = pending_cancel_order.get("payload", {})
|
|
if payload.get("numero_pedido") and not payload.get("motivo"):
|
|
free_text = str(message or "").strip()
|
|
if len(free_text) >= 4:
|
|
return True
|
|
|
|
pending_review_management = self.service.state.get_entry("pending_review_management_drafts", user_id, expire=True)
|
|
if pending_review_management:
|
|
payload = pending_review_management.get("payload", {})
|
|
action = pending_review_management.get("action", "cancel")
|
|
if action == "cancel" and payload.get("protocolo") and not payload.get("motivo"):
|
|
free_text = str(message or "").strip()
|
|
if len(free_text) >= 4 and not self.service._is_affirmative_message(free_text):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
# Interpreta a resposta do usuário na etapa de seleção.
|
|
def detect_selected_order_index(
|
|
self,
|
|
message: str,
|
|
orders: list[dict],
|
|
turn_decision: dict | None = None,
|
|
) -> tuple[int | None, bool]:
|
|
selection_index = self._decision_selection_index(turn_decision)
|
|
if selection_index is not None and 0 <= selection_index < len(orders):
|
|
return selection_index, False
|
|
|
|
normalized = self.strip_choice_message(self.service.normalizer.normalize_text(message))
|
|
indifferent_tokens = {
|
|
"tanto faz",
|
|
"indiferente",
|
|
"qualquer um",
|
|
"qualquer uma",
|
|
"voce escolhe",
|
|
"pode escolher",
|
|
"fica a seu criterio",
|
|
}
|
|
if normalized in indifferent_tokens:
|
|
return 0, True
|
|
if normalized in {"1", "primeiro", "primeira", "opcao 1", "acao 1", "pedido 1"}:
|
|
return 0, False
|
|
if normalized in {"2", "segundo", "segunda", "opcao 2", "acao 2", "pedido 2"}:
|
|
return 1, False
|
|
|
|
decision_domain = self._decision_domain(turn_decision)
|
|
if len(orders) >= 2 and decision_domain in {"review", "sales"}:
|
|
matches = [index for index, order in enumerate(orders) if order.get("domain") == decision_domain]
|
|
if len(matches) == 1:
|
|
return matches[0], False
|
|
|
|
review_matches = [index for index, order in enumerate(orders) if order.get("domain") == "review"]
|
|
sales_matches = [index for index, order in enumerate(orders) if order.get("domain") == "sales"]
|
|
has_review_signal = self.contains_any_term(normalized, {"revisao", "agendamento", "agendar", "remarcar", "pos venda"})
|
|
has_sales_signal = self.contains_any_term(normalized, {"venda", "compra", "comprar", "pedido", "cancelamento", "cancelar", "carro", "veiculo"})
|
|
|
|
if len(review_matches) == 1 and has_review_signal and not has_sales_signal:
|
|
return review_matches[0], False
|
|
if len(sales_matches) == 1 and has_sales_signal and not has_review_signal:
|
|
return sales_matches[0], False
|
|
return None, False
|
|
|
|
|
|
# É a função que efetivamente trata a resposta do usuário quando você perguntou “qual pedido quer fazer primeiro?”.
|
|
async def try_resolve_pending_order_selection(
|
|
self,
|
|
message: str,
|
|
user_id: int | None,
|
|
turn_decision: dict | None = None,
|
|
) -> str | None:
|
|
context = self.service._get_user_context(user_id)
|
|
if not context:
|
|
return None
|
|
pending = context.get("pending_order_selection")
|
|
if not isinstance(pending, dict):
|
|
return None
|
|
if pending.get("expires_at") and pending["expires_at"] < datetime.utcnow():
|
|
context["pending_order_selection"] = None
|
|
self._save_context(user_id=user_id, context=context)
|
|
return None
|
|
orders = pending.get("orders") or []
|
|
if len(orders) < 2:
|
|
context["pending_order_selection"] = None
|
|
self._save_context(user_id=user_id, context=context)
|
|
return None
|
|
|
|
decision_action = self._decision_action(turn_decision)
|
|
if decision_action == "clear_context" or self.is_order_selection_reset_message(message):
|
|
self.service._clear_user_conversation_state(user_id=user_id)
|
|
cleaned_message = self.remove_order_selection_reset_prefix(message)
|
|
if not cleaned_message:
|
|
return "Tudo bem. Limpei o contexto atual. Pode me dizer o que voce quer fazer agora?"
|
|
return await self.service.handle_message(cleaned_message, user_id=user_id)
|
|
|
|
selected_index, auto_selected = self.detect_selected_order_index(
|
|
message=message,
|
|
orders=orders,
|
|
turn_decision=turn_decision,
|
|
)
|
|
if selected_index is None:
|
|
if self.looks_like_fresh_operational_request(message, turn_decision=turn_decision):
|
|
context["pending_order_selection"] = None
|
|
self._save_context(user_id=user_id, context=context)
|
|
return None
|
|
return self.render_order_selection_prompt(orders)
|
|
|
|
selected_order = orders[selected_index]
|
|
remaining_order = orders[1 - selected_index]
|
|
context["pending_order_selection"] = None
|
|
self.queue_order_with_memory_seed(
|
|
user_id=user_id,
|
|
domain=remaining_order["domain"],
|
|
order_message=remaining_order["message"],
|
|
memory_seed=remaining_order.get("memory_seed"),
|
|
)
|
|
|
|
intro = (
|
|
f"Vou escolher e comecar por: {self.describe_order_selection_option(selected_order)}"
|
|
if auto_selected
|
|
else f"Perfeito. Vou comecar por: {self.describe_order_selection_option(selected_order)}"
|
|
)
|
|
selected_memory = dict(selected_order.get("memory_seed") or {})
|
|
if selected_memory:
|
|
context["generic_memory"] = selected_memory
|
|
self._save_context(user_id=user_id, context=context)
|
|
next_response = await self.service.handle_message(str(selected_order.get("message") or ""), user_id=user_id)
|
|
return f"{intro}\n{next_response}"
|
|
|
|
|
|
# Cria o aviso de fila.
|
|
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'."
|
|
|
|
|
|
# Mostra ao usuário o que falta concluir no fluxo atual antes de mudar de assunto.
|
|
def render_open_flow_prompt(self, user_id: int | None, domain: str) -> str:
|
|
if domain == "review" and user_id is not None:
|
|
draft = self.service.state.get_entry("pending_review_drafts", user_id, expire=True)
|
|
if draft:
|
|
missing = [field for field in REVIEW_REQUIRED_FIELDS if field not in draft.get("payload", {})]
|
|
if missing:
|
|
return self.service._render_missing_review_fields_prompt(missing)
|
|
management_draft = self.service.state.get_entry("pending_review_management_drafts", user_id, expire=True)
|
|
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.service._render_missing_review_reschedule_fields_prompt(missing)
|
|
else:
|
|
missing = [field for field in ("protocolo",) if field not in payload]
|
|
if missing:
|
|
return self.service._render_missing_review_cancel_fields_prompt(missing)
|
|
if self.service.state.get_entry("pending_review_confirmations", user_id, expire=True):
|
|
return "Antes de mudar de assunto, me confirme se podemos concluir seu agendamento de revisao."
|
|
if self.service.state.get_entry("pending_review_reuse_confirmations", user_id, expire=True):
|
|
return self.service._render_review_reuse_question()
|
|
|
|
if domain == "sales" and user_id is not None:
|
|
draft = self.service.state.get_entry("pending_order_drafts", user_id, expire=True)
|
|
if draft:
|
|
missing = [field for field in ORDER_REQUIRED_FIELDS if field not in draft.get("payload", {})]
|
|
if missing:
|
|
return self.service._render_missing_order_fields_prompt(missing)
|
|
cancel_draft = self.service.state.get_entry("pending_cancel_order_drafts", user_id, expire=True)
|
|
if cancel_draft:
|
|
missing = [field for field in CANCEL_ORDER_REQUIRED_FIELDS if field not in cancel_draft.get("payload", {})]
|
|
if missing:
|
|
return self.service._render_missing_cancel_order_fields_prompt(missing)
|
|
|
|
return "Vamos concluir este assunto primeiro e ja sigo com o proximo em seguida."
|
|
|
|
|
|
# Cria a introdução para mudar de assunto de forma natural.
|
|
def build_next_order_transition(self, domain: str) -> str:
|
|
if domain == "sales":
|
|
return "Agora, sobre a compra do veiculo:"
|
|
if domain == "review":
|
|
return "Agora, sobre o agendamento da revisao:"
|
|
return "Agora, sobre o proximo assunto:"
|
|
|
|
|
|
# Quando um fluxo termina, ela prepara a passagem para o próximo pedido da fila.
|
|
async def maybe_auto_advance_next_order(self, base_response: str, user_id: int | None) -> str:
|
|
context = self.service._get_user_context(user_id)
|
|
if not context:
|
|
return base_response
|
|
if context.get("pending_switch"):
|
|
return base_response
|
|
active_domain = context.get("active_domain", "general")
|
|
if self.has_open_flow(user_id=user_id, domain=active_domain):
|
|
return base_response
|
|
|
|
next_order = self.pop_next_order(user_id=user_id)
|
|
if not next_order:
|
|
return base_response
|
|
|
|
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.service._new_tab_memory(user_id=user_id)),
|
|
"expires_at": datetime.utcnow() + timedelta(minutes=15),
|
|
}
|
|
self._save_context(user_id=user_id, context=context)
|
|
transition = self.build_next_order_transition(next_order["domain"])
|
|
return (
|
|
f"{base_response}\n\n"
|
|
f"{transition}\n"
|
|
"Tenho um proximo pedido na fila. Quando quiser, diga 'continuar' para eu seguir nele."
|
|
)
|
|
|
|
|
|
# Converte intenções em um domínio principal de atendimento.
|
|
def domain_from_intents(self, intents: dict | None) -> str:
|
|
normalized = self.service.normalizer.normalize_intents(intents)
|
|
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"
|
|
if sales_score > review_score and sales_score > 0:
|
|
return "sales"
|
|
return "general"
|
|
|
|
|
|
# Detecta comandos de continuação.
|
|
def is_context_switch_confirmation(self, message: str, turn_decision: dict | None = None) -> bool:
|
|
if self._decision_action(turn_decision) in {"continue_queue", "cancel_active_flow", "clear_context", "discard_queue"}:
|
|
return True
|
|
if self._decision_domain(turn_decision) in {"review", "sales"}:
|
|
return True
|
|
return self.service._is_affirmative_message(message) or self.service._is_negative_message(message)
|
|
|
|
|
|
# Executa o próximo pedido da fila quando o usuário disser “continuar”.
|
|
def is_continue_queue_message(self, message: str, turn_decision: dict | None = None) -> bool:
|
|
if self._decision_action(turn_decision) == "continue_queue" or self._decision_intent(turn_decision) == "queue_continue":
|
|
return True
|
|
normalized = self.service.normalizer.normalize_text(message).strip()
|
|
normalized = re.sub(r"[.!?,;:]+$", "", normalized)
|
|
return normalized in {"continuar", "pode continuar", "seguir", "pode seguir", "proximo", "segue"}
|
|
|
|
|
|
# Executa o próximo pedido da fila quando o usuário disser “continuar”.
|
|
async def try_continue_queued_order(
|
|
self,
|
|
message: str,
|
|
user_id: int | None,
|
|
turn_decision: dict | None = None,
|
|
) -> str | None:
|
|
context = self.service._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
|
|
self._save_context(user_id=user_id, context=context)
|
|
return None
|
|
queued_message = str(pending_switch.get("queued_message") or "").strip()
|
|
if not queued_message:
|
|
return None
|
|
|
|
decision_action = self._decision_action(turn_decision)
|
|
if self.service._is_negative_message(message) and decision_action != "continue_queue":
|
|
context["pending_switch"] = None
|
|
self._save_context(user_id=user_id, context=context)
|
|
return "Tudo bem. Mantive o proximo pedido fora da fila por enquanto."
|
|
if not (
|
|
self.is_continue_queue_message(message, turn_decision=turn_decision)
|
|
or self.service._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.service._get_user_context(user_id)
|
|
if refreshed is not None:
|
|
refreshed["generic_memory"] = memory_seed
|
|
self._save_context(user_id=user_id, context=refreshed)
|
|
transition = self.build_next_order_transition(target_domain)
|
|
next_response = await self.service.handle_message(queued_message, user_id=user_id)
|
|
return f"{transition}\n{next_response}"
|
|
|
|
|
|
# Diz se ainda existe algo pendente antes de encerrar aquele assunto.
|
|
def has_open_flow(self, user_id: int | None, domain: str) -> bool:
|
|
if user_id is None:
|
|
return False
|
|
if domain == "review":
|
|
return bool(
|
|
self.service.state.get_entry("pending_review_drafts", user_id, expire=True)
|
|
or self.service.state.get_entry("pending_review_confirmations", user_id, expire=True)
|
|
or self.service.state.get_entry("pending_review_management_drafts", user_id, expire=True)
|
|
or self.service.state.get_entry("pending_review_reuse_confirmations", user_id, expire=True)
|
|
)
|
|
if domain == "sales":
|
|
return bool(
|
|
self.service.state.get_entry("pending_order_drafts", user_id, expire=True)
|
|
or self.service.state.get_entry("pending_cancel_order_drafts", user_id, expire=True)
|
|
)
|
|
return False
|
|
|
|
|
|
# Encerra o contexto anterior e troca oficialmente para o novo assunto.
|
|
def apply_domain_switch(self, user_id: int | None, target_domain: str) -> None:
|
|
context = self.service._get_user_context(user_id)
|
|
if not context:
|
|
return
|
|
previous_domain = context.get("active_domain", "general")
|
|
if previous_domain == "review":
|
|
self.service._reset_pending_review_states(user_id=user_id)
|
|
if previous_domain == "sales":
|
|
self.service._reset_pending_order_states(user_id=user_id)
|
|
context["active_domain"] = target_domain
|
|
context["generic_memory"] = self.service._new_tab_memory(user_id=user_id)
|
|
context["pending_order_selection"] = None
|
|
context["pending_switch"] = None
|
|
self._save_context(user_id=user_id, context=context)
|
|
|
|
|
|
# Controla a confirmação de “você quer mesmo sair deste assunto e ir para outro?”.
|
|
def handle_context_switch(
|
|
self,
|
|
message: str,
|
|
user_id: int | None,
|
|
target_domain_hint: str = "general",
|
|
turn_decision: dict | None = None,
|
|
) -> str | None:
|
|
context = self.service._get_user_context(user_id)
|
|
if not context:
|
|
return None
|
|
pending_switch = context.get("pending_switch")
|
|
if pending_switch:
|
|
if pending_switch["expires_at"] < datetime.utcnow():
|
|
context["pending_switch"] = None
|
|
self._save_context(user_id=user_id, context=context)
|
|
elif self.is_context_switch_confirmation(message, turn_decision=turn_decision):
|
|
if self.service._is_affirmative_message(message) or self._decision_domain(turn_decision) == pending_switch["target_domain"]:
|
|
target_domain = pending_switch["target_domain"]
|
|
self.apply_domain_switch(user_id=user_id, target_domain=target_domain)
|
|
return self.render_context_switched_message(target_domain=target_domain)
|
|
context["pending_switch"] = None
|
|
self._save_context(user_id=user_id, context=context)
|
|
return "Perfeito, vamos continuar no fluxo atual."
|
|
|
|
pending_order_selection = context.get("pending_order_selection")
|
|
if pending_order_selection and pending_order_selection.get("expires_at") < datetime.utcnow():
|
|
context["pending_order_selection"] = None
|
|
self._save_context(user_id=user_id, context=context)
|
|
|
|
current_domain = context.get("active_domain", "general")
|
|
if target_domain_hint == "general" or target_domain_hint == current_domain:
|
|
return None
|
|
if not self.has_open_flow(user_id=user_id, domain=current_domain):
|
|
return None
|
|
|
|
context["pending_switch"] = {
|
|
"source_domain": current_domain,
|
|
"target_domain": target_domain_hint,
|
|
"expires_at": datetime.utcnow() + timedelta(minutes=15),
|
|
}
|
|
self._save_context(user_id=user_id, context=context)
|
|
return self.render_context_switch_confirmation(source_domain=current_domain, target_domain=target_domain_hint)
|
|
|
|
|
|
# Marca qual domínio está ativo atualmente.
|
|
def update_active_domain(self, user_id: int | None, domain_hint: str = "general") -> None:
|
|
context = self.service._get_user_context(user_id)
|
|
if context and domain_hint != "general":
|
|
context["active_domain"] = domain_hint
|
|
self._save_context(user_id=user_id, context=context)
|
|
|
|
|
|
# Serve para exibir o nome do domínio em mensagens para o usuário.
|
|
def domain_label(self, domain: str) -> str:
|
|
labels = {
|
|
"review": "agendamento de revisao",
|
|
"sales": "compra de veiculo",
|
|
"general": "atendimento geral",
|
|
}
|
|
return labels.get(domain, "atendimento")
|
|
|
|
|
|
# É o prompt de confirmação da troca.
|
|
def render_context_switch_confirmation(self, source_domain: str, target_domain: str) -> str:
|
|
return (
|
|
f"Entendi que voce quer sair de {self.domain_label(source_domain)} "
|
|
f"e ir para {self.domain_label(target_domain)}. Tem certeza?"
|
|
)
|
|
|
|
|
|
#Mensagem exibida após a troca acontecer.
|
|
def render_context_switched_message(self, target_domain: str) -> str:
|
|
return f"Certo, contexto anterior encerrado. Vamos seguir com {self.domain_label(target_domain)}."
|
|
|
|
|
|
# Serve para depuração, observabilidade ou até para alimentar outro componente com um resumo do estado atual.
|
|
def build_context_summary(self, user_id: int | None) -> str:
|
|
context = self.service._get_user_context(user_id)
|
|
if not context:
|
|
return "Contexto de conversa: sem contexto ativo."
|
|
summary = [f"Contexto de conversa ativo: {self.domain_label(context.get('active_domain', 'general'))}."]
|
|
memory = context.get("generic_memory", {})
|
|
if memory:
|
|
summary.append(f"Memoria generica temporaria: {memory}.")
|
|
selected_vehicle = context.get("selected_vehicle")
|
|
if isinstance(selected_vehicle, dict) and selected_vehicle.get("modelo"):
|
|
summary.append(f"Veiculo selecionado para compra: {selected_vehicle.get('modelo')}.")
|
|
stock_results = context.get("last_stock_results") or []
|
|
if isinstance(stock_results, list) and stock_results:
|
|
summary.append(f"Ultima consulta de estoque com {len(stock_results)} opcao(oes) disponivel(is).")
|
|
order_queue = context.get("order_queue", [])
|
|
if order_queue:
|
|
summary.append(f"Fila de pedidos pendentes: {len(order_queue)}.")
|
|
return " ".join(summary)
|