Files
clientflow_backend/app/llm_reply_generator.py

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()],
}