272 lines
13 KiB
Python
272 lines
13 KiB
Python
"""Optional OpenRouter-backed business-aware reply generator."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any, Dict, Optional
|
|
|
|
import httpx
|
|
|
|
from app.action_llm_client import extract_first_json_object
|
|
from app.business_knowledge_service import KnowledgeMatch
|
|
from app.config import settings
|
|
from app.message_cleaner import extract_customer_reply_text
|
|
from app.reply_recipient_utils import apply_preferred_greeting, resolve_reply_recipient
|
|
|
|
|
|
class LLMReplyError(Exception):
|
|
pass
|
|
|
|
|
|
def _trim(text: str, max_chars: int) -> str:
|
|
text = str(text or "").strip()
|
|
if len(text) <= max_chars:
|
|
return text
|
|
return text[: max_chars - 1] + "…"
|
|
|
|
|
|
_COMPANY_MARKERS = {"lda", "l.da", "sa", "s.a", "unipessoal", "limitada", "empresa", "condomínio", "condominio"}
|
|
_FEMALE_FIRST_NAMES = {
|
|
"ana", "barbara", "bárbara", "beatriz", "catarina", "claudia", "cláudia", "cristina",
|
|
"daniela", "filipa", "ines", "inês", "joana", "maria", "mariana", "marta",
|
|
"patricia", "patrícia", "rita", "sara", "sofia", "susana", "teresa", "vera",
|
|
}
|
|
_MALE_FIRST_NAMES = {
|
|
"antonio", "antónio", "bruno", "carlos", "diogo", "duarte", "fernando", "francisco",
|
|
"joao", "joão", "jose", "josé", "luis", "luís", "manuel", "miguel", "nuno",
|
|
"paulo", "pedro", "ricardo", "rui", "sergio", "sérgio", "tiago", "vasco",
|
|
}
|
|
|
|
|
|
def _looks_like_person_name(name: str) -> bool:
|
|
name = str(name or "").strip()
|
|
if not name or "@" in name:
|
|
return False
|
|
lowered = name.lower().replace(",", " ")
|
|
if any(marker in lowered.split() for marker in _COMPANY_MARKERS):
|
|
return False
|
|
return bool(name.split())
|
|
|
|
|
|
def _preferred_greeting(customer_name: str, customer_message: str) -> str:
|
|
"""Return a readable greeting to guide/normalize LLM replies.
|
|
|
|
Use a title only when the first name is strongly recognized. Otherwise prefer
|
|
the full name without title rather than inventing gender.
|
|
"""
|
|
raw_name = " ".join(str(customer_name or "").split()).strip()
|
|
message = str(customer_message or "").lower()
|
|
if "boa tarde" in message:
|
|
base = "Boa tarde"
|
|
elif "boa noite" in message:
|
|
base = "Boa noite"
|
|
else:
|
|
base = "Bom dia"
|
|
|
|
if not _looks_like_person_name(raw_name):
|
|
return f"{base},"
|
|
|
|
first = raw_name.split()[0].strip().lower()
|
|
if first in _FEMALE_FIRST_NAMES:
|
|
return f"{base} Sra. {raw_name},"
|
|
if first in _MALE_FIRST_NAMES:
|
|
return f"{base} Sr. {raw_name},"
|
|
return f"{base} {raw_name},"
|
|
|
|
|
|
def _apply_preferred_greeting(message_body: str, preferred_greeting: str) -> str:
|
|
return apply_preferred_greeting(message_body, preferred_greeting)
|
|
|
|
|
|
def build_reply_system_prompt() -> str:
|
|
return """És o assistente de respostas da BLIF dentro do Clientflow.
|
|
|
|
Objetivo:
|
|
- Interpretar a intenção real da mensagem limpa do cliente.
|
|
- Usar contexto da tarefa/oportunidade e conhecimento BLIF aprovado.
|
|
- Gerar um rascunho curto, profissional e editável para o operador.
|
|
|
|
Regras obrigatórias:
|
|
- Usa apenas factos fornecidos no contexto, conhecimento BLIF e documentos/oportunidade.
|
|
- Não inventes preços, stock, IVA, descontos, prazos, anexos ou condições comerciais.
|
|
- Não transformes todo pedido em lista de equipamentos. Primeiro responde ao que o cliente pediu.
|
|
- A BLIF fabrica/fornece carregadores e acessórios; não presta serviço direto de instalação.
|
|
- Se o cliente perguntar sobre instalação, esclarece que o valor do equipamento não inclui instalação salvo indicação explícita e que qualquer eletricista qualificado pode instalar; a BLIF pode prestar suporte remoto.
|
|
- Se o pedido for apenas esclarecimento, não peças anexos nem digas "segue em anexo".
|
|
- Se for pedido de documento/anexo e não houver documento selecionado, marca requires_attachment=true e explica em warnings.
|
|
- Se não houver informação suficiente para responder com segurança, usa reply_type=manual_review e confidence baixo.
|
|
- Devolve APENAS JSON válido com as chaves: intent, reply_type, requires_attachment, confidence, customer_need, recommended_next_action, message_body, knowledge_used, warnings.
|
|
|
|
Tipos de reply_type permitidos:
|
|
- answer_without_attachment
|
|
- answer_with_links_or_attachment
|
|
- send_document
|
|
- operational_ack
|
|
- support_ack
|
|
- no_customer_reply
|
|
- manual_review
|
|
|
|
Formato visual obrigatório para message_body:
|
|
- Começar exatamente com context.preferred_greeting quando esse campo existir.
|
|
- Usar recipient.person_name como pessoa destinatária quando existir; não cumprimentar com nome de empresa, cliente fiscal, LDA, SA ou UNIPESSOAL.
|
|
- Usar cumprimento formal quando context.preferred_greeting incluir “Sr.” ou “Sra.”; não trocar por “Olá”.
|
|
- Usar parágrafos curtos e fáceis de ler.
|
|
- Em pedidos de preço/proposta, usar estrutura como proposta comercial:
|
|
cumprimento, agradecimento, equipamento/valor s/IVA, funcionalidades incluídas, prazo de entrega quando conhecido e próximo passo.
|
|
- Usar travessão “–” para linhas de preço, funcionalidades e prazos.
|
|
- Em respostas comerciais, terminar com:
|
|
Com os melhores cumprimentos,
|
|
Sérgio Araújo
|
|
Blif
|
|
- Se o cliente pedir carregador + instalação, apresentar o equipamento indicado e esclarecer que instalação/cablagem não está incluída e deve ser validada por eletricista qualificado.
|
|
- Se houver dados suficientes no catálogo para identificar o modelo provável, usar o preço de catálogo s/IVA; não responder apenas “vamos preparar orçamento”.
|
|
- Não inventar cabo extra, metros de cabo fornecidos, stock ou condições de instalação. Se a cablagem for mencionada pelo cliente, dizer que deve ser avaliada pelo eletricista.
|
|
- Se o cliente fizer follow-up sobre morada/endereço, não responder genericamente “estamos a analisar”. Confirmar a receção da morada, resumir a morada se estiver na mensagem e indicar o próximo passo concreto.
|
|
|
|
message_body deve estar em português de Portugal, sem informação repetida e pronto para edição."""
|
|
|
|
|
|
def build_reply_user_prompt(context: Dict[str, Any]) -> str:
|
|
return json.dumps(context, ensure_ascii=False, indent=2, default=str)
|
|
|
|
|
|
def generate_reply_with_openrouter(context: Dict[str, Any]) -> Dict[str, Any]:
|
|
api_key = str(settings.openrouter_api_key or "").strip()
|
|
if not api_key:
|
|
raise LLMReplyError("OPENROUTER_API_KEY não configurada.")
|
|
timeout = float(getattr(settings, "clientflow_reply_llm_timeout_seconds", 20) or 20)
|
|
payload = {
|
|
"model": getattr(settings, "clientflow_reply_llm_model", "") or settings.openrouter_model,
|
|
"messages": [
|
|
{"role": "system", "content": build_reply_system_prompt()},
|
|
{"role": "user", "content": build_reply_user_prompt(context)},
|
|
],
|
|
"temperature": float(getattr(settings, "clientflow_reply_llm_temperature", 0.2) or 0.2),
|
|
"max_tokens": int(getattr(settings, "clientflow_reply_llm_max_tokens", 700) or 700),
|
|
}
|
|
headers = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": "application/json",
|
|
"HTTP-Referer": "https://clientflow.blif.pt",
|
|
"X-Title": "ClientFlow BLIF Reply Assistant",
|
|
}
|
|
try:
|
|
with httpx.Client(timeout=timeout) as client:
|
|
response = client.post(settings.openrouter_url, headers=headers, json=payload)
|
|
if response.status_code >= 400:
|
|
raise LLMReplyError(f"OpenRouter HTTP {response.status_code}: {response.text[:300]}")
|
|
data = response.json()
|
|
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
|
if not content:
|
|
raise LLMReplyError("OpenRouter não devolveu conteúdo.")
|
|
parsed = json.loads(extract_first_json_object(content))
|
|
if not isinstance(parsed, dict):
|
|
raise ValueError("JSON gerado não é objeto")
|
|
return parsed
|
|
except LLMReplyError:
|
|
raise
|
|
except Exception as exc:
|
|
raise LLMReplyError(str(exc)) from exc
|
|
|
|
|
|
def build_llm_reply_context(
|
|
*,
|
|
task: Dict[str, Any],
|
|
template: Dict[str, Any],
|
|
baseline_message: str,
|
|
knowledge: KnowledgeMatch,
|
|
selected_documents: list[Dict[str, Any]],
|
|
) -> Dict[str, Any]:
|
|
customer_message = extract_customer_reply_text(task)
|
|
recipient = resolve_reply_recipient(task, cleaned_customer_message=customer_message)
|
|
preferred_greeting = recipient.get("preferred_greeting") or ""
|
|
max_chars = int(getattr(settings, "clientflow_reply_llm_max_context_chars", 5000) or 5000)
|
|
return {
|
|
"recipient": {
|
|
"person_name": recipient.get("person_name") or "",
|
|
"company_name": recipient.get("company_name") or "",
|
|
"greeting_source": recipient.get("greeting_source") or "",
|
|
"rule": "Usar person_name na saudação quando existir. Não usar company_name/fiscal_name como destinatário da saudação.",
|
|
},
|
|
"customer": {
|
|
"name": recipient.get("person_name") or recipient.get("contact_name") or "",
|
|
"contact_name": recipient.get("contact_name") or "",
|
|
"fiscal_name": task.get("linked_customer_name") or "",
|
|
"tax_id": task.get("linked_customer_tax_id") or "",
|
|
"email": task.get("customer_email") or task.get("linked_customer_email") or "",
|
|
},
|
|
"opportunity": {
|
|
"id": task.get("opportunity_id") or "",
|
|
"action_code": task.get("action_code") or "",
|
|
"status": task.get("opportunity_stage") or task.get("status") or "",
|
|
"documents": [
|
|
{
|
|
"id": doc.get("id"),
|
|
"kind": doc.get("document_kind"),
|
|
"number": doc.get("document_number") or doc.get("external_id"),
|
|
"amount": doc.get("total_amount") or doc.get("amount"),
|
|
"currency": doc.get("currency") or "EUR",
|
|
}
|
|
for doc in selected_documents
|
|
],
|
|
},
|
|
"customer_message": _trim(customer_message, max_chars),
|
|
"historico_recente_conversa": task.get("recent_conversation_context") or task.get("conversation_history") or task.get("previous_context") or [],
|
|
"preferred_greeting": preferred_greeting,
|
|
"selected_template": template,
|
|
"baseline_message": baseline_message,
|
|
"relevant_knowledge": knowledge.to_prompt_context(),
|
|
"catalog_reference": {
|
|
"products": list(knowledge.products),
|
|
"accessories": list(knowledge.accessories),
|
|
"note": "Preços de catálogo são sem IVA e podem mudar; usar apenas se relevante para a pergunta.",
|
|
},
|
|
"available_reply_types": [
|
|
"answer_without_attachment",
|
|
"answer_with_links_or_attachment",
|
|
"send_document",
|
|
"operational_ack",
|
|
"support_ack",
|
|
"no_customer_reply",
|
|
"manual_review",
|
|
],
|
|
"operator_instructions": [
|
|
"Responder em português de Portugal.",
|
|
"Começar a mensagem exatamente com preferred_greeting quando disponível.",
|
|
"Ser curto, claro e profissional.",
|
|
"Formatar a resposta com parágrafos curtos e listas com travessão quando houver preço, funcionalidades ou prazos.",
|
|
"Para pedidos de orçamento, dar uma proposta preliminar quando o catálogo tiver preço relevante.",
|
|
"Não repetir informação desnecessária.",
|
|
"Não inventar informação que não esteja no contexto.",
|
|
"Não dizer que segue anexo quando requires_attachment=false ou não há documento selecionado.",
|
|
],
|
|
}
|
|
|
|
|
|
def _to_bool(value: Any) -> bool:
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, str):
|
|
return value.strip().lower() in {"1", "true", "yes", "sim"}
|
|
return bool(value)
|
|
|
|
|
|
def normalize_llm_reply(raw: Dict[str, Any], *, fallback_message: str, knowledge: KnowledgeMatch, preferred_greeting: str = "") -> Dict[str, Any]:
|
|
message = str(raw.get("message_body") or "").strip() or fallback_message
|
|
message = _apply_preferred_greeting(message, preferred_greeting)
|
|
try:
|
|
confidence = float(raw.get("confidence") or 0.75)
|
|
except Exception:
|
|
confidence = 0.75
|
|
confidence = max(0.0, min(1.0, confidence))
|
|
return {
|
|
"intent": str(raw.get("intent") or (knowledge.primary_topic.id if knowledge.primary_topic else "business_reply")),
|
|
"reply_type": str(raw.get("reply_type") or knowledge.reply_type or "answer_without_attachment"),
|
|
"requires_attachment": _to_bool(raw.get("requires_attachment")),
|
|
"confidence": confidence,
|
|
"customer_need": str(raw.get("customer_need") or ""),
|
|
"recommended_next_action": str(raw.get("recommended_next_action") or ""),
|
|
"message_body": message,
|
|
"knowledge_used": list(raw.get("knowledge_used") or ([knowledge.primary_topic.id] if knowledge.primary_topic else [])),
|
|
"warnings": [str(item) for item in (raw.get("warnings") or []) if str(item).strip()],
|
|
}
|