Spaces:
Sleeping
Sleeping
Download src/models/template_client.py from ViniciusKhan/analytical_force: direct link, hf CLI and curl.
- Browser
- Download file 26.9 kB
-
https://huggingface.co/spaces/ViniciusKhan/analytical_force/resolve/main/src/models/template_client.py
- Command line
-
hf download hf://spaces/ViniciusKhan/analytical_force/src/models/template_client.py
-
curl -L -o template_client.py https://huggingface.co/spaces/ViniciusKhan/analytical_force/resolve/main/src/models/template_client.py
26.9 kB
| """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_contratos(m: dict[str, Any]) -> list[str]: | |
| """Seção de Contratos: modificações do dia + reajuste do mês (se configurado).""" | |
| linhas = ["## 7. Contratos", ""] | |
| if not m or not m.get("configured"): | |
| linhas.append("> ⚙️ Fonte de contratos ainda não configurada.") | |
| return linhas | |
| linhas.append(_linha("Contratos modificados hoje", _num(m.get("modified_today_count")))) | |
| modificados = m.get("modified_today") or [] | |
| if modificados: | |
| linhas.append("") | |
| linhas.append("**Quem modificou o quê hoje:**") | |
| for c in modificados[:10]: | |
| quem = c.get("modified_by") or "responsável não identificado" | |
| linhas.append(f"- {c.get('name', c.get('id', '—'))} — modificado por {quem}") | |
| if not m.get("readjustment_configured"): | |
| linhas.append("") | |
| linhas.append( | |
| "> ⚙️ Módulo de reajuste de contrato não configurado " | |
| "(defina os campos de valor atual/anterior/data de reajuste)." | |
| ) | |
| return linhas | |
| linhas += [ | |
| "", | |
| _linha("Contratos reajustados no mês", _num(m.get("readjustment_month_count"))), | |
| _linha("Total de reajuste aplicado no mês", _moeda(m.get("readjustment_month_total"))), | |
| _linha("Reajuste médio (%)", _pct(m.get("readjustment_month_avg_percent"))), | |
| ] | |
| inconsistentes = int(m.get("readjustment_inconsistent_count") or 0) | |
| if inconsistentes: | |
| linhas.append( | |
| _linha("Contratos com possível inconsistência", _num(inconsistentes)) | |
| ) | |
| linhas.append("") | |
| linhas.append(f"> ⚠️ {m.get('readjustment_disclaimer', '')}") | |
| qtd = int(m.get("readjustment_month_count") or 0) | |
| if qtd: | |
| linhas.append(_insight( | |
| f"{qtd} contrato(s) reajustado(s) no mês somam " | |
| f"{_moeda(m.get('readjustment_month_total'))} — confirme os " | |
| f"{inconsistentes} caso(s) sinalizado(s) com o time de contratos antes de reportar como definitivo." | |
| if inconsistentes | |
| else f"{qtd} contrato(s) reajustado(s) no mês somam {_moeda(m.get('readjustment_month_total'))}." | |
| )) | |
| return linhas | |
| def _secao_alertas(alertas: list[dict[str, Any]]) -> list[str]: | |
| linhas = ["## 8. 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 = ["## 9. 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 — com começo, meio e fim — a partir das | |
| métricas já calculadas, adaptando o tom ao cenário do dia (tranquilo, | |
| atenção ou crítico) e usando os números como PROVA para convencer a | |
| liderança a agir. Nenhum número é inventado: cada frase referencia um | |
| valor presente no JSON de métricas. | |
| """ | |
| 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 {} | |
| contratos = metrics.get("contracts", {}) 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") | |
| # Abertura: o "gancho" da história, com o tom do dia como pano de fundo. | |
| if altos == 0: | |
| abertura = ( | |
| f"O dia **{data}** conta uma história de controle: nenhum risco alto " | |
| "surgiu na operação comercial, e os números abaixo mostram por quê." | |
| ) | |
| elif altos <= 2: | |
| abertura = ( | |
| f"O dia **{data}** tem um ponto de virada: **{altos} risco(s) alto(s)** " | |
| "aparecem no meio de um funil que, no restante, segue saudável — e é " | |
| "exatamente aí que a atenção de hoje faz diferença." | |
| ) | |
| else: | |
| abertura = ( | |
| f"O dia **{data}** é de sinal vermelho: **{altos} riscos altos** se " | |
| "acumulam ao mesmo tempo, e os dados a seguir mostram onde o pipeline " | |
| "e a receita podem ser afetados se ninguém agir agora." | |
| ) | |
| # Capítulo Leads — entrada do funil. | |
| 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"Tudo começa na entrada do funil: **{novos} novo(s) lead(s)** chegaram " | |
| f"hoje, convertendo a **{conv}**{_variacao(leads, 'conversion_rate')}." | |
| ) | |
| if sem_tarefa: | |
| cap_leads += ( | |
| f" O detalhe que muda o resultado de amanhã: **{sem_tarefa} lead(s)** " | |
| "ainda não têm a primeira tarefa registrada — é o ganho mais barato do " | |
| "dia, e cada hora sem follow-up reduz a chance de conversão." | |
| ) | |
| # Capítulo Oportunidades — onde a receita se decide. | |
| ganhas = _num(opp.get("won_opportunities")) | |
| perdidas = _num(opp.get("lost_opportunities")) | |
| criadas = int(opp.get("new_opportunities") or 0) | |
| 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 meio da história está o pipeline: **{_moeda(_pipeline_valor(opp))}** em " | |
| f"aberto{_variacao(opp, 'open_pipeline_amount')}" | |
| + (f", reforçado por **{criadas} oportunidade(s) nova(s)**" if criadas else "") | |
| + f", com **{ganhas} ganha(s)** contra **{perdidas} perdida(s)** no fechamento de hoje." | |
| ) | |
| if alto_valor: | |
| cap_opp += ( | |
| f" A prova mais concreta do risco: **{alto_valor} oportunidade(s) de " | |
| "alto valor parada(s)** — negócios relevantes esfriando sem contato, o " | |
| "ponto que mais pesa contra o resultado do mês." | |
| ) | |
| elif paradas: | |
| cap_opp += f" Ainda há **{paradas} oportunidade(s) parada(s)** aguardando reengajamento." | |
| # Capítulo Contratos — reajuste e movimentação, com a ressalva de qualidade. | |
| cap_contratos = "" | |
| if contratos.get("configured"): | |
| mod = int(contratos.get("modified_today_count") or 0) | |
| if mod: | |
| cap_contratos = f"Nos contratos, **{mod} registro(s)** foram modificados hoje" | |
| responsaveis = { | |
| c.get("modified_by") | |
| for c in (contratos.get("modified_today") or []) | |
| if c.get("modified_by") | |
| } | |
| if responsaveis: | |
| cap_contratos += f", movimentados por {', '.join(sorted(responsaveis)[:3])}" | |
| cap_contratos += "." | |
| if contratos.get("readjustment_configured") and contratos.get("readjustment_month_count"): | |
| qtd_reaj = _num(contratos.get("readjustment_month_count")) | |
| total_reaj = _moeda(contratos.get("readjustment_month_total")) | |
| cap_contratos += ( | |
| f" No acumulado do mês, **{qtd_reaj} contrato(s)** já foram " | |
| f"reajustados, somando **{total_reaj}** — número estimado pela " | |
| "diferença entre valor atual e anterior, que pode não ser 100% " | |
| "exato caso haja erro de preenchimento no contrato." | |
| ) | |
| # Capítulo operação (tarefas) — sem alarmismo, só o fato. | |
| 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) — o desfecho do relacionamento. | |
| cap_cliente = "" | |
| if sat.get("configured") and sat.get("responses"): | |
| cap_cliente += ( | |
| f"Do lado do cliente, a satisfação do dia ficou em **{_num(sat.get('avg_score'))}** " | |
| f"({_num(sat.get('responses'))} resposta(s)), com " | |
| f"**{_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: a moral da história, com direção clara de ação. | |
| if altos: | |
| titulos = "; ".join(a.get("title", "") for a in alertas if a.get("severity") == "high") | |
| fecho = ( | |
| f"**A conclusão é direta:** os dados apontam para {titulos.lower()} como " | |
| "prioridade — as ações detalhadas estão na seção de Prioridades, e adiar " | |
| "significa deixar esse risco crescer amanhã." | |
| ) | |
| else: | |
| fecho = ( | |
| "**A conclusão é direta:** os números sustentam manter a cadência atual, " | |
| "acompanhar as variações sinalizadas e antecipar os follow-ups dos " | |
| "negócios de maior valor antes que virem urgência." | |
| ) | |
| paragrafos = [abertura, cap_leads, cap_opp] | |
| if cap_contratos: | |
| paragrafos.append(cap_contratos.strip()) | |
| 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 = ["## 10. 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("") | |
| linhas += _secao_contratos(metrics.get("contracts", {}) or {}) | |
| linhas.append("") | |
| # 8-10. 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) | |