Spaces:
Running
Running
| """Cliente de template (relatório sem IA). | |
| Gera o relatório diário em Markdown a partir do JSON de métricas já calculado | |
| em Python. É o modo mais confiável e obrigatório do MVP: funciona sempre, | |
| mesmo sem nenhum modelo de IA instalado. | |
| Quando uma narrativa de IA é fornecida (``narrativa_ia``), ela substitui | |
| apenas a seção de Resumo Executivo/diagnóstico — todos os números das demais | |
| seções continuam vindo do cálculo em Python. | |
| """ | |
| from __future__ import annotations | |
| from typing import Any | |
| # Rótulos de severidade em português para exibição. | |
| _ROTULO_SEVERIDADE = {"high": "🔴 Alta", "medium": "🟠 Média", "low": "🟡 Baixa"} | |
| # ---------------------------------------------------------------------- | |
| # Helpers de formatação | |
| # ---------------------------------------------------------------------- | |
| def _moeda(valor: Any) -> str: | |
| """Formata um número como moeda em Real (R$ 1.234,56).""" | |
| try: | |
| numero = float(valor or 0.0) | |
| except (TypeError, ValueError): | |
| return "R$ 0,00" | |
| inteiro = f"{numero:,.2f}" | |
| # Converte do padrão en-US (1,234.56) para pt-BR (1.234,56). | |
| inteiro = inteiro.replace(",", "X").replace(".", ",").replace("X", ".") | |
| return f"R$ {inteiro}" | |
| def _num(valor: Any, padrao: str = "—") -> str: | |
| """Formata um número inteiro/decimal de forma amigável.""" | |
| if valor is None: | |
| return padrao | |
| if isinstance(valor, bool): | |
| return "Sim" if valor else "Não" | |
| if isinstance(valor, int): | |
| return str(valor) | |
| if isinstance(valor, float): | |
| return f"{valor:.2f}".rstrip("0").rstrip(".") if valor % 1 else str(int(valor)) | |
| return str(valor) | |
| def _pct(valor: Any) -> str: | |
| """Formata um percentual (ex.: ``12.5`` → ``12,5%``).""" | |
| if valor is None: | |
| return "—" | |
| try: | |
| return f"{float(valor):.1f}%".replace(".", ",") | |
| except (TypeError, ValueError): | |
| return "—" | |
| def _variacao(metricas: dict[str, Any], chave: str) -> str: | |
| """Monta um texto curto de variação (vs dia anterior / vs 7 dias).""" | |
| comparacoes = metricas.get("comparisons", {}) | |
| item = comparacoes.get(chave) | |
| if not item: | |
| return "" | |
| partes: list[str] = [] | |
| vp = item.get("variation_percent_vs_previous") | |
| if vp is not None: | |
| seta = "▲" if vp > 0 else ("▼" if vp < 0 else "▬") | |
| partes.append(f"{seta} {abs(vp):.1f}% vs ontem".replace(".", ",")) | |
| v7 = item.get("variation_percent_vs_7day_avg") | |
| if v7 is not None: | |
| seta = "▲" if v7 > 0 else ("▼" if v7 < 0 else "▬") | |
| partes.append(f"{seta} {abs(v7):.1f}% vs 7d".replace(".", ",")) | |
| return f" ({'; '.join(partes)})" if partes else "" | |
| def _linha(rotulo: str, valor: str, sufixo: str = "") -> str: | |
| """Linha de lista Markdown padronizada (fato).""" | |
| return f"- **{rotulo}:** {valor}{sufixo}" | |
| def _insight(texto: str) -> str: | |
| """Linha de insight (storytelling por regras) ao fim de uma seção.""" | |
| return f"\n> 💡 **Insight:** {texto}" | |
| # ---------------------------------------------------------------------- | |
| # Seções do relatório | |
| # ---------------------------------------------------------------------- | |
| def _secao_oportunidades(m: dict[str, Any]) -> list[str]: | |
| linhas = ["## 2. Oportunidades", ""] | |
| if not m: | |
| linhas.append("_Sem dados de oportunidades no período._") | |
| return linhas | |
| linhas += [ | |
| _linha("Novas", _num(m.get("new_opportunities")), _variacao(m, "new_opportunities")), | |
| _linha("Abertas", _num(m.get("open_opportunities")), _variacao(m, "open_opportunities")), | |
| _linha("Ganhas", _num(m.get("won_opportunities")), _variacao(m, "won_opportunities")), | |
| _linha("Perdidas", _num(m.get("lost_opportunities")), _variacao(m, "lost_opportunities")), | |
| _linha("Pipeline aberto", _moeda(m.get("open_pipeline_amount")), _variacao(m, "open_pipeline_amount")), | |
| _linha("Valor ganho", _moeda(m.get("won_amount")), _variacao(m, "won_amount")), | |
| _linha("Valor perdido", _moeda(m.get("lost_amount"))), | |
| _linha("Win rate", _pct(m.get("win_rate"))), | |
| _linha("Loss rate", _pct(m.get("loss_rate"))), | |
| _linha("Paradas (sem atividade)", _num(m.get("stalled_opportunities"))), | |
| _linha("Sem próxima tarefa", _num(m.get("opportunities_without_next_task"))), | |
| _linha("Alto valor paradas", _num(m.get("high_value_stalled_opportunities"))), | |
| _linha("Fecham no mês", _num(m.get("opportunities_closing_this_month"))), | |
| ] | |
| won = int(m.get("won_opportunities") or 0) | |
| lost = int(m.get("lost_opportunities") or 0) | |
| alto = int(m.get("high_value_stalled_opportunities") or 0) | |
| if alto: | |
| linhas.append(_insight( | |
| f"{alto} oportunidade(s) de alto valor parada(s) concentram o risco do dia — " | |
| "priorize o contato antes que esfriem.")) | |
| elif won > lost: | |
| linhas.append(_insight(f"Saldo positivo: {won} ganha(s) contra {lost} perdida(s) hoje.")) | |
| elif lost > won: | |
| linhas.append(_insight( | |
| f"Mais perdas que ganhos hoje ({lost} x {won}) — vale revisar objeções recorrentes.")) | |
| return linhas | |
| def _secao_leads(m: dict[str, Any]) -> list[str]: | |
| linhas = ["## 3. Leads", ""] | |
| if not m: | |
| linhas.append("_Sem dados de leads no período._") | |
| return linhas | |
| linhas += [ | |
| _linha("Novos", _num(m.get("new_leads")), _variacao(m, "new_leads")), | |
| _linha("Convertidos", _num(m.get("converted_leads")), _variacao(m, "converted_leads")), | |
| _linha("Taxa de conversão", _pct(m.get("conversion_rate")), _variacao(m, "conversion_rate")), | |
| _linha("Sem primeira tarefa", _num(m.get("leads_without_first_task"))), | |
| _linha("Tempo médio até 1ª tarefa (h)", _num(m.get("avg_time_to_first_task_hours"))), | |
| _linha("Tempo mediano até 1ª tarefa (h)", _num(m.get("median_time_to_first_task_hours"))), | |
| _linha("Origem de maior volume", _num(m.get("top_lead_source_by_volume"))), | |
| _linha("Melhor origem (conversão)", _num(m.get("best_lead_source_by_conversion"))), | |
| _linha("Pior origem (conversão)", _num(m.get("worst_lead_source_by_conversion"))), | |
| ] | |
| sem = int(m.get("leads_without_first_task") or 0) | |
| conv = m.get("conversion_rate") | |
| if sem: | |
| linhas.append(_insight( | |
| f"{sem} lead(s) sem a primeira tarefa — o follow-up rápido é o ganho mais barato hoje.")) | |
| elif isinstance(conv, (int, float)): | |
| linhas.append(_insight( | |
| f"Conversão do dia em {_pct(conv)}; acompanhe a melhor origem para replicar o que funciona.")) | |
| return linhas | |
| def _secao_tarefas(m: dict[str, Any]) -> list[str]: | |
| linhas = ["## 4. Tarefas", ""] | |
| if not m: | |
| linhas.append("_Sem dados de tarefas no período._") | |
| return linhas | |
| linhas += [ | |
| _linha("Criadas", _num(m.get("tasks_created")), _variacao(m, "tasks_created")), | |
| _linha("Concluídas", _num(m.get("tasks_completed"))), | |
| _linha("Taxa de conclusão", _pct(m.get("completion_rate"))), | |
| _linha("Vencidas", _num(m.get("tasks_overdue")), _variacao(m, "tasks_overdue")), | |
| _linha("Futuras", _num(m.get("tasks_future"))), | |
| _linha("Vencidas ligadas a leads", _num(m.get("overdue_tasks_linked_to_leads"))), | |
| _linha("Vencidas ligadas a oportunidades", _num(m.get("overdue_tasks_linked_to_opportunities"))), | |
| _linha("Atraso médio das vencidas (dias)", _num(m.get("avg_overdue_delay_days"))), | |
| ] | |
| if m.get("top_overdue_owner"): | |
| linhas.append( | |
| _linha( | |
| "Responsável com mais vencidas", | |
| f"{m.get('top_overdue_owner')} ({_num(m.get('top_overdue_owner_count'))})", | |
| ) | |
| ) | |
| venc = int(m.get("tasks_overdue") or 0) | |
| taxa = m.get("completion_rate") | |
| if venc: | |
| linhas.append(_insight( | |
| f"{venc} tarefa(s) vencida(s) no total — concentre o esforço nas ligadas a negócios de maior valor.")) | |
| elif isinstance(taxa, (int, float)): | |
| linhas.append(_insight(f"Taxa de conclusão do dia em {_pct(taxa)}.")) | |
| return linhas | |
| def _secao_satisfacao(m: dict[str, Any]) -> list[str]: | |
| linhas = ["## 5. Satisfação", ""] | |
| if not m or not m.get("configured"): | |
| msg = (m or {}).get("message") or ( | |
| "Fonte de satisfação ainda não configurada. " | |
| "Configure o objeto e os campos em object_mapping." | |
| ) | |
| linhas.append(f"> ⚙️ {msg}") | |
| return linhas | |
| if not m.get("responses"): | |
| linhas.append(f"> {m.get('message') or 'Sem respostas no período.'}") | |
| return linhas | |
| linhas += [ | |
| _linha("Nota média", _num(m.get("avg_score")), _variacao(m, "avg_score")), | |
| _linha("Respostas", _num(m.get("responses"))), | |
| _linha("Avaliações negativas", _num(m.get("negative_count"))), | |
| ] | |
| motivos = m.get("top_negative_reasons") or {} | |
| if motivos: | |
| itens = ", ".join(f"{k} ({v})" for k, v in motivos.items()) | |
| linhas.append(_linha("Principais motivos negativos", itens)) | |
| neg = int(m.get("negative_count") or 0) | |
| avg = m.get("avg_score") | |
| if neg: | |
| linhas.append(_insight( | |
| f"{neg} avaliação(ões) negativa(s) — acione o CS para os clientes em risco antes que virem churn.")) | |
| elif isinstance(avg, (int, float)): | |
| linhas.append(_insight(f"Satisfação média saudável em {_num(avg)}.")) | |
| return linhas | |
| def _secao_cancelamentos(m: dict[str, Any]) -> list[str]: | |
| linhas = ["## 6. Cancelamentos", ""] | |
| if not m or not m.get("configured"): | |
| msg = (m or {}).get("message") or ( | |
| "Fonte de cancelamento ainda não configurada. " | |
| "Configure o objeto e os campos em object_mapping." | |
| ) | |
| linhas.append(f"> ⚙️ {msg}") | |
| return linhas | |
| linhas += [ | |
| _linha("Cancelamentos", _num(m.get("cancellations_count")), _variacao(m, "cancellations_count")), | |
| _linha("Impacto em MRR", _moeda(m.get("mrr_impact"))), | |
| _linha("Impacto em ARR", _moeda(m.get("arr_impact"))), | |
| _linha("Motivo principal", _num(m.get("top_reason"))), | |
| ] | |
| por_produto = m.get("cancellations_by_product") or {} | |
| if por_produto: | |
| itens = ", ".join(f"{k} ({v})" for k, v in por_produto.items()) | |
| linhas.append(_linha("Por produto", itens)) | |
| qtd = int(m.get("cancellations_count") or 0) | |
| if qtd: | |
| linhas.append(_insight( | |
| f"{qtd} cancelamento(s) somando {_moeda(m.get('mrr_impact'))} em MRR — " | |
| "acione retenção e investigue o motivo principal.")) | |
| return linhas | |
| def _secao_alertas(alertas: list[dict[str, Any]]) -> list[str]: | |
| linhas = ["## 7. Principais Alertas", ""] | |
| if not alertas: | |
| linhas.append("Nenhum alerta gerado para o período. ✅") | |
| return linhas | |
| for a in alertas: | |
| rotulo = _ROTULO_SEVERIDADE.get(a.get("severity", "low"), "🟡 Baixa") | |
| linhas.append(f"### {rotulo} — {a.get('title', '')}") | |
| linhas.append(f"- **Categoria:** {a.get('category', 'Geral')}") | |
| linhas.append(f"- **Diagnóstico:** {a.get('description', '')}") | |
| if a.get("recommended_action"): | |
| linhas.append(f"- **Ação recomendada:** {a['recommended_action']}") | |
| if a.get("source_object"): | |
| origem = a["source_object"] | |
| if a.get("source_record_id"): | |
| origem += f" ({a['source_record_id']})" | |
| linhas.append(f"- **Origem:** {origem}") | |
| linhas.append("") | |
| return linhas | |
| def _secao_prioridades(alertas: list[dict[str, Any]]) -> list[str]: | |
| """Lista as ações recomendadas priorizadas pela severidade dos alertas.""" | |
| linhas = ["## 8. Prioridades para Hoje", ""] | |
| acoes = [a for a in alertas if a.get("recommended_action")] | |
| if not acoes: | |
| linhas.append("Sem prioridades críticas. Manter rotina comercial padrão.") | |
| return linhas | |
| # Já vêm ordenados por severidade; pega as 5 primeiras ações. | |
| for i, a in enumerate(acoes[:5], start=1): | |
| rotulo = _ROTULO_SEVERIDADE.get(a.get("severity", "low"), "🟡 Baixa") | |
| linhas.append(f"{i}. [{rotulo}] {a['recommended_action']}") | |
| return linhas | |
| def _pipeline_valor(opp: dict[str, Any]) -> Any: | |
| """Usa o valor de produtos (recorrente+pontual) quando houver; senão Amount.""" | |
| pv = opp.get("open_pipeline_product_value") | |
| return pv if pv else opp.get("open_pipeline_amount") | |
| def _resumo_executivo_template(payload: dict[str, Any]) -> list[str]: | |
| """Resumo executivo em **storytelling**, por regras (sem IA). | |
| Constrói uma narrativa executiva a partir das métricas calculadas, | |
| adaptando o tom ao cenário do dia (tranquilo, atenção ou crítico) e | |
| destacando os pontos que exigem ação. Nenhum número é inventado. | |
| """ | |
| metrics = payload.get("metrics", {}) | |
| leads = metrics.get("leads", {}) or {} | |
| opp = metrics.get("opportunities", {}) or {} | |
| tasks = metrics.get("tasks", {}) or {} | |
| sat = metrics.get("satisfaction", {}) or {} | |
| canc = metrics.get("cancellations", {}) or {} | |
| alertas = payload.get("alerts", []) or [] | |
| altos = sum(1 for a in alertas if a.get("severity") == "high") | |
| data = payload.get("report_date", "o dia") | |
| # Tom de abertura conforme o cenário. | |
| if altos == 0: | |
| abertura = ( | |
| f"O dia **{data}** transcorreu sob controle: a operação comercial não " | |
| "acumulou riscos altos e segue dentro do ritmo esperado." | |
| ) | |
| elif altos <= 2: | |
| abertura = ( | |
| f"O dia **{data}** pede atenção pontual: surgiram **{altos} risco(s) alto(s)** " | |
| "que, se tratados hoje, evitam impacto no funil." | |
| ) | |
| else: | |
| abertura = ( | |
| f"O dia **{data}** exige ação imediata: são **{altos} riscos altos** " | |
| "concentrados que podem comprometer pipeline e receita se não forem endereçados." | |
| ) | |
| # Capítulo Leads. | |
| novos = _num(leads.get("new_leads")) | |
| conv = _pct(leads.get("conversion_rate")) | |
| sem_tarefa = int(leads.get("leads_without_first_task") or 0) | |
| cap_leads = ( | |
| f"Na entrada do funil, **{novos} novo(s) lead(s)** chegaram com conversão de " | |
| f"**{conv}**{_variacao(leads, 'conversion_rate')}." | |
| ) | |
| if sem_tarefa: | |
| cap_leads += ( | |
| f" Há **{sem_tarefa} lead(s) sem a primeira tarefa**, ou seja, contatos novos " | |
| "ainda sem follow-up — o ponto mais barato de corrigir agora." | |
| ) | |
| # Capítulo Oportunidades. | |
| ganhas = _num(opp.get("won_opportunities")) | |
| perdidas = _num(opp.get("lost_opportunities")) | |
| pipeline = _moeda(_pipeline_valor(opp)) | |
| paradas = int(opp.get("stalled_opportunities") or 0) | |
| alto_valor = int(opp.get("high_value_stalled_opportunities") or 0) | |
| cap_opp = ( | |
| f"No pipeline, o valor em aberto soma **{pipeline}**" | |
| f"{_variacao(opp, 'open_pipeline_amount')}, com **{ganhas} ganha(s)** e " | |
| f"**{perdidas} perdida(s)** fechando no dia." | |
| ) | |
| if alto_valor: | |
| cap_opp += ( | |
| f" O sinal mais sensível: **{alto_valor} oportunidade(s) de alto valor parada(s)** — " | |
| "negócios relevantes que estão esfriando e merecem contato prioritário." | |
| ) | |
| elif paradas: | |
| cap_opp += f" Ainda há **{paradas} oportunidade(s) parada(s)** aguardando reengajamento." | |
| # Capítulo operação (tarefas) — sem alarmismo. | |
| venc = int(tasks.get("tasks_overdue") or 0) | |
| cap_ops = "" | |
| if venc: | |
| cap_ops = ( | |
| f"Na operação, o backlog registra **{venc} tarefa(s) vencida(s)**; o foco do dia " | |
| "deve recair sobre as ligadas a negócios de maior valor." | |
| ) | |
| # Capítulo cliente (satisfação/cancelamento), se configurado. | |
| cap_cliente = "" | |
| if sat.get("configured") and sat.get("responses"): | |
| cap_cliente += ( | |
| f"Do lado do cliente, a satisfação média ficou em **{_num(sat.get('avg_score'))}** " | |
| f"com **{_num(sat.get('negative_count'))} avaliação(ões) negativa(s)**." | |
| ) | |
| if canc.get("configured") and canc.get("cancellations_count"): | |
| cap_cliente += ( | |
| f" Foram **{_num(canc.get('cancellations_count'))} cancelamento(s)**, impacto de " | |
| f"**{_moeda(canc.get('mrr_impact'))}** em MRR — atenção à retenção." | |
| ) | |
| # Fecho com direção. | |
| if altos: | |
| titulos = "; ".join(a.get("title", "") for a in alertas if a.get("severity") == "high") | |
| fecho = ( | |
| f"**Direção para hoje:** priorizar {titulos.lower()}. As ações detalhadas estão " | |
| "na seção de Prioridades." | |
| ) | |
| else: | |
| fecho = ( | |
| "**Direção para hoje:** manter a cadência, acompanhar as variações sinalizadas e " | |
| "antecipar follow-ups dos negócios de maior valor." | |
| ) | |
| paragrafos = [abertura, cap_leads, cap_opp] | |
| if cap_ops: | |
| paragrafos.append(cap_ops) | |
| if cap_cliente: | |
| paragrafos.append(cap_cliente.strip()) | |
| paragrafos.append(fecho) | |
| return ["## 1. Resumo Executivo", "", "\n\n".join(paragrafos)] | |
| def gerar_plano_acao(alerta: dict[str, Any]) -> str: | |
| """Gera um plano de ação em storytelling para um alerta (por regras, sem IA). | |
| Usa o diagnóstico, a ação recomendada e os registros afetados do próprio | |
| alerta para montar um texto acionável — sem inventar dados. | |
| """ | |
| categoria = alerta.get("category", "Geral") | |
| descricao = alerta.get("description", "").strip() | |
| acao = alerta.get("recommended_action", "").strip() | |
| registros = alerta.get("affected_records") or [] | |
| linhas: list[str] = [] | |
| if descricao: | |
| linhas.append(f"**O que está acontecendo:** {descricao}") | |
| # Por que importa (por categoria). | |
| porques = { | |
| "Oportunidades": "Cada dia parado reduz a probabilidade de fechamento e trava o pipeline.", | |
| "Leads": "Lead sem follow-up rápido esfria — a janela de conversão é curta.", | |
| "Tarefas": "Tarefas vencidas acumulam e mascaram o que é realmente prioritário.", | |
| "Satisfação": "Clientes insatisfeitos hoje são risco de churn amanhã.", | |
| "Cancelamentos": "Cancelamentos atacam diretamente a receita recorrente.", | |
| } | |
| if porques.get(categoria): | |
| linhas.append(f"**Por que importa:** {porques[categoria]}") | |
| if acao: | |
| linhas.append(f"**Plano sugerido:** {acao}") | |
| if registros: | |
| linhas.append("**Comece por (maior valor/risco primeiro):**") | |
| for r in registros[:5]: | |
| partes = [str(r.get("name") or r.get("id") or "registro")] | |
| if r.get("info"): | |
| partes.append(str(r["info"])) | |
| if r.get("amount") is not None: | |
| partes.append(_moeda(r["amount"])) | |
| if r.get("owner"): | |
| partes.append(f"resp.: {r['owner']}") | |
| linhas.append("- " + " — ".join(partes)) | |
| return "\n".join(linhas).strip() | |
| def _conclusao_template(payload: dict[str, Any]) -> list[str]: | |
| """Conclusão objetiva, sempre ligada a métricas/alertas.""" | |
| alertas = payload.get("alerts", []) or [] | |
| altos = [a for a in alertas if a.get("severity") == "high"] | |
| linhas = ["## 9. Conclusão", ""] | |
| if altos: | |
| titulos = "; ".join(a.get("title", "") for a in altos[:3]) | |
| linhas.append( | |
| f"O dia apresenta **{len(altos)} risco(s) alto(s)** que devem ser tratados " | |
| f"prioritariamente: {titulos}. As ações da seção 8 atacam esses pontos." | |
| ) | |
| else: | |
| linhas.append( | |
| "Sem riscos altos no período. Manter o ritmo e monitorar as variações " | |
| "indicadas nas seções anteriores." | |
| ) | |
| return linhas | |
| # ---------------------------------------------------------------------- | |
| # Função principal | |
| # ---------------------------------------------------------------------- | |
| def renderizar_relatorio( | |
| payload: dict[str, Any], narrativa_ia: str | None = None | |
| ) -> str: | |
| """Renderiza o relatório diário completo em Markdown. | |
| Args: | |
| payload: JSON estruturado com ``metrics``, ``alerts`` e ``data_quality``. | |
| narrativa_ia: Texto interpretativo opcional gerado por um modelo de IA. | |
| Quando presente, substitui o Resumo Executivo baseado em regras. | |
| Returns: | |
| Relatório completo em Markdown (9 seções obrigatórias). | |
| """ | |
| metrics = payload.get("metrics", {}) | |
| alertas = payload.get("alerts", []) or [] | |
| data_report = payload.get("report_date", "") | |
| linhas: list[str] = [ | |
| "# Relatório Diário — Analytical-Force", | |
| "", | |
| f"**Data de referência:** {data_report} ", | |
| f"**Fuso:** {payload.get('timezone', 'America/Sao_Paulo')}", | |
| "", | |
| ] | |
| # 1. Resumo Executivo (IA ou template). | |
| if narrativa_ia and narrativa_ia.strip(): | |
| linhas += ["## 1. Resumo Executivo", "", narrativa_ia.strip()] | |
| else: | |
| linhas += _resumo_executivo_template(payload) | |
| linhas.append("") | |
| # 2-6. Seções factuais (sempre calculadas em Python). | |
| linhas += _secao_oportunidades(metrics.get("opportunities", {}) or {}) | |
| linhas.append("") | |
| linhas += _secao_leads(metrics.get("leads", {}) or {}) | |
| linhas.append("") | |
| linhas += _secao_tarefas(metrics.get("tasks", {}) or {}) | |
| linhas.append("") | |
| linhas += _secao_satisfacao(metrics.get("satisfaction", {}) or {}) | |
| linhas.append("") | |
| linhas += _secao_cancelamentos(metrics.get("cancellations", {}) or {}) | |
| linhas.append("") | |
| # 7-9. Alertas, prioridades e conclusão. | |
| linhas += _secao_alertas(alertas) | |
| linhas.append("") | |
| linhas += _secao_prioridades(alertas) | |
| linhas.append("") | |
| linhas += _conclusao_template(payload) | |
| linhas.append("") | |
| # Rodapé de rastreabilidade. | |
| dq = payload.get("data_quality", {}) | |
| linhas += [ | |
| "---", | |
| f"_Gerado por Analytical-Force • conexão Salesforce: " | |
| f"{dq.get('salesforce_connection', 'desconhecida')} • " | |
| f"satisfação configurada: {dq.get('satisfaction_configured', False)} • " | |
| f"cancelamento configurado: {dq.get('cancellation_configured', False)}._", | |
| ] | |
| return "\n".join(linhas) | |