Files
clientflow_backend/app/reply_intent_gate.py

422 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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",
"well 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