Import ClientFlow production v4928.1.5.132.4
This commit is contained in:
928
app/email_reply_agent_service.py
Normal file
928
app/email_reply_agent_service.py
Normal 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 50–60 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,
|
||||
}
|
||||
Reference in New Issue
Block a user