Iniciativa Cidadã Independente · Transparência Parlamentar
TS248 linhas11.162 bytes

apps/worker/src/services/newsletter-ai.ts

SHA-256

ff2e3a6f2da14f508834660070862823bcaee1188b989e4fb1b5ce198a4d4add

Somente leiturafonte-acf635076da2
/**
 * Gera análise jornalística da semana via Cloudflare Workers AI (Llama 3.3 70B).
 * Prompt rigorosamente factual: a IA só comenta dados explicitamente fornecidos.
 *
 * Saída: markdown com 6 seções fixas + metadata (tokens, modelo).
 */

import type { Env } from '../types'
import type { NewsletterData } from './newsletter-data'

const MODEL_PRIMARY = '@cf/meta/llama-3.3-70b-instruct-fp8-fast'
const MODEL_FALLBACK = '@cf/meta/llama-3.1-70b-instruct'

export interface NewsletterAIResult {
  markdown: string
  modelo: string
  promptTokens?: number
  outputTokens?: number
  fallbackUsed: boolean
}

const SYSTEM_PROMPT = `Você é jornalista político brasileiro experiente, especializado em transparência parlamentar. Escreve para a newsletter semanal do Observatório do Senado, uma iniciativa cidadã independente que avalia o desempenho dos senadores da 57ª Legislatura.

GLOSSÁRIO OBRIGATÓRIO (use APENAS estas definições — nunca invente outras):

- **IDS (Índice de Desempenho Senatorial)**: índice composto de 0 a 100 que sintetiza 6 dimensões da atividade parlamentar (produtividade, efetividade, participação, fiscalização, eficiência da CEAP e transparência). É um indicador metodológico do Observatório, não uma medida oficial do Senado.
- **CEAP (Cota para o Exercício da Atividade Parlamentar)**: cota mensal de reembolso a que cada senador tem direito para custear despesas relacionadas ao mandato — passagens aéreas, hospedagem, locação de imóveis para escritório político, contratação de consultorias, divulgação da atividade parlamentar, combustível, telefonia, material de escritório etc. NÃO é um índice de desempenho nem de cumprimento de agenda. É uma verba pública. O Observatório monitora o uso da CEAP como dimensão de eficiência (gasto mensal) e transparência (% gasto em "divulgação da atividade parlamentar", que é proxy de autopromoção).
- **Suplente**: parlamentar que assume vaga deixada pelo titular afastado (licença, ministério, falecimento). Pode ter exercícios fragmentados.
- **Relatoria**: designação para emitir parecer técnico sobre matéria em comissão.
- **Autoria**: parlamentar responsável pela apresentação de uma matéria (PEC, PL, PLP, RQS etc).

REGRAS RÍGIDAS:
1. Use APENAS os dados estruturados fornecidos. Nunca invente nomes, números, partidos, eventos, expansões de siglas ou justificativas.
2. Tom jornalístico neutro: comente padrões, mas NÃO julgue moralmente. Não use adjetivos como "inativo", "improdutivo", "preguiçoso". Prefira "menor produtividade", "menos votações registradas".
3. Cite senadores pelo nome próprio + (partido/UF), exatamente como nos dados.
4. NÃO invente motivos para movimentações ou variações no IDS. Se os dados não explicam, descreva apenas o fato.
5. Para CEAP: comente valores em reais e padrões de uso (concentração em divulgação, fornecedores, etc.) APENAS se houver dados explícitos. Se não houver, faça uma nota curta lembrando o que é CEAP e que o detalhamento por senador está disponível no site.
6. Português brasileiro formal mas acessível. Frases médias.
7. Saída em markdown, com EXATAMENTE estas seções e nessa ordem:

## Editorial
## Destaques da semana
## Atividade legislativa
## Movimentações
## CEAP em foco
## O que esperar

8. Cada seção: 2-4 parágrafos curtos. Total 500-900 palavras.
9. NÃO escreva preâmbulo nem despedida. Comece direto em "## Editorial".`

function buildUserPrompt(data: NewsletterData): string {
  const fmt = (n: number) => n.toLocaleString('pt-BR', { maximumFractionDigits: 1 })
  const fmtBRL = (n: number) =>
    n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 })

  const top10 = data.topAtual
    .map((s) => {
      const delta = s.delta != null ? (s.delta > 0 ? `+${s.delta}` : `${s.delta}`) : '—'
      const dIds = s.deltaIds != null ? (s.deltaIds > 0 ? `+${s.deltaIds.toFixed(1)}` : s.deltaIds.toFixed(1)) : '—'
      return `${s.posicao}º. ${s.nome} (${s.partido}/${s.uf}) — IDS ${fmt(s.idsTotal)} (Δ posição ${delta} · Δ IDS ${dIds})`
    })
    .join('\n')

  const bottom = data.bottomAtual
    .map((s) => `${s.posicao}º. ${s.nome} (${s.partido}/${s.uf}) — IDS ${fmt(s.idsTotal)}`)
    .join('\n')

  const movers = data.topMovers.length
    ? data.topMovers
        .map((s) => {
          const delta = s.delta != null ? (s.delta > 0 ? `subiu ${s.delta}` : `desceu ${Math.abs(s.delta)}`) : '—'
          const dIds = s.deltaIds != null ? (s.deltaIds > 0 ? `+${s.deltaIds.toFixed(1)}` : s.deltaIds.toFixed(1)) : '—'
          return `- ${s.nome} (${s.partido}/${s.uf}): ${delta} posições · IDS ${fmt(s.idsTotal)} (Δ ${dIds}); de ${s.posicaoAnterior ?? '—'}º para ${s.posicao}º`
        })
        .join('\n')
    : 'Nenhuma alteração relevante no ranking esta semana (variações <5 posições).'

  const movs = data.movimentacoes.length
    ? data.movimentacoes
        .map((m) => {
          if (m.tipo === 'entrou') return `- ENTRADA EM EXERCÍCIO: ${m.nome} (${m.partido}/${m.uf})`
          if (m.tipo === 'afastou') return `- AFASTAMENTO: ${m.nome} (${m.partido}/${m.uf}) — motivo: ${m.motivo ?? 'não informado'}${m.data ? ` · desde ${m.data}` : ''}`
          return `- SAÍDA: ${m.nome} (${m.partido}/${m.uf})`
        })
        .join('\n')
    : 'Nenhuma movimentação de exercício/afastamento registrada na semana.'

  const topAutoresSemana = data.atividade.topAutores?.length
    ? data.atividade.topAutores
        .map((a) => `- ${a.nome} (${a.partido}/${a.uf}): ${a.quantidade} matéria(s) apresentada(s)`)
        .join('\n')
    : '(sem dados de top autores)'

  const agenda = data.agenda.length
    ? data.agenda
        .slice(0, 6)
        .map((e) => `- ${e.dataHora.slice(0, 16).replace('T', ' ')}: ${e.titulo}${e.local ? ` (${e.local})` : ''}`)
        .join('\n')
    : 'Sem eventos públicos confirmados no calendário.'

  // CEAP — dados reais (acumulado anual em R$, não semanal).
  const ceap = data.ceap
  const topGastadores = ceap.topGastadores.length
    ? ceap.topGastadores
        .map((c) => `- ${c.nome} (${c.partido}/${c.uf}): ${fmtBRL(c.total)} acumulados em ${ceap.fonteAno}`)
        .join('\n')
    : '(sem dados de CEAP no snapshot)'
  const topDivulg = ceap.topPctDivulgacao.length
    ? ceap.topPctDivulgacao
        .map((c) => `- ${c.nome} (${c.partido}/${c.uf}): ${c.pct.toFixed(1)}% do total em divulgação (total ${fmtBRL(c.total)})`)
        .join('\n')
    : '(sem dados de % divulgação)'

  return `# Dados estruturados — Edição ${data.edicao}

Semana referência: ${data.semana.inicio} → ${data.semana.fim}
Semana anterior (comparativo): ${data.semanaAnterior.inicio} → ${data.semanaAnterior.fim}
Senadores em exercício: ${data.totalSenadores}

## Panorama
- IDS médio: ${fmt(data.panorama.idsMedio)}
- Maior IDS: ${fmt(data.panorama.idsMaior)}
- Menor IDS: ${fmt(data.panorama.idsMenor)}
- Confiança alta (≥24m de mandato): ${data.panorama.confiancaAlta}
- Confiança baixa (<12m): ${data.panorama.confiancaBaixa}

## Top 10 atual (com deltas vs semana anterior)
${top10}

## Bottom 5
${bottom}

## Maiores movimentos no ranking
${movers}

## Movimentações de exercício
${movs}

## Atividade legislativa da semana
- Matérias apresentadas: ${data.atividade.totalProcessosNovos}
- Votações nominais ocorridas: ${data.atividade.totalVotacoes}
- Discursos registrados: ${data.atividade.totalDiscursos}
- Top autores da semana:
${topAutoresSemana}

## CEAP — dados de uso da cota (acumulado em ${ceap.fonteAno}, em reais)

ATENÇÃO: CEAP é a Cota para o Exercício da Atividade Parlamentar — verba de
reembolso de despesas do mandato (passagens, hospedagem, escritório,
divulgação, consultoria, combustível). NÃO é índice nem medida de agenda.
Comente os valores abaixo de forma factual, sem julgamento moral.

Média de gasto anual entre senadores com despesas registradas: ${fmtBRL(ceap.mediaGastoAno)}

Maiores gastadores no ano:
${topGastadores}

Maior % gasto em divulgação da atividade parlamentar (proxy de autopromoção):
${topDivulg}

## Agenda da próxima semana
${agenda}

---
INSTRUÇÃO: Escreva a newsletter agora seguindo as 6 seções definidas no system prompt. Comece direto em "## Editorial".`
}

interface CFAIResponse {
  success: boolean
  result?: { response?: string; usage?: { prompt_tokens?: number; completion_tokens?: number } }
  errors?: Array<{ message?: string }>
}

async function callModel(env: Env, model: string, system: string, user: string): Promise<CFAIResponse> {
  const acc = process.env.CLOUDFLARE_ACCOUNT_ID
  const email = process.env.CLOUDFLARE_EMAIL
  const key = process.env.CLOUDFLARE_API_KEY
  if (!acc || !email || !key) throw new Error('CLOUDFLARE_* envs ausentes')

  const url = `https://api.cloudflare.com/client/v4/accounts/${acc}/ai/run/${model}`
  const res = await fetch(url, {
    method: 'POST',
    headers: {
      'X-Auth-Email': email,
      'X-Auth-Key': key,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      messages: [
        { role: 'system', content: system },
        { role: 'user', content: user },
      ],
      max_tokens: 2200,
      temperature: 0.4,
    }),
    signal: AbortSignal.timeout(120_000),
  })
  return (await res.json()) as CFAIResponse
}

function validar(markdown: string): { ok: boolean; motivo?: string } {
  if (!markdown || markdown.trim().length < 400) return { ok: false, motivo: 'curto demais' }
  const seções = ['## Editorial', '## Destaques da semana', '## Atividade legislativa', '## Movimentações', '## CEAP em foco', '## O que esperar']
  for (const s of seções) {
    if (!markdown.includes(s)) return { ok: false, motivo: `seção ausente: ${s}` }
  }
  return { ok: true }
}

export async function gerarAnalise(env: Env, data: NewsletterData): Promise<NewsletterAIResult> {
  const user = buildUserPrompt(data)

  // Tentativa 1: Llama 3.3 70B FP8 fast
  const r1 = await callModel(env, MODEL_PRIMARY, SYSTEM_PROMPT, user)
  if (r1.success && r1.result?.response) {
    const v = validar(r1.result.response)
    if (v.ok) {
      return {
        markdown: r1.result.response.trim(),
        modelo: MODEL_PRIMARY,
        promptTokens: r1.result.usage?.prompt_tokens,
        outputTokens: r1.result.usage?.completion_tokens,
        fallbackUsed: false,
      }
    }
    console.warn(`[newsletter-ai] saída do primário inválida (${v.motivo}), tentando fallback`)
  } else {
    console.warn('[newsletter-ai] primário falhou:', r1.errors)
  }

  // Tentativa 2: Llama 3.1 70B
  const r2 = await callModel(env, MODEL_FALLBACK, SYSTEM_PROMPT, user)
  if (r2.success && r2.result?.response) {
    const v = validar(r2.result.response)
    if (v.ok) {
      return {
        markdown: r2.result.response.trim(),
        modelo: MODEL_FALLBACK,
        promptTokens: r2.result.usage?.prompt_tokens,
        outputTokens: r2.result.usage?.completion_tokens,
        fallbackUsed: true,
      }
    }
  }

  throw new Error('IA não produziu saída válida em nenhum dos modelos')
}