Import ClientFlow production v4928.1.5.132.4

This commit is contained in:
plx
2026-07-29 13:11:01 +00:00
parent 6445044ac6
commit 261d342057
405 changed files with 48373 additions and 1401 deletions

View File

@@ -0,0 +1,928 @@
"""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,
}