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.
orquestrador/app/services/orchestration/conversation_policy.py

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)