analytical_force / src /models /template_client.py
ViniciusKhan's picture
API robusta + Transformers + front + fix Turso
4b1ff9f
Raw
History Blame Contribute Delete
22.2 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_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)