Files
clientflow_backend/app/email_reply_agent_service.py

929 lines
45 KiB
Python
Raw Permalink 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.
"""OpenAI/file_search email reply agent for ClientFlow safe draft mode.
Phase 1 goal: generate structured, editable customer reply drafts from a task,
using the approved BLIF knowledge stored in an OpenAI vector store. This module
never sends messages; it only returns a suggested draft and metadata that the
operator can review in ClientFlow.
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Sequence
import httpx
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 EmailReplyAgentError(Exception):
"""Raised when the external reply agent cannot produce a safe draft."""
AGENT_INTENTS = [
"pedido_preco",
"confirmacao_encomenda",
"pagamento",
"envio_transitario",
"instalacao",
"instalacao_condominio",
"mobi_e_dpc",
"pedido_tecnico",
"suporte_instalacao",
"balanceador",
"rfid",
"sem_internet_historico",
"garantia",
"prazo_entrega",
"reclamacao",
"desconto",
"outro",
]
AGENT_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"resumo_pedido": {
"type": "string",
"description": "Resumo curto do que o cliente pediu.",
},
"intencao": {
"type": "string",
"enum": AGENT_INTENTS,
"description": (
"Categoria principal da mensagem. Se o cliente mencionar condomínio e faturação separada, "
"consumo fora da fatura do condomínio, eletricidade comum, administrador, contador do cliente, "
"MOBI.E ou DPC, usar mobi_e_dpc. Se perguntar apenas sobre instalação em condomínio, usar instalacao_condominio."
),
},
"prioridade": {
"type": "string",
"enum": ["baixa", "normal", "alta", "urgente"],
},
"informacao_usada_da_base": {
"type": "string",
"description": "Resumo da informação encontrada na base de conhecimento usada para responder. Não incluir a palavra Fonte nem referências vazias.",
},
"informacao_em_falta": {
"type": "string",
"description": "Dados que faltam para responder melhor. Usar string vazia se não faltar nada.",
},
"precisa_revisao_humana": {"type": "boolean"},
"motivo_revisao": {
"type": "string",
"description": "Motivo da revisão humana. Usar string vazia se não precisar.",
},
"resposta_sugerida": {
"type": "string",
"description": (
"Resposta pronta a enviar ao cliente. Deve ser clara, bem formatada, "
"com parágrafos curtos e listas com travessão quando houver preços, "
"funcionalidades, prazos, condições ou próximos passos."
),
},
"nivel_confianca": {
"type": "string",
"enum": ["baixo", "medio", "alto"],
},
},
"required": [
"resumo_pedido",
"intencao",
"prioridade",
"informacao_usada_da_base",
"informacao_em_falta",
"precisa_revisao_humana",
"motivo_revisao",
"resposta_sugerida",
"nivel_confianca",
],
"additionalProperties": False,
}
def email_reply_agent_enabled() -> bool:
return bool(getattr(settings, "clientflow_email_reply_agent_enabled", False))
def email_reply_agent_configured() -> bool:
return bool(
str(getattr(settings, "openai_api_key", "") or "").strip()
and str(getattr(settings, "openai_vector_store_id", "") or "").strip()
)
def build_agent_prompt() -> str:
return """És um assistente de atendimento comercial e suporte da Blif.
Contexto da empresa:
- A Blif atua no setor da mobilidade elétrica.
- A Blif fabrica carregadores para veículos elétricos e vende acessórios.
- A Blif não presta serviços diretos de instalação.
- A instalação dos carregadores pode ser feita por qualquer eletricista qualificado.
- A Blif pode dar suporte remoto ao eletricista do cliente, quando necessário.
Objetivo:
- Analisar emails ou mensagens recebidas por clientes.
- Consultar a base de conhecimento antes de responder sobre preços, produtos, acessórios, instalação, prazos, faturação, pagamento, envio, MOBI.E DPC ou condições comerciais.
- Gerar uma resposta pronta a enviar ao cliente, em português de Portugal.
Regras obrigatórias:
- Não inventar preços, prazos, condições, descontos, disponibilidade ou funcionalidades.
- Se a informação não estiver na base de conhecimento, dizer que é necessário confirmar internamente.
- Não prometer descontos ou exceções comerciais.
- Não confirmar encomendas, pagamentos, envios, stock ou condições especiais sem validação humana.
- Não dizer que o email foi enviado.
- Se faltarem dados importantes, pedir apenas a informação em falta.
Preços, IVA e faturação:
- Apresentar preços sempre como “s/IVA”, salvo se o cliente pedir explicitamente valores com IVA.
- Não escrever “+ IVA”; usar “s/IVA”.
- Não calcular valores com IVA, exceto se a taxa de IVA estiver explicitamente definida na base de conhecimento.
- Se o cliente pedir valores com IVA e a taxa não estiver definida na base, apresentar os valores s/IVA e dizer que os valores finais com IVA serão apresentados na proposta/fatura.
- Indicar que a fatura é emitida após confirmação de pagamento quando o tema for pagamento/encomenda.
- Se for necessário emitir fatura, pedir dados de faturação e morada, caso não tenham sido fornecidos.
Instalação:
- Se o cliente pedir instalação, explicar que a Blif não presta instalação direta.
- Indicar que qualquer eletricista qualificado pode instalar o equipamento.
- Indicar que a Blif pode dar suporte remoto ao eletricista, se necessário.
- Em pedidos sobre condomínios, explicar que a instalação é viável e pode ser analisada conforme o caso.
Condomínios e MOBI.E DPC:
- Se o cliente mencionar condomínio e faturação separada, consumo fora da fatura do condomínio, eletricidade comum, administrador, contador do cliente, MOBI.E ou DPC, classificar a intenção como mobi_e_dpc.
- Usar instalacao_condominio apenas quando o cliente perguntar genericamente sobre viabilidade ou instalação em condomínio, sem foco em faturação separada.
- Para pedidos sobre condomínios e faturação separada, explicar MOBI.E DPC de forma simples.
- Não prometer integração DPC/MOBI.E sem validação técnica; pedir os dados técnicos necessários se faltarem.
Envio e transitário:
- Para a maioria dos clientes, não mencionar transitário nem envio gratuito, salvo se o cliente perguntar diretamente.
- Só falar em transitário se o cliente mencionar ilhas, Madeira, Açores, transitário ou envio para fora do continente.
- Para clientes no continente, responder apenas com o prazo normal de entrega quando relevante.
- Se o cliente mencionar Madeira, Açores, ilhas ou transitário, explicar que a Blif pode enviar para transitário indicado pelo cliente no continente.
- Em casos de ilhas/transitário, pedir contactos e morada do transitário.
- Não apresentar envio gratuito como benefício geral.
- Marcar revisão humana se houver condição especial, exceção, dúvida de elegibilidade ou informação incompleta para confirmar o envio.
Regras comerciais e revisão humana:
- Se o cliente pedir desconto, marcar revisão humana.
- Se o cliente pedir cancelamento, reclamação, devolução, reembolso ou exceção comercial, marcar revisão humana.
- Se o cliente pedir dados de pagamento sem encomenda clara, marcar revisão humana.
- Se o cliente pedir confirmação de encomenda, envio, stock ou condições especiais, marcar revisão humana.
- Se houver conflito, reclamação ou insatisfação, responder com empatia e marcar revisão humana.
Regras técnicas da Blif:
- Em edifícios com vários pisos, betão, garagem subterrânea, estrutura densa ou grande distância, recomendar balanceador com fios Modbus/RS485.
- Se o cliente mencionar 5060 metros de cabo, grande distância ou cablagem entre pisos, não inventar cabo extra incluído; explicar que a cablagem/instalação deve ser validada e orçamentada pelo eletricista/instalador.
- Para perguntas sobre RFID, explicar que o leitor RFID permite cartões ilimitados e separação de consumos/histórico por utilizador.
- Para perguntas sobre funcionamento sem Internet, explicar que o histórico pode ser consultado localmente por Wi-Fi/Bluetooth e que se perde em caso de reset de fábrica.
- Para pedidos de suporte ao eletricista, explicar que a Blif pode dar apoio técnico remoto.
- Para pedidos de ficha técnica, datasheet, manual, dimensões ou documentação técnica, preferir URL público quando esse URL existir no contexto/base. Se o cliente pedir explicitamente anexo, mencionar anexo e incluir também o URL público quando disponível. Nunca inventar URLs.
- Para pedidos sobre ficha/tomada no carregador, adaptadores ou ligação a tomada, responder apenas com informação tecnicamente validada no contexto/base. Não recomendar adaptações inseguras; quando aplicável, indicar que deve ser validado por eletricista qualificado.
Correções de rascunho pelo operador:
- Quando o operador pedir para corrigir ou melhorar um rascunho, preservar a intenção do texto atual e aplicar apenas a instrução dada.
- Se o operador pedir para adicionar preços, usar apenas preços explícitos no contexto/base; se não existirem, indicar que é necessário confirmar.
- Se o operador pedir para adicionar ficha técnica ou documentos, usar apenas URLs/anexos existentes no contexto/base; não inventar.
Tom e estilo da resposta:
- Usar português de Portugal.
- Ser profissional, claro, simpático, objetivo e natural.
- Escrever como uma pessoa da equipa escreveria.
- Preferir cumprimento formal quando houver nome completo da pessoa que escreveu a última mensagem: “Bom dia Sr./Sra. Nome Completo,”, “Boa tarde Sr./Sra. Nome Completo,” ou equivalente.
- A pessoa que assina a última mensagem tem prioridade sobre o nome da empresa, cliente fiscal ou oportunidade.
- Nunca cumprimentar com designações de empresa, LDA, SA, UNIPESSOAL ou nomes fiscais; usar essas designações apenas como contexto.
- Se o email do cliente começar por “Boa tarde”, responder com “Boa tarde”; se começar por “Bom dia”, responder com “Bom dia”.
- Se o nome completo não estiver disponível, usar apenas “Bom dia,”, “Boa tarde,” ou “Olá,”.
- Só usar “Sr.”, “Sra.” ou o nome completo se essa informação estiver disponível no email ou no CRM; não inventar nomes.
- Se houver referência de outra pessoa no email ou no contexto, agradecer essa referência de forma natural; não inventar referências.
- Evitar respostas demasiado longas quando o pedido é simples.
- A resposta sugerida deve ser curta, natural e pronta a enviar.
- Terminar com uma frase curta, sem fórmulas excessivamente formais.
- Em respostas comerciais, propostas, encomendas ou pedidos de preço, terminar com:
Com os melhores cumprimentos,
Sérgio Araújo
Blif
Formato visual e legibilidade:
- Escrever respostas fáceis de ler, com parágrafos curtos.
- Separar claramente agradecimento, proposta/equipamento, funcionalidades, prazo de entrega e próximo passo.
- Usar listas com travessão “–” quando houver preços, funcionalidades, condições, prazos ou próximos passos.
- Evitar blocos grandes de texto.
- Em pedidos de preço ou proposta, usar estrutura de proposta comercial.
- 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.
- Não usar linguagem demasiado automática.
Estrutura para pedidos de preço/proposta:
- Quando a intenção for pedido_preco, proposta ou confirmacao_encomenda, seguir esta estrutura quando aplicável:
1. Cumprimento formal personalizado, se houver nome completo.
2. Agradecimento pelo contacto.
3. Referência à proposta/equipamento solicitado.
4. Linha do produto com preço s/IVA.
5. Lista curta de funcionalidades incluídas.
6. Prazo de entrega, se existir na base.
7. Próximo passo: ficha técnica, proposta formal, dados de faturação ou confirmação.
8. Assinatura:
Com os melhores cumprimentos,
Sérgio Araújo
Blif
Regras para os campos JSON:
- Devolver sempre JSON válido, sem texto fora do JSON.
- No campo informacao_usada_da_base, resumir apenas a informação encontrada na base de conhecimento.
- No campo informacao_usada_da_base, não escrever “Fonte:” nem referências vazias.
- No campo informacao_em_falta, usar string vazia se não faltar informação relevante.
- No campo motivo_revisao, usar string vazia se não precisar de revisão humana.
"""
def _trim(text: Any, max_chars: int = 5000) -> str:
value = str(text or "").strip()
if len(value) <= max_chars:
return value
return value[: max_chars - 1] + ""
def _document_context(selected_documents: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
docs: List[Dict[str, Any]] = []
for doc in selected_documents or []:
kind = str(doc.get("document_kind") or "").lower()
number = str(doc.get("document_number") or doc.get("external_id") or "")
operational_role = "proforma_orc" if kind == "quotation" and number.upper().startswith("ORC") else kind
docs.append({
"id": doc.get("id"),
"kind": kind,
"number": number,
"amount": doc.get("total_amount") or doc.get("amount"),
"currency": doc.get("currency") or "EUR",
"operational_role": operational_role,
})
return docs
def build_agent_input(
*,
task: Dict[str, Any],
knowledge: KnowledgeMatch,
selected_documents: Sequence[Dict[str, Any]],
) -> str:
customer_message = extract_customer_reply_text(task)
recipient = resolve_reply_recipient(task, cleaned_customer_message=customer_message)
context = {
"destinatario_resposta": {
"nome_pessoa": recipient.get("person_name") or "",
"nome_empresa": recipient.get("company_name") or "",
"cumprimento_preferido": recipient.get("preferred_greeting") or "",
"origem_cumprimento": recipient.get("greeting_source") or "",
"regra": "Usar nome_pessoa na saudação quando existir. Não usar nome_empresa/nome_fiscal como destinatário da saudação.",
},
"cliente": {
"nome_contacto": task.get("customer_name") or "",
"nome_fiscal": task.get("linked_customer_name") or "",
"email": task.get("customer_email") or task.get("linked_customer_email") or "",
"nif": task.get("linked_customer_tax_id") or "",
},
"tarefa": {
"id": task.get("id") or "",
"action_code": task.get("action_code") or "",
"route": task.get("route") or "",
"subject": task.get("message_subject") or task.get("subject") or "",
},
"objetivo_operador": task.get("communication_objective") or {},
"instrucao_operador": task.get("operator_instruction") or "",
"oportunidade": {
"id": task.get("opportunity_id") or "",
"stage": task.get("opportunity_stage") or task.get("status") or "",
},
"documentos_selecionados": _document_context(selected_documents),
"historico_recente_conversa": task.get("recent_conversation_context") or task.get("conversation_history") or task.get("previous_context") or [],
"conhecimento_deterministico_clientflow": knowledge.to_prompt_context(),
}
return (
"Mensagem recebida do cliente:\n"
f"{_trim(customer_message, 6000)}\n\n"
"Contexto ClientFlow para apoiar a resposta:\n"
f"{json.dumps(context, ensure_ascii=False, indent=2, default=str)}\n\n"
"Tarefa: gera uma resposta pronta a enviar ao cliente, obedecendo ao objetivo_operador quando existir. "
"Não alteres o objetivo operacional, não inventes anexos e não contradigas documentos selecionados. "
"Regra BLIF/ClientFlow: quando action_code/objetivo for SEND_PROFORMA, a pró-forma operacional é o orçamento Jasmin ORC.* selecionado; "
"diz que esse orçamento/proforma segue em anexo e não escrevas que ainda vais enviar proposta formal."
)
def _extract_output_text(data: Dict[str, Any]) -> str:
direct = str(data.get("output_text") or "").strip()
if direct:
return direct
parts: List[str] = []
for item in data.get("output") or []:
if not isinstance(item, dict):
continue
for content in item.get("content") or []:
if not isinstance(content, dict):
continue
if content.get("type") in {"output_text", "text"}:
text = str(content.get("text") or "").strip()
if text:
parts.append(text)
return "\n".join(parts).strip()
def _normalize_agent_result(result: Dict[str, Any]) -> Dict[str, Any]:
normalized = {
"resumo_pedido": str(result.get("resumo_pedido") or "").strip(),
"intencao": str(result.get("intencao") or "outro").strip() or "outro",
"prioridade": str(result.get("prioridade") or "normal").strip() or "normal",
"informacao_usada_da_base": str(result.get("informacao_usada_da_base") or "").strip(),
"informacao_em_falta": str(result.get("informacao_em_falta") or "").strip(),
"precisa_revisao_humana": bool(result.get("precisa_revisao_humana")),
"motivo_revisao": str(result.get("motivo_revisao") or "").strip(),
"resposta_sugerida": str(result.get("resposta_sugerida") or "").strip(),
"nivel_confianca": str(result.get("nivel_confianca") or "medio").strip() or "medio",
}
if normalized["intencao"] not in AGENT_INTENTS:
normalized["intencao"] = "outro"
if normalized["prioridade"] not in {"baixa", "normal", "alta", "urgente"}:
normalized["prioridade"] = "normal"
if normalized["nivel_confianca"] not in {"baixo", "medio", "alto"}:
normalized["nivel_confianca"] = "medio"
return normalized
FOLLOW_UP_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"objetivo_follow_up": {
"type": "string",
"description": "Objetivo curto do follow-up: proposta, pró-forma, pagamento, interesse ou genérico.",
},
"informacao_usada": {
"type": "string",
"description": "Dados concretos usados do contexto: cliente, documento, produto ou valor. Usar string vazia se não foram usados.",
},
"informacao_em_falta": {
"type": "string",
"description": "Informação que faltou para personalizar melhor. Usar string vazia se não faltar nada crítico.",
},
"precisa_revisao_humana": {"type": "boolean"},
"motivo_revisao": {
"type": "string",
"description": "Motivo da revisão humana. Usar string vazia se não precisar.",
},
"resposta_sugerida": {
"type": "string",
"description": "Mensagem curta de follow-up pronta a editar/enviar ao cliente.",
},
"nivel_confianca": {
"type": "string",
"enum": ["baixo", "medio", "alto"],
},
},
"required": [
"objetivo_follow_up",
"informacao_usada",
"informacao_em_falta",
"precisa_revisao_humana",
"motivo_revisao",
"resposta_sugerida",
"nivel_confianca",
],
"additionalProperties": False,
}
def build_follow_up_agent_prompt() -> str:
return """És um assistente comercial da Blif especializado em follow-ups.
Contexto da empresa:
- A Blif atua no setor da mobilidade elétrica.
- A Blif fabrica/fornece carregadores para veículos elétricos e acessórios.
- A Blif não presta instalação direta; a instalação pode ser feita por eletricista qualificado.
- A Blif pode dar suporte remoto ao eletricista quando necessário.
Objetivo único:
- Gerar uma mensagem curta de follow-up para a Blif enviar ao cliente.
- A mensagem deve ser personalizada com dados disponíveis do cliente, oportunidade, documento, valor, produto e histórico recente.
- A mensagem nunca é enviada automaticamente; será revista pelo operador.
Regras obrigatórias para FOLLOW_UP_PAYMENT:
- O objetivo é confirmar se o cliente recebeu os dados/documento de pagamento e se precisa de alguma informação para avançar.
- Não escrever que a Blif vai confirmar internamente se o pagamento foi recebido.
- Não afirmar que o pagamento foi recebido.
- Não pedir comprovativo salvo se o contexto disser explicitamente que esse é o objetivo.
- Não transformar o follow-up numa resposta operacional interna.
- No ClientFlow/BLIF, um orçamento Jasmin ORC.* pode funcionar como pró-forma operacional.
- Se o documento selecionado for ORC.* e o objetivo/follow-up for pró-forma ou pagamento, podes chamar-lhe “orçamento/proforma” ou “pró-forma”.
- Não inventar uma pró-forma separada se o documento selecionado for o ORC.*.
Regras obrigatórias para FOLLOW_UP_QUOTE:
- Confirmar se o cliente recebeu o orçamento/proposta e se ficou com dúvidas.
- Pode perguntar se pretende avançar para emissão de pró-forma quando isso fizer sentido.
Regras obrigatórias para FOLLOW_UP_PROFORMA:
- Confirmar se recebeu a pró-forma/dados de pagamento e se está tudo claro para avançar.
Regras obrigatórias para FOLLOW_UP_CUSTOMER_REVIEW:
- Confirmar se mantém interesse e se precisa de informação adicional.
Regras gerais:
- Usar português de Portugal.
- Ser profissional, natural, curto e humano.
- Não inventar preços, documentos, anexos, prazos, stock, descontos, URLs, condições especiais ou estado de pagamento.
- Usar apenas dados explícitos no contexto/documentos/base de conhecimento.
- Não mencionar notas internas, backfill, metadata, task, sistema, IA, ClientFlow ou classificação.
- Não mencionar informação fiscal incompleta, a menos que a mensagem tenha como objetivo pedir esses dados.
- Não usar tom agressivo, pressão excessiva ou urgência falsa.
- Se houver cumprimento_preferido com confiança média/alta, começar exatamente por esse cumprimento.
- Se o nome vier de email pessoal com confiança alta, pode usar primeiro nome formal, por exemplo “Bom dia Sr. Nuno,”.
- Se não houver nome de pessoa seguro, usar cumprimento neutro: “Bom dia,” ou “Olá,”.
- Se houver documento/valor explícito, pode mencionar de forma simples; se não houver, escrever mensagem neutra.
- Não assinar com nome de operador se não existir operador confirmado no contexto. Preferir “Obrigado.” ou “Com os melhores cumprimentos,\nBlif”.
Formato da resposta:
- Devolver sempre JSON válido, sem texto fora do JSON.
- O campo resposta_sugerida deve conter só a mensagem para o cliente, pronta a editar/enviar.
"""
def _follow_up_objective(action_code: str) -> str:
code = str(action_code or "").strip().upper()
return {
"FOLLOW_UP_PAYMENT": "Confirmar se recebeu a pró-forma/dados de pagamento e se precisa de informação adicional para avançar.",
"FOLLOW_UP_PROFORMA": "Confirmar se recebeu a pró-forma/dados de pagamento e se está tudo claro para avançar.",
"FOLLOW_UP_QUOTE": "Confirmar se recebeu o orçamento/proposta e se ficou com dúvidas.",
"FOLLOW_UP_CUSTOMER_REVIEW": "Confirmar se mantém interesse e se precisa de informação adicional.",
"FOLLOW_UP_GENERIC": "Dar seguimento ao processo e confirmar se precisa de informação adicional.",
}.get(code, "Dar seguimento ao processo e confirmar se precisa de informação adicional.")
def build_follow_up_agent_input(
*,
task: Dict[str, Any],
knowledge: KnowledgeMatch,
selected_documents: Sequence[Dict[str, Any]],
baseline_message: str,
) -> str:
customer_message = extract_customer_reply_text(task)
recipient = resolve_reply_recipient(task, cleaned_customer_message=customer_message)
action_code = str(task.get("action_code") or "").strip().upper()
context = {
"modo": "follow_up_personalizado",
"objetivo_obrigatorio": _follow_up_objective(action_code),
"destinatario_resposta": {
"nome_pessoa": recipient.get("person_name") or "",
"primeiro_nome": recipient.get("person_first_name") or "",
"confianca_nome_pessoa": recipient.get("person_confidence") or "",
"origem_nome_pessoa": recipient.get("greeting_source") or "",
"email": recipient.get("email") or "",
"nome_empresa": recipient.get("company_name") or "",
"cumprimento_preferido": recipient.get("preferred_greeting") or "",
"regra": "Começar exatamente por cumprimento_preferido quando existir. Se origem_nome_pessoa=email e confianca_nome_pessoa for alto, é permitido usar saudação formal com primeiro nome. Não usar nome fiscal/empresa como destinatário da saudação.",
},
"cliente": {
"nome_contacto": task.get("customer_name") or "",
"nome_fiscal": task.get("linked_customer_name") or "",
"email": task.get("customer_email") or task.get("linked_customer_email") or "",
"nif": task.get("linked_customer_tax_id") or "",
},
"tarefa": {
"id": task.get("id") or "",
"action_code": action_code,
"route": task.get("route") or "",
"subject": task.get("message_subject") or task.get("subject") or "",
"nota_interna_nao_copiar": _trim(task.get("note") or task.get("message_body") or "", 1200),
},
"oportunidade": {
"id": task.get("opportunity_id") or "",
"stage": task.get("opportunity_stage") or task.get("status") or "",
"produto_interesse": task.get("product_interest") or task.get("opportunity_product_interest") or "",
"valor": task.get("value_amount") or task.get("opportunity_value_amount") or "",
"moeda": task.get("currency") or task.get("opportunity_currency") or "EUR",
},
"documentos_selecionados": _document_context(selected_documents),
"historico_recente_conversa": task.get("recent_conversation_context") or task.get("conversation_history") or task.get("previous_context") or [],
"mensagem_base_segura": baseline_message,
"conhecimento_deterministico_clientflow": knowledge.to_prompt_context(),
"restricoes_criticas": [
"Preservar o objetivo_obrigatorio; não mudar para outra ação operacional.",
"Para FOLLOW_UP_PAYMENT, não dizer que vamos confirmar internamente se o pagamento foi recebido.",
"Não dizer que o pagamento foi recebido.",
"Não incluir a nota interna nem texto de backfill.",
"Não inventar documento, preço, URL, stock, prazo, assinatura ou nome de pessoa.",
],
}
return (
"Gera um rascunho personalizado de follow-up ao cliente. "
"Usa a mensagem_base_segura como base e melhora apenas com dados concretos do contexto.\n\n"
f"{json.dumps(context, ensure_ascii=False, indent=2, default=str)}"
)
def _normalize_follow_up_agent_result(result: Dict[str, Any]) -> Dict[str, Any]:
normalized = {
"objetivo_follow_up": str(result.get("objetivo_follow_up") or "").strip(),
"informacao_usada": str(result.get("informacao_usada") or "").strip(),
"informacao_em_falta": str(result.get("informacao_em_falta") or "").strip(),
"precisa_revisao_humana": bool(result.get("precisa_revisao_humana")),
"motivo_revisao": str(result.get("motivo_revisao") or "").strip(),
"resposta_sugerida": str(result.get("resposta_sugerida") or "").strip(),
"nivel_confianca": str(result.get("nivel_confianca") or "medio").strip() or "medio",
}
if normalized["nivel_confianca"] not in {"baixo", "medio", "alto"}:
normalized["nivel_confianca"] = "medio"
return normalized
REVISION_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"resposta_revisada": {
"type": "string",
"description": "Nova versão completa do rascunho, pronta a editar/enviar.",
},
"alteracoes_aplicadas": {
"type": "string",
"description": "Resumo curto do que foi alterado.",
},
"avisos": {
"type": "string",
"description": "Avisos para o operador. Usar string vazia se não houver.",
},
"precisa_revisao_humana": {"type": "boolean"},
"motivo_revisao": {
"type": "string",
"description": "Motivo da revisão humana. Usar string vazia se não precisar.",
},
"informacao_em_falta": {
"type": "string",
"description": "Dados que faltam para aplicar a correção com segurança. Usar string vazia se não faltar nada.",
},
},
"required": [
"resposta_revisada",
"alteracoes_aplicadas",
"avisos",
"precisa_revisao_humana",
"motivo_revisao",
"informacao_em_falta",
],
"additionalProperties": False,
}
def build_revision_agent_input(
*,
task: Dict[str, Any],
knowledge: KnowledgeMatch,
selected_documents: Sequence[Dict[str, Any]],
current_body: str,
instruction: str,
) -> str:
customer_message = extract_customer_reply_text(task)
recipient = resolve_reply_recipient(task, cleaned_customer_message=customer_message)
context = {
"instrucao_do_operador": str(instruction or "").strip(),
"rascunho_atual": _trim(current_body, 8000),
"mensagem_atual_cliente": _trim(customer_message, 6000),
"historico_recente_conversa": task.get("recent_conversation_context") or task.get("conversation_history") or task.get("previous_context") or [],
"destinatario_resposta": {
"nome_pessoa": recipient.get("person_name") or "",
"nome_empresa": recipient.get("company_name") or "",
"cumprimento_preferido": recipient.get("preferred_greeting") or "",
"regra": "Preservar/usar nome_pessoa na saudação quando existir. Não usar nome_empresa/nome_fiscal como destinatário da saudação.",
},
"cliente": {
"nome_contacto": task.get("customer_name") or "",
"nome_fiscal": task.get("linked_customer_name") or "",
"email": task.get("customer_email") or task.get("linked_customer_email") or "",
"nif": task.get("linked_customer_tax_id") or "",
},
"tarefa": {
"id": task.get("id") or "",
"action_code": task.get("action_code") or "",
"route": task.get("route") or "",
"subject": task.get("message_subject") or task.get("subject") or "",
},
"objetivo_operador": task.get("communication_objective") or {},
"instrucao_operador": task.get("operator_instruction") or "",
"oportunidade": {
"id": task.get("opportunity_id") or "",
"stage": task.get("opportunity_stage") or task.get("status") or "",
},
"documentos_selecionados": _document_context(selected_documents),
"conhecimento_deterministico_clientflow": knowledge.to_prompt_context(),
"regras_de_correcao": [
"Aplicar a instrução do operador sem inventar preços, URLs, anexos, stock, descontos ou condições comerciais.",
"Se a instrução pedir preço, usar apenas preço existente no contexto/base. Se não existir, indicar que falta confirmação.",
"Se a instrução pedir ficha técnica/URL/anexo, usar apenas documentos/URLs existentes no contexto/base.",
"Considerar as últimas 2-3 mensagens da conversa para evitar repetir ou contradizer informação já trocada.",
"Manter português de Portugal e resposta pronta a enviar.",
],
}
return (
"Corrige o rascunho atual de resposta ao cliente com base na instrução do operador.\n"
"Devolve uma versão completa do rascunho, não apenas o trecho alterado.\n\n"
f"{json.dumps(context, ensure_ascii=False, indent=2, default=str)}"
)
def revise_email_reply_agent(
*,
task: Dict[str, Any],
knowledge: KnowledgeMatch,
selected_documents: Sequence[Dict[str, Any]],
current_body: str,
instruction: str,
) -> Dict[str, Any]:
"""Revise an existing editable draft using OpenAI/file_search and operator instructions."""
if not email_reply_agent_enabled():
raise EmailReplyAgentError("CLIENTFLOW_EMAIL_REPLY_AGENT_ENABLED=false")
if not email_reply_agent_configured():
raise EmailReplyAgentError("OPENAI_API_KEY ou OPENAI_VECTOR_STORE_ID não configurados.")
instruction = str(instruction or "").strip()
if not instruction:
raise EmailReplyAgentError("Instrução de correção vazia.")
current_body = str(current_body or "").strip()
if not current_body:
raise EmailReplyAgentError("Rascunho atual vazio.")
api_key = str(getattr(settings, "openai_api_key", "") or "").strip()
vector_store_id = str(getattr(settings, "openai_vector_store_id", "") or "").strip()
model = str(getattr(settings, "clientflow_email_reply_agent_model", "") or "").strip() or "gpt-4.1-mini"
timeout = float(getattr(settings, "clientflow_email_reply_agent_timeout_seconds", 30) or 30)
max_results = int(getattr(settings, "clientflow_email_reply_agent_max_results", 6) or 6)
url = str(getattr(settings, "openai_responses_url", "") or "https://api.openai.com/v1/responses").strip()
payload: Dict[str, Any] = {
"model": model,
"instructions": build_agent_prompt(),
"input": build_revision_agent_input(
task=task,
knowledge=knowledge,
selected_documents=selected_documents,
current_body=current_body,
instruction=instruction,
),
"tools": [
{
"type": "file_search",
"vector_store_ids": [vector_store_id],
"max_num_results": max_results,
}
],
"text": {
"format": {
"type": "json_schema",
"name": "clientflow_blif_email_reply_revision",
"schema": REVISION_SCHEMA,
"strict": True,
}
},
}
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
try:
with httpx.Client(timeout=timeout) as client:
response = client.post(url, headers=headers, json=payload)
if response.status_code >= 400:
raise EmailReplyAgentError(f"OpenAI HTTP {response.status_code}: {response.text[:500]}")
data = response.json()
output_text = _extract_output_text(data)
if not output_text:
raise EmailReplyAgentError("OpenAI não devolveu output_text.")
parsed = json.loads(output_text)
if not isinstance(parsed, dict):
raise ValueError("JSON gerado não é objeto.")
revised = str(parsed.get("resposta_revisada") or "").strip()
if not revised:
raise EmailReplyAgentError("OpenAI não devolveu resposta_revisada.")
recipient = resolve_reply_recipient(task, cleaned_customer_message=extract_customer_reply_text(task))
parsed["resposta_revisada"] = apply_preferred_greeting(revised, recipient.get("preferred_greeting") or "")
parsed["metadata"] = {
"enabled": True,
"used": True,
"status": "used",
"provider": "openai_responses_file_search",
"mode": "draft_revision",
"model": model,
"vector_store_id": vector_store_id,
"prompt_version": str(getattr(settings, "clientflow_email_reply_agent_prompt_version", "") or "blif-email-agent-phase1-readability-ui-formal-20260613"),
"response_id": data.get("id") or "",
"operator_instruction": instruction,
}
return parsed
except EmailReplyAgentError:
raise
except Exception as exc:
raise EmailReplyAgentError(str(exc)) from exc
def generate_email_reply_agent(
*,
task: Dict[str, Any],
knowledge: KnowledgeMatch,
selected_documents: Sequence[Dict[str, Any]],
) -> Dict[str, Any]:
"""Call OpenAI Responses API and return a normalized agent result.
The returned object is safe metadata + a suggested message body. It never
sends a message and never mutates ClientFlow state directly.
"""
if not email_reply_agent_enabled():
raise EmailReplyAgentError("CLIENTFLOW_EMAIL_REPLY_AGENT_ENABLED=false")
if not email_reply_agent_configured():
raise EmailReplyAgentError("OPENAI_API_KEY ou OPENAI_VECTOR_STORE_ID não configurados.")
api_key = str(getattr(settings, "openai_api_key", "") or "").strip()
vector_store_id = str(getattr(settings, "openai_vector_store_id", "") or "").strip()
model = str(getattr(settings, "clientflow_email_reply_agent_model", "") or "").strip() or "gpt-4.1-mini"
timeout = float(getattr(settings, "clientflow_email_reply_agent_timeout_seconds", 30) or 30)
max_results = int(getattr(settings, "clientflow_email_reply_agent_max_results", 6) or 6)
url = str(getattr(settings, "openai_responses_url", "") or "https://api.openai.com/v1/responses").strip()
payload: Dict[str, Any] = {
"model": model,
"instructions": build_agent_prompt(),
"input": build_agent_input(task=task, knowledge=knowledge, selected_documents=selected_documents),
"tools": [
{
"type": "file_search",
"vector_store_ids": [vector_store_id],
"max_num_results": max_results,
}
],
"text": {
"format": {
"type": "json_schema",
"name": "clientflow_blif_email_reply_agent",
"schema": AGENT_SCHEMA,
"strict": True,
}
},
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
try:
with httpx.Client(timeout=timeout) as client:
response = client.post(url, headers=headers, json=payload)
if response.status_code >= 400:
raise EmailReplyAgentError(f"OpenAI HTTP {response.status_code}: {response.text[:500]}")
data = response.json()
output_text = _extract_output_text(data)
if not output_text:
raise EmailReplyAgentError("OpenAI não devolveu output_text.")
parsed = json.loads(output_text)
if not isinstance(parsed, dict):
raise ValueError("JSON gerado não é objeto.")
normalized = _normalize_agent_result(parsed)
recipient = resolve_reply_recipient(task, cleaned_customer_message=extract_customer_reply_text(task))
normalized["resposta_sugerida"] = apply_preferred_greeting(
normalized.get("resposta_sugerida") or "",
recipient.get("preferred_greeting") or "",
)
normalized["metadata"] = {
"enabled": True,
"used": True,
"status": "used",
"provider": "openai_responses_file_search",
"model": model,
"vector_store_id": vector_store_id,
"prompt_version": str(getattr(settings, "clientflow_email_reply_agent_prompt_version", "") or "blif-email-agent-phase1-readability-ui-formal-20260613"),
"response_id": data.get("id") or "",
"recipient_person_name": recipient.get("person_name") or "",
"recipient_company_name": recipient.get("company_name") or "",
"preferred_greeting": recipient.get("preferred_greeting") or "",
"greeting_source": recipient.get("greeting_source") or "",
"intencao": normalized["intencao"],
"prioridade": normalized["prioridade"],
"precisa_revisao_humana": normalized["precisa_revisao_humana"],
"motivo_revisao": normalized["motivo_revisao"],
"nivel_confianca": normalized["nivel_confianca"],
"informacao_usada_da_base": normalized["informacao_usada_da_base"],
"informacao_em_falta": normalized["informacao_em_falta"],
}
return normalized
except EmailReplyAgentError:
raise
except Exception as exc:
raise EmailReplyAgentError(str(exc)) from exc
def generate_follow_up_draft_agent(
*,
task: Dict[str, Any],
knowledge: KnowledgeMatch,
selected_documents: Sequence[Dict[str, Any]],
baseline_message: str,
) -> Dict[str, Any]:
"""Generate a dedicated OpenAI follow-up draft.
This intentionally does not reuse the generic email reply prompt because a
follow-up is not a customer support answer. The agent receives the safe
template as baseline and may only personalize it with explicit context.
"""
if not email_reply_agent_enabled():
raise EmailReplyAgentError("CLIENTFLOW_EMAIL_REPLY_AGENT_ENABLED=false")
if not email_reply_agent_configured():
raise EmailReplyAgentError("OPENAI_API_KEY ou OPENAI_VECTOR_STORE_ID não configurados.")
api_key = str(getattr(settings, "openai_api_key", "") or "").strip()
vector_store_id = str(getattr(settings, "openai_vector_store_id", "") or "").strip()
model = str(getattr(settings, "clientflow_email_reply_agent_model", "") or "").strip() or "gpt-4.1-mini"
timeout = float(getattr(settings, "clientflow_email_reply_agent_timeout_seconds", 30) or 30)
max_results = int(getattr(settings, "clientflow_email_reply_agent_max_results", 6) or 6)
url = str(getattr(settings, "openai_responses_url", "") or "https://api.openai.com/v1/responses").strip()
payload: Dict[str, Any] = {
"model": model,
"instructions": build_follow_up_agent_prompt(),
"input": build_follow_up_agent_input(
task=task,
knowledge=knowledge,
selected_documents=selected_documents,
baseline_message=baseline_message,
),
"tools": [
{
"type": "file_search",
"vector_store_ids": [vector_store_id],
"max_num_results": max_results,
}
],
"text": {
"format": {
"type": "json_schema",
"name": "clientflow_blif_follow_up_draft_agent",
"schema": FOLLOW_UP_SCHEMA,
"strict": True,
}
},
}
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
try:
with httpx.Client(timeout=timeout) as client:
response = client.post(url, headers=headers, json=payload)
if response.status_code >= 400:
raise EmailReplyAgentError(f"OpenAI HTTP {response.status_code}: {response.text[:500]}")
data = response.json()
output_text = _extract_output_text(data)
if not output_text:
raise EmailReplyAgentError("OpenAI não devolveu output_text.")
parsed = json.loads(output_text)
if not isinstance(parsed, dict):
raise ValueError("JSON gerado não é objeto.")
normalized = _normalize_follow_up_agent_result(parsed)
if not normalized["resposta_sugerida"]:
raise EmailReplyAgentError("OpenAI não devolveu resposta_sugerida.")
recipient = resolve_reply_recipient(task, cleaned_customer_message=extract_customer_reply_text(task))
normalized["resposta_sugerida"] = apply_preferred_greeting(
normalized.get("resposta_sugerida") or "",
recipient.get("preferred_greeting") or "",
)
normalized["metadata"] = {
"enabled": True,
"used": True,
"status": "used",
"provider": "openai_responses_file_search",
"mode": "follow_up_draft",
"model": model,
"vector_store_id": vector_store_id,
"prompt_version": "blif-follow-up-draft-v4928-1-5-24-contact-person",
"response_id": data.get("id") or "",
"action_code": task.get("action_code") or "",
"objetivo_follow_up": normalized["objetivo_follow_up"],
"informacao_usada": normalized["informacao_usada"],
"informacao_em_falta": normalized["informacao_em_falta"],
"precisa_revisao_humana": normalized["precisa_revisao_humana"],
"motivo_revisao": normalized["motivo_revisao"],
"nivel_confianca": normalized["nivel_confianca"],
"recipient_person_name": recipient.get("person_name") or "",
"recipient_company_name": recipient.get("company_name") or "",
"preferred_greeting": recipient.get("preferred_greeting") or "",
"recipient_first_name": recipient.get("person_first_name") or "",
"recipient_name_confidence": recipient.get("person_confidence") or "",
"greeting_source": recipient.get("greeting_source") or "",
}
return normalized
except EmailReplyAgentError:
raise
except Exception as exc:
raise EmailReplyAgentError(str(exc)) from exc
def disabled_agent_state(error: str = "") -> Dict[str, Any]:
return {
"enabled": email_reply_agent_enabled(),
"used": False,
"status": "disabled" if not email_reply_agent_enabled() else "fallback",
"provider": "openai_responses_file_search",
"error": error,
}