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