422 lines
14 KiB
Python
422 lines
14 KiB
Python
"""First-pass intent gate for BLIF reply suggestions.
|
||
|
||
This module runs before commercial templates/knowledge retrieval. Its job is to
|
||
avoid dangerous false positives: bounces, unsubscribe requests, no-interest
|
||
replies and support incidents must not fall through to sales templates such as
|
||
"send equipment list".
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from dataclasses import asdict, dataclass
|
||
from typing import Any, Dict, Iterable, List, Optional, Sequence
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ReplyIntent:
|
||
category: str
|
||
label: str
|
||
template_code: str
|
||
reply_type: str
|
||
confidence: float
|
||
auto_reply_allowed: bool
|
||
commercial_reply_allowed: bool
|
||
requires_manual_review: bool
|
||
reasons: tuple[str, ...] = ()
|
||
matched_terms: tuple[str, ...] = ()
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
data = asdict(self)
|
||
data["reasons"] = list(self.reasons)
|
||
data["matched_terms"] = list(self.matched_terms)
|
||
return data
|
||
|
||
|
||
ACCENT_MAP = str.maketrans({
|
||
"á": "a", "à": "a", "ã": "a", "â": "a", "ä": "a",
|
||
"é": "e", "è": "e", "ê": "e", "ë": "e",
|
||
"í": "i", "ì": "i", "î": "i", "ï": "i",
|
||
"ó": "o", "ò": "o", "õ": "o", "ô": "o", "ö": "o",
|
||
"ú": "u", "ù": "u", "û": "u", "ü": "u",
|
||
"ç": "c",
|
||
})
|
||
|
||
|
||
def normalize_text(value: Any) -> str:
|
||
text = str(value or "").lower().translate(ACCENT_MAP)
|
||
text = re.sub(r"\s+", " ", text)
|
||
return text.strip()
|
||
|
||
|
||
def _task_value(task: Optional[Dict[str, Any]], *keys: str) -> str:
|
||
task = task or {}
|
||
for key in keys:
|
||
value = task.get(key)
|
||
if value:
|
||
return str(value)
|
||
return ""
|
||
|
||
|
||
def _combined_text(task: Optional[Dict[str, Any]], message: str) -> str:
|
||
task = task or {}
|
||
parts = [
|
||
message,
|
||
_task_value(task, "message_subject", "subject"),
|
||
_task_value(task, "customer_email", "linked_customer_email"),
|
||
_task_value(task, "customer_name", "linked_customer_name"),
|
||
_task_value(task, "route"),
|
||
_task_value(task, "action_code"),
|
||
_task_value(task, "action"),
|
||
_task_value(task, "note"),
|
||
]
|
||
return normalize_text("\n".join(part for part in parts if part))
|
||
|
||
|
||
def _matches(text: str, patterns: Sequence[str]) -> List[str]:
|
||
found: List[str] = []
|
||
for pattern in patterns:
|
||
if pattern.startswith("re:"):
|
||
if re.search(pattern[3:], text, flags=re.IGNORECASE):
|
||
found.append(pattern)
|
||
elif pattern in text:
|
||
found.append(pattern)
|
||
return found
|
||
|
||
|
||
BOUNCE_PATTERNS = (
|
||
"mailer-daemon",
|
||
"mail delivery subsystem",
|
||
"postmaster",
|
||
"delivery status notification",
|
||
"delivery has failed",
|
||
"address not found",
|
||
"undeliverable",
|
||
"returned mail",
|
||
"recipient address rejected",
|
||
"no such user",
|
||
"nosuchuser",
|
||
"mailbox full",
|
||
"quota exceeded",
|
||
"quotaexceeded",
|
||
"spf error",
|
||
"550 5.1.1",
|
||
"550 5.2.2",
|
||
"permanent error",
|
||
"message not delivered",
|
||
)
|
||
|
||
UNSUBSCRIBE_PATTERNS = (
|
||
"cancelar email",
|
||
"cancelar e-mail",
|
||
"remover da lista",
|
||
"remova da lista",
|
||
"retirar da lista",
|
||
"retirem-me",
|
||
"nao queremos receber",
|
||
"nao quero receber",
|
||
"unsubscribe",
|
||
"desinscrever",
|
||
"apagar os meus dados",
|
||
"rgpd",
|
||
"incumprimento rgpd",
|
||
"nao pedimos qualquer contato",
|
||
"nao pedimos qualquer contacto",
|
||
)
|
||
|
||
ADDRESS_UPDATE_PATTERNS = (
|
||
"email foi substituido",
|
||
"e-mail foi substituido",
|
||
"este email foi substituido",
|
||
"este e-mail foi substituido",
|
||
"novo email",
|
||
"novo e-mail",
|
||
"atualizar contacto",
|
||
"actualizar contacto",
|
||
"corrigir contacto",
|
||
"passou a ser",
|
||
)
|
||
|
||
NO_INTEREST_PATTERNS = (
|
||
"nao estamos interessados",
|
||
"nao temos interesse",
|
||
"sem interesse",
|
||
"nao pretendemos",
|
||
"nao queremos",
|
||
"ja dispomos de solucao",
|
||
"ja temos solucao",
|
||
"nao temos veiculos eletricos",
|
||
"nao temos viaturas eletricas",
|
||
"de momento nao",
|
||
"obrigado mas nao",
|
||
)
|
||
|
||
SUPPORT_PATTERNS = (
|
||
"avaria",
|
||
"avariado",
|
||
"anomalia",
|
||
"problema por resolver",
|
||
"nao funciona",
|
||
"nao esta a funcionar",
|
||
"recolha",
|
||
"garantia",
|
||
"assistencia",
|
||
"contactora",
|
||
"nao atraca",
|
||
"placa",
|
||
"ainda nao chegou",
|
||
"nao chegou",
|
||
"devolucao",
|
||
"re:rma\b",
|
||
)
|
||
|
||
INSTALLATION_PATTERNS = (
|
||
"sem instalacao",
|
||
"instalacao incluida",
|
||
"inclui instalacao",
|
||
"com instalacao",
|
||
"valor e sem instalacao",
|
||
"eletricista",
|
||
"electricista",
|
||
)
|
||
|
||
VAT_PATTERNS = ("iva", "com iva", "sem iva")
|
||
DELIVERY_PATTERNS = ("entrega", "prazo", "transport", "transitario", "envio")
|
||
RFID_PATTERNS = ("rfid", "cartao", "cartoes")
|
||
BALANCER_PATTERNS = ("balanceador", "dinamico", "modbus", "zigbee", "disjuntor", "sobrecarga")
|
||
CONDOMINIUM_PATTERNS = ("condominio", "garagem", "mobi.e", "mobie", "dpc")
|
||
PRICE_OR_CATALOG_PATTERNS = (
|
||
"preco",
|
||
"precos",
|
||
"valores",
|
||
"quanto custa",
|
||
"tabela de precos",
|
||
"lista de equipamentos",
|
||
"catalogo",
|
||
"carregador",
|
||
"wallbox",
|
||
"proposta",
|
||
"orcamento",
|
||
)
|
||
INVOICE_PATTERNS = ("fatura", "factura", "recibo")
|
||
CALLBACK_PATTERNS = ("ligar", "contactar", "telefone", "chamada")
|
||
|
||
|
||
def classify_reply_intent(task: Optional[Dict[str, Any]], message: str) -> ReplyIntent:
|
||
"""Classify the nature of a task before choosing a reply template."""
|
||
text = _combined_text(task, message)
|
||
route = normalize_text(_task_value(task, "route"))
|
||
|
||
checks = [
|
||
("BOUNCE_EMAIL", "Email devolvido / automático", "INTERNAL_BOUNCE_EMAIL", "no_customer_reply", 0.99, False, False, True, BOUNCE_PATTERNS, "Mensagem automática de devolução; não responder ao cliente."),
|
||
("UNSUBSCRIBE_REQUEST", "Pedido de remoção da lista", "ACK_UNSUBSCRIBE", "operational_ack", 0.96, True, False, True, UNSUBSCRIBE_PATTERNS, "Pedido de remoção/privacidade; não enviar conteúdo comercial."),
|
||
("ADDRESS_UPDATE", "Atualização de contacto", "ACK_ADDRESS_UPDATE", "operational_ack", 0.92, True, False, True, ADDRESS_UPDATE_PATTERNS, "Pedido de atualização de contacto; confirmar e atualizar CRM."),
|
||
("NO_INTEREST", "Sem interesse", "ACK_NO_INTEREST", "operational_ack", 0.94, True, False, True, NO_INTEREST_PATTERNS, "Cliente indicou ausência de interesse; não insistir comercialmente."),
|
||
]
|
||
for category, label, template, reply_type, confidence, auto_reply, commercial, manual, patterns, reason in checks:
|
||
found = _matches(text, patterns)
|
||
if found:
|
||
return ReplyIntent(category, label, template, reply_type, confidence, auto_reply, commercial, manual, (reason,), tuple(found[:6]))
|
||
|
||
support_matches = _matches(text, SUPPORT_PATTERNS)
|
||
if route in {"suporte", "operacoes", "operações"} or support_matches:
|
||
return ReplyIntent(
|
||
"SUPPORT_INCIDENT",
|
||
"Incidente / suporte",
|
||
"ACK_SUPPORT_RECEIVED",
|
||
"support_ack",
|
||
0.90 if support_matches else 0.75,
|
||
True,
|
||
False,
|
||
True,
|
||
("Mensagem aparenta ser suporte/garantia/operação; não usar templates de venda.",),
|
||
tuple(support_matches[:6]),
|
||
)
|
||
|
||
install_matches = _matches(text, INSTALLATION_PATTERNS)
|
||
if install_matches:
|
||
return ReplyIntent(
|
||
"COMMERCIAL_CLARIFICATION",
|
||
"Esclarecimento comercial — instalação",
|
||
"CLARIFY_INSTALLATION_SCOPE",
|
||
"answer_without_attachment",
|
||
0.91,
|
||
True,
|
||
True,
|
||
False,
|
||
("Cliente está a pedir esclarecimento sobre instalação/âmbito do preço.",),
|
||
tuple(install_matches[:6]),
|
||
)
|
||
|
||
for category, label, template, patterns, reason in [
|
||
("VAT_CLARIFICATION", "Esclarecimento de IVA", "CLARIFY_VAT_POLICY", VAT_PATTERNS, "Cliente está a pedir esclarecimento de IVA."),
|
||
("DELIVERY_PAYMENT", "Entrega / pagamento", "CLARIFY_DELIVERY_PAYMENT", DELIVERY_PATTERNS, "Cliente está a pedir esclarecimento de entrega/pagamento."),
|
||
("RFID_QUESTION", "Pergunta RFID", "CLARIFY_RFID", RFID_PATTERNS, "Cliente está a pedir informação sobre RFID."),
|
||
("LOAD_BALANCER_QUESTION", "Pergunta balanceador", "CLARIFY_LOAD_BALANCER", BALANCER_PATTERNS, "Cliente está a pedir informação sobre balanceador/carga dinâmica."),
|
||
("CONDOMINIUM_MOBIE", "Condomínio / MOBI.E", "CLARIFY_CONDOMINIUM_MOBIE", CONDOMINIUM_PATTERNS, "Cliente está a pedir informação sobre condomínio/MOBI.E."),
|
||
]:
|
||
found = _matches(text, patterns)
|
||
if found:
|
||
return ReplyIntent(category, label, template, "answer_without_attachment", 0.86, True, True, False, (reason,), tuple(found[:6]))
|
||
|
||
if _matches(text, INVOICE_PATTERNS):
|
||
return ReplyIntent(
|
||
"INVOICE_REQUEST",
|
||
"Pedido de fatura",
|
||
"SEND_INVOICE",
|
||
"send_document",
|
||
0.84,
|
||
True,
|
||
True,
|
||
True,
|
||
("Pedido relacionado com fatura; validar documento antes de enviar.",),
|
||
tuple(_matches(text, INVOICE_PATTERNS)[:6]),
|
||
)
|
||
|
||
catalog_matches = _matches(text, PRICE_OR_CATALOG_PATTERNS)
|
||
if catalog_matches:
|
||
# Pedido explícito de orçamento/cotação/proposta deve manter objetivo SEND_QUOTE,
|
||
# mesmo quando ainda não há ORC/anexo. A resposta pode ser proposta textual.
|
||
action = normalize_text(_task_value(task, "action_code"))
|
||
quote_terms = ("orcamento", "cotacao", "proposta", "quote", "quotation")
|
||
if action == "send_quote" or any(term in text for term in quote_terms):
|
||
template = "SEND_QUOTE"
|
||
reply_type = "send_document_or_text_quote"
|
||
else:
|
||
template = "SEND_PRICE_LIST" if any(term in text for term in ["preco", "precos", "valores", "quanto custa"]) else "SEND_INFO_EQUIPMENT_LIST"
|
||
reply_type = "answer_without_attachment"
|
||
return ReplyIntent(
|
||
"COMMERCIAL_REQUEST",
|
||
"Pedido comercial",
|
||
template,
|
||
reply_type,
|
||
0.78,
|
||
True,
|
||
True,
|
||
False,
|
||
("Pedido comercial detetado; aplicar template de venda apenas neste caso.",),
|
||
tuple(catalog_matches[:6]),
|
||
)
|
||
|
||
if _matches(text, CALLBACK_PATTERNS):
|
||
return ReplyIntent(
|
||
"CALLBACK_REQUEST",
|
||
"Pedido de contacto/chamada",
|
||
"ACK_CALLBACK_REQUEST",
|
||
"operational_ack",
|
||
0.78,
|
||
True,
|
||
False,
|
||
True,
|
||
("Cliente pediu contacto; confirmar receção e tratar manualmente.",),
|
||
tuple(_matches(text, CALLBACK_PATTERNS)[:6]),
|
||
)
|
||
|
||
return ReplyIntent(
|
||
"MANUAL_REVIEW",
|
||
"Revisão manual necessária",
|
||
"MANUAL_REVIEW_REQUIRED",
|
||
"manual_internal",
|
||
0.40,
|
||
False,
|
||
False,
|
||
True,
|
||
("Não foi possível classificar com segurança; não usar fallback comercial automático.",),
|
||
(),
|
||
)
|
||
|
||
# v4928.1.5.4: hard guardrails only for LLM-first mode.
|
||
# These categories are objective enough to block customer-facing LLM replies
|
||
# before interpretation. Commercial/support nuance should be interpreted by
|
||
# the LLM with BLIF knowledge instead of by expanding keyword rules forever.
|
||
AUTO_REPLY_PATTERNS = (
|
||
"out of office",
|
||
"fora do escritorio",
|
||
"fora do escritório",
|
||
"vamos estar ausentes",
|
||
"we’ll be out of the office",
|
||
"we'll be out of the office",
|
||
"auto reply",
|
||
"automatic reply",
|
||
"resposta automatica",
|
||
"resposta automática",
|
||
)
|
||
|
||
|
||
def classify_reply_guardrail(task: Optional[Dict[str, Any]], message: str) -> Optional[ReplyIntent]:
|
||
"""Return only objective safety/operational guardrails.
|
||
|
||
In LLM-first mode this function replaces the old broad keyword gate as the
|
||
first step. It should not classify nuanced commercial/support requests.
|
||
"""
|
||
text = _combined_text(task, message)
|
||
|
||
found = _matches(text, BOUNCE_PATTERNS)
|
||
if found:
|
||
return ReplyIntent(
|
||
"BOUNCE_EMAIL",
|
||
"Email devolvido / automático",
|
||
"INTERNAL_BOUNCE_EMAIL",
|
||
"no_customer_reply",
|
||
0.99,
|
||
False,
|
||
False,
|
||
True,
|
||
("Mensagem automática de devolução; não responder ao cliente.",),
|
||
tuple(found[:6]),
|
||
)
|
||
|
||
found = _matches(text, AUTO_REPLY_PATTERNS)
|
||
if found:
|
||
return ReplyIntent(
|
||
"AUTO_REPLY",
|
||
"Resposta automática / ausência",
|
||
"INTERNAL_AUTO_REPLY",
|
||
"no_customer_reply",
|
||
0.96,
|
||
False,
|
||
False,
|
||
True,
|
||
("Mensagem automática/ausência; não enviar resposta comercial.",),
|
||
tuple(found[:6]),
|
||
)
|
||
|
||
found = _matches(text, UNSUBSCRIBE_PATTERNS)
|
||
if found:
|
||
return ReplyIntent(
|
||
"UNSUBSCRIBE_REQUEST",
|
||
"Pedido de remoção da lista",
|
||
"ACK_UNSUBSCRIBE",
|
||
"operational_ack",
|
||
0.96,
|
||
True,
|
||
False,
|
||
True,
|
||
("Pedido de remoção/privacidade; não enviar conteúdo comercial.",),
|
||
tuple(found[:6]),
|
||
)
|
||
|
||
found = _matches(text, ADDRESS_UPDATE_PATTERNS + (
|
||
"correo electronico ha cambiado",
|
||
"my email has changed",
|
||
"please update your contact",
|
||
"por favor actualice su contacto",
|
||
"por favor atualize o seu contacto",
|
||
"nova conta",
|
||
"nueva cuenta",
|
||
))
|
||
if found:
|
||
return ReplyIntent(
|
||
"ADDRESS_UPDATE",
|
||
"Atualização de contacto",
|
||
"ACK_ADDRESS_UPDATE",
|
||
"operational_ack",
|
||
0.94,
|
||
True,
|
||
False,
|
||
True,
|
||
("Pedido de atualização de contacto; confirmar e atualizar CRM.",),
|
||
tuple(found[:6]),
|
||
)
|
||
|
||
return None
|