"""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, }