Iniciativa Cidadã Independente · Transparência Parlamentar
TS559 linhas18.701 bytes

apps/worker/src/services/premiacao.ts

SHA-256

694e5cbd314647339f4bea4cbf1fea7458432c1178dd19e851577e846c62b43e

Somente leiturafonte-e58fe007b6ee
import type { Env, IdsScore } from '../types'
import { MESES_MIN_PREMIO } from './legis'
import { WEIGHTS, getRankingSnapshot } from './ranking'

/**
 * Prêmio Observatório do Senado — Legislatura 57.
 *
 * 12 prêmios em 3 eixos. Duas réguas de tempo, propositalmente distintas:
 * entra na coorte pontuada quem tem ≥ MESES_MIN_PREMIO meses de exercício
 * (`elegivelPremio`), e disputa os prêmios quem tem ≥ MESES_MIN_VITORIA.
 *
 * Decisões de desenho (2026-09-22):
 *  - Acúmulo é permitido: vencer o Grande Prêmio não tira ninguém do páreo
 *    regional nem temático. Cada eixo premia o líder real da sua régua.
 *  - Quem já saiu do exercício concorre normalmente, sem distinção visual.
 *    `emExercicio`/`dataFimExercicio` seguem no payload para quem quiser exibir.
 *  - O resultado é definitivo, não prévia. Por isso a apuração roda sobre um
 *    snapshot FIXADO (ver `getSnapshotFixado`) e não sobre o mais recente: o
 *    cron recalcula todo domingo e os vencedores mudariam em silêncio.
 */

/**
 * Mínimo de meses em exercício para CONCORRER a um prêmio — metade dos 48
 * meses da legislatura.
 *
 * É régua distinta de MESES_MIN_PREMIO (12), que define quem entra na coorte
 * pontuada do IDS e serve de população de referência do z-score. Aquela decide
 * quem é avaliado; esta decide quem disputa: um prêmio de fim de legislatura
 * pressupõe ter exercido ao menos metade dela. Quem fica entre as duas réguas
 * continua com score publicado no ranking, apenas fora da disputa.
 *
 * Mexer aqui NÃO altera nota de ninguém: a normalização segue calculada sobre
 * os elegíveis de MESES_MIN_PREMIO em computeRanking.
 */
export const MESES_MIN_VITORIA = 24

export type RegiaoId = 'N' | 'NE' | 'CO' | 'SE' | 'S'
export type EixoId = 'grande_premio' | 'regional' | 'tematico'

/** Regiões do IBGE. A UF é o único vínculo territorial disponível no snapshot. */
export const REGIOES: Record<RegiaoId, { nome: string; ufs: string[] }> = {
  N: { nome: 'Norte', ufs: ['AC', 'AM', 'AP', 'PA', 'RO', 'RR', 'TO'] },
  NE: {
    nome: 'Nordeste',
    ufs: ['AL', 'BA', 'CE', 'MA', 'PB', 'PE', 'PI', 'RN', 'SE'],
  },
  CO: { nome: 'Centro-Oeste', ufs: ['DF', 'GO', 'MS', 'MT'] },
  SE: { nome: 'Sudeste', ufs: ['ES', 'MG', 'RJ', 'SP'] },
  S: { nome: 'Sul', ufs: ['PR', 'RS', 'SC'] },
}

const UF_PARA_REGIAO: Record<string, RegiaoId> = Object.fromEntries(
  Object.entries(REGIOES).flatMap(([id, r]) =>
    r.ufs.map((uf) => [uf, id as RegiaoId]),
  ),
)

export function regiaoDaUf(uf: string): RegiaoId | undefined {
  return UF_PARA_REGIAO[uf.toUpperCase()]
}

// A régua da vitória não pode ser mais frouxa que a da coorte pontuada:
// premiaria quem o IDS nem considera avaliável.
if (MESES_MIN_VITORIA < MESES_MIN_PREMIO) {
  console.warn(
    `[premiacao] MESES_MIN_VITORIA (${MESES_MIN_VITORIA}) < ` +
      `MESES_MIN_PREMIO (${MESES_MIN_PREMIO})`,
  )
}

/**
 * Piso de linhas para uma apuração ser considerada confiável.
 *
 * O snapshot de 2026-08-23 gravou 81 linhas em vez das 105 da coorte — um cron
 * parcial. Publicar prêmio sobre um snapshot truncado premiaria o vencedor de
 * uma coorte incompleta, então a apuração recusa em vez de degradar.
 */
export const MIN_LINHAS_SNAPSHOT = 100

const CHAVE_SNAPSHOT = 'snapshot_leg57'

/** Meses de exercício, protegido contra divisão por zero. */
function meses(s: IdsScore): number {
  return Math.max(s.mesesAtivos ?? 0, 1)
}

/**
 * Indicador bruto por dimensão, usado só como critério de desempate.
 *
 * As dimensões são arredondadas para inteiro na persistência, então empates no
 * topo acontecem de fato (Participação e Transparência já empatam hoje). Estas
 * funções reproduzem a taxa bruta de `computeRanking` a partir das colunas do
 * snapshot — exceto produtividade e efetividade, cujas versões ponderadas por
 * tipo de proposição não são persistidas e aqui aparecem como proxy declarado.
 */
const INDICADOR_BRUTO = {
  /** Proxy: a produtividade do IDS é ponderada por tipo de proposição. */
  produtividade: (s: IdsScore) => s.autoriasTotal / meses(s),
  /** Proxy: a efetividade do IDS é ponderada por status × tipo. */
  efetividade: (s: IdsScore) =>
    s.autoriasAprovadas / Math.max(s.autoriasTotal, 1),
  participacao: (s: IdsScore) => {
    const plen = s.votacoesTotal > 0 ? s.votacaoPresentes / s.votacoesTotal : 0
    const com =
      (s.votacoesComissaoTotal ?? 0) > 0
        ? (s.votacoesComissaoPresentes ?? 0) / (s.votacoesComissaoTotal ?? 1)
        : plen
    return 0.6 * plen + 0.4 * com
  },
  fiscalizacao: (s: IdsScore) =>
    s.relatoriasTotal / (1 + 0.3 * (s.cargosLideranca ?? 0)) / meses(s),
  /** Negativo: gastar menos pontua mais, como na dimensão. */
  ceap: (s: IdsScore) => {
    const fator = Math.max(1 - 0.6 * ((s.pctDivulgacao ?? 0) / 100), 0.4)
    return -(s.ceapTotalAno / fator) / meses(s)
  },
  transparencia: (s: IdsScore) =>
    (s.discursosTotal + 0.5 * s.apartesTotal) / meses(s),
} as const

interface CategoriaDef {
  id: string
  eixo: EixoId
  titulo: string
  descricao: string
  /** Campo do IdsScore que ordena a categoria. */
  criterio: string
  /** Peso da dimensão no IDS — só para o eixo temático. */
  peso?: number
  regiao?: RegiaoId
  valor: (s: IdsScore) => number
  bruto: (s: IdsScore) => number
  indicador: (s: IdsScore) => IndicadorBruto
}

const emMeses = (s: IdsScore) => `em ${s.mesesAtivos ?? 0} meses de exercício`

/** Categorias por IDS: a nota já é o total, então o indicador decompõe. */
const indicadorIds = (s: IdsScore): IndicadorBruto => ({
  rotulo: 'Composição do IDS',
  valor: s.idsTotal,
  unidade: 'de 100',
  detalhe: emMeses(s),
  composicao: [
    { rotulo: 'Produtividade', valor: s.dimProdutividade },
    { rotulo: 'Efetividade', valor: s.dimEfetividade },
    { rotulo: 'Participação', valor: s.dimParticipacao },
    { rotulo: 'Fiscalização', valor: s.dimFiscalizacao },
    { rotulo: 'CEAP', valor: s.dimCeap },
    { rotulo: 'Transparência', valor: s.dimTransparencia },
  ],
})

const TEMATICAS: CategoriaDef[] = [
  {
    id: 'produtividade',
    eixo: 'tematico',
    titulo: 'Produtividade Legislativa',
    descricao:
      'Volume de proposições de autoria própria, ponderado por tipo e por mês de exercício.',
    criterio: 'dimProdutividade',
    peso: WEIGHTS.produtividade,
    valor: (s) => s.dimProdutividade,
    bruto: INDICADOR_BRUTO.produtividade,
    indicador: (s) => ({
      rotulo: 'Proposições de autoria',
      valor: s.autoriasTotal,
      unidade: s.autoriasTotal === 1 ? 'proposição' : 'proposições',
      detalhe: emMeses(s),
    }),
  },
  {
    id: 'efetividade',
    eixo: 'tematico',
    titulo: 'Efetividade Legislativa',
    descricao:
      'Proporção das proposições próprias que avançaram, ponderada por status e tipo.',
    criterio: 'dimEfetividade',
    peso: WEIGHTS.efetividade,
    valor: (s) => s.dimEfetividade,
    bruto: INDICADOR_BRUTO.efetividade,
    indicador: (s) => ({
      rotulo: 'Proposições aprovadas',
      valor: s.autoriasAprovadas,
      unidade: `de ${s.autoriasTotal}`,
      detalhe:
        s.autoriasTotal > 0
          ? `${((s.autoriasAprovadas / s.autoriasTotal) * 100).toFixed(1)}% das próprias proposições`
          : 'sem proposições de autoria no período',
    }),
  },
  {
    id: 'participacao',
    eixo: 'tematico',
    titulo: 'Presença e Participação',
    descricao:
      'Comparecimento às votações: 60% plenário e 40% comissões.',
    criterio: 'dimParticipacao',
    peso: WEIGHTS.participacao,
    valor: (s) => s.dimParticipacao,
    bruto: INDICADOR_BRUTO.participacao,
    indicador: (s) => ({
      rotulo: 'Presença em plenário',
      valor: s.votacoesTotal > 0
        ? Math.round((s.votacaoPresentes / s.votacoesTotal) * 1000) / 10
        : 0,
      unidade: '%',
      detalhe:
        `${s.votacaoPresentes} de ${s.votacoesTotal} votações em plenário` +
        ((s.votacoesComissaoTotal ?? 0) > 0
          ? ` · ${s.votacoesComissaoPresentes} de ${s.votacoesComissaoTotal} em comissões`
          : ''),
    }),
  },
  {
    id: 'fiscalizacao',
    eixo: 'tematico',
    titulo: 'Fiscalização e Relatoria',
    descricao:
      'Relatorias assumidas por mês de exercício, descontado o peso dos cargos de liderança.',
    criterio: 'dimFiscalizacao',
    peso: WEIGHTS.fiscalizacao,
    valor: (s) => s.dimFiscalizacao,
    bruto: INDICADOR_BRUTO.fiscalizacao,
    indicador: (s) => ({
      rotulo: 'Relatorias',
      valor: s.relatoriasTotal,
      unidade: s.relatoriasTotal === 1 ? 'relatoria' : 'relatorias',
      detalhe:
        emMeses(s) +
        ((s.cargosLideranca ?? 0) > 0
          ? ` · taxa ajustada por ${s.cargosLideranca} cargo(s) de liderança`
          : ''),
    }),
  },
  {
    id: 'ceap',
    eixo: 'tematico',
    titulo: 'Uso Responsável do CEAP',
    descricao:
      'Menor gasto da cota parlamentar por mês, com penalidade sobre a fatia gasta em divulgação.',
    criterio: 'dimCeap',
    peso: WEIGHTS.ceap,
    valor: (s) => s.dimCeap,
    bruto: INDICADOR_BRUTO.ceap,
    indicador: (s) => ({
      rotulo: 'Cota parlamentar',
      valor: Math.round(s.ceapTotalAno / Math.max(s.mesesAtivos ?? 0, 1)),
      unidade: 'R$/mês',
      detalhe:
        s.ceapTotalAno === 0
          ? 'nenhuma despesa de cota registrada na fonte oficial no período'
          : `R$ ${Math.round(s.ceapTotalAno).toLocaleString('pt-BR')} no total` +
            ` · ${(s.pctDivulgacao ?? 0).toFixed(1)}% em divulgação`,
    }),
  },
  {
    id: 'transparencia',
    eixo: 'tematico',
    titulo: 'Transparência e Atividade',
    descricao:
      'Discursos e apartes em plenário por mês de exercício — atividade pública registrada.',
    criterio: 'dimTransparencia',
    peso: WEIGHTS.transparencia,
    valor: (s) => s.dimTransparencia,
    bruto: INDICADOR_BRUTO.transparencia,
    indicador: (s) => ({
      rotulo: 'Discursos e apartes',
      valor: s.discursosTotal + s.apartesTotal,
      unidade: 'registros',
      detalhe: `${s.discursosTotal} discursos e ${s.apartesTotal} apartes, ${emMeses(s)}`,
    }),
  },
]

function categoriasRegionais(): CategoriaDef[] {
  return (Object.keys(REGIOES) as RegiaoId[]).map((id) => ({
    id: `regional_${id.toLowerCase()}`,
    eixo: 'regional' as EixoId,
    titulo: `Destaque ${REGIOES[id].nome}`,
    descricao: `Maior IDS entre os concorrentes das UFs do ${REGIOES[id].nome}.`,
    criterio: 'idsTotal',
    regiao: id,
    valor: (s: IdsScore) => s.idsTotal,
    bruto: (s: IdsScore) => s.idsTotalBruto ?? s.idsTotal,
    indicador: indicadorIds,
  }))
}

const GRANDE_PREMIO: CategoriaDef = {
  id: 'grande_premio',
  eixo: 'grande_premio',
  titulo: 'Grande Prêmio',
  descricao:
    'Maior Índice de Desempenho Senatorial entre todos os concorrentes da 57ª Legislatura.',
  criterio: 'idsTotal',
  valor: (s) => s.idsTotal,
  bruto: (s) => s.idsTotalBruto ?? s.idsTotal,
  indicador: indicadorIds,
}

/**
 * Evidência bruta por trás da nota normalizada de uma categoria.
 *
 * A nota é um percentil de z-score: informativa para ordenar, opaca para
 * auditar. Quem lê a página precisa ver o número contado — "150 relatorias em
 * 40 meses" — e não só "99".
 */
export interface IndicadorBruto {
  rotulo: string
  valor: number
  /** Unidade já formatada, ex.: "relatorias" ou "R$/mês". */
  unidade: string
  /** Frase de apoio, ex.: "em 40 meses de exercício". */
  detalhe: string
  /**
   * Decomposição da nota, quando a métrica é o IDS.
   *
   * Para Grande Prêmio e regionais o "indicador bruto" seria a própria nota —
   * repetir o número não informa nada. O que abre a caixa-preta ali é mostrar
   * quais dimensões produziram o total.
   */
  composicao?: { rotulo: string; valor: number }[]
}

export interface Premiado {
  posicao: number
  senadorCod: string
  nome: string
  partido: string
  uf: string
  regiao?: RegiaoId
  fotoUrl?: string
  idsTotal: number
  /** Valor da métrica que ordena a categoria. */
  valor: number
  mesesAtivos: number
  emExercicio: boolean
  dataFimExercicio?: string
  /** Dado contado que sustenta a posição nesta categoria. */
  indicador: IndicadorBruto
}

export interface CategoriaApurada {
  id: string
  eixo: EixoId
  titulo: string
  descricao: string
  criterio: string
  peso?: number
  regiao?: RegiaoId
  ufs?: string[]
  elegiveis: number
  vencedor: Premiado
  podio: Premiado[]
  /** true quando o 1º lugar empatou na métrica e caiu no critério secundário. */
  houveDesempate: boolean
}

export interface Premiacao {
  edicao: string
  legislatura: number
  /** false = apuração ainda lendo o snapshot mais recente, sujeita a mudar. */
  congelado: boolean
  snapshot: string
  apuradoEm: string
  coorte: {
    /** Total de senadores no snapshot. */
    avaliados: number
    /** Na coorte pontuada do IDS (≥ MESES_MIN_PREMIO meses). */
    elegiveis: number
    /** Efetivamente na disputa (≥ MESES_MIN_VITORIA meses). */
    concorrentes: number
    mesesMinimos: number
    mesesMinimosVitoria: number
  }
  categorias: CategoriaApurada[]
}

/**
 * Ordena pela métrica da categoria, com desempate determinístico.
 *
 * Ordem: métrica → indicador bruto da DIMENSÃO → IDS total → meses → nome.
 *
 * O indicador da própria categoria vem antes do IDS geral de propósito. As
 * notas são percentis arredondados e saturam no topo: em Transparência dois
 * senadores empatavam em 100 com 56,4 e 21,6 registros por mês. Desempatar
 * pelo IDS geral entregaria a categoria a quem é mais completo no conjunto,
 * não a quem fez mais daquilo que a categoria mede — e a categoria é sobre
 * essa dimensão, não sobre o conjunto.
 */
function ordenar(cat: CategoriaDef, scores: IdsScore[]): IdsScore[] {
  return [...scores].sort(
    (a, b) =>
      cat.valor(b) - cat.valor(a) ||
      cat.bruto(b) - cat.bruto(a) ||
      b.idsTotal - a.idsTotal ||
      (b.mesesAtivos ?? 0) - (a.mesesAtivos ?? 0) ||
      a.nome.localeCompare(b.nome, 'pt-BR'),
  )
}

function toPremiado(s: IdsScore, cat: CategoriaDef, posicao: number): Premiado {
  return {
    posicao,
    senadorCod: s.senadorCod,
    nome: s.nome,
    partido: s.partido,
    uf: s.uf,
    regiao: regiaoDaUf(s.uf),
    fotoUrl: s.fotoUrl,
    idsTotal: s.idsTotal,
    valor: cat.valor(s),
    mesesAtivos: s.mesesAtivos ?? 0,
    emExercicio: s.emExercicio !== false,
    dataFimExercicio: s.dataFimExercicio,
    indicador: cat.indicador(s),
  }
}

function apurarCategoria(
  cat: CategoriaDef,
  elegiveis: IdsScore[],
): CategoriaApurada | null {
  const universo = cat.regiao
    ? elegiveis.filter((s) => regiaoDaUf(s.uf) === cat.regiao)
    : elegiveis
  if (universo.length === 0) return null

  const ordenado = ordenar(cat, universo)
  const podio = ordenado.slice(0, 3).map((s, i) => toPremiado(s, cat, i + 1))

  return {
    id: cat.id,
    eixo: cat.eixo,
    titulo: cat.titulo,
    descricao: cat.descricao,
    criterio: cat.criterio,
    peso: cat.peso,
    regiao: cat.regiao,
    ufs: cat.regiao ? REGIOES[cat.regiao].ufs : undefined,
    elegiveis: universo.length,
    vencedor: podio[0],
    podio,
    houveDesempate:
      ordenado.length > 1 && cat.valor(ordenado[0]) === cat.valor(ordenado[1]),
  }
}

/** Lê o snapshot fixado para a premiação, ou null se ainda não há um. */
export async function getSnapshotFixado(env: Env): Promise<string | null> {
  try {
    const row = await env.SENADO_DB.prepare(
      `SELECT valor FROM premiacao_config WHERE chave = ?`,
    )
      .bind(CHAVE_SNAPSHOT)
      .first<{ valor: string }>()
    return row?.valor ?? null
  } catch {
    // Tabela ainda não migrada — trata como "não fixado".
    return null
  }
}

/** Fixa o snapshot da premiação. `null` desfaz o congelamento. */
export async function setSnapshotFixado(
  env: Env,
  computedAt: string | null,
): Promise<void> {
  if (computedAt === null) {
    await env.SENADO_DB.prepare(
      `DELETE FROM premiacao_config WHERE chave = ?`,
    )
      .bind(CHAVE_SNAPSHOT)
      .run()
    return
  }
  await env.SENADO_DB.prepare(
    `INSERT INTO premiacao_config (chave, valor, atualizado_em)
     VALUES (?, ?, ?)
     ON CONFLICT(chave) DO UPDATE SET valor = excluded.valor,
                                      atualizado_em = excluded.atualizado_em`,
  )
    .bind(CHAVE_SNAPSHOT, computedAt, new Date().toISOString())
    .run()
}

export class PremiacaoIndisponivel extends Error {
  constructor(
    readonly motivo: 'sem_snapshot' | 'snapshot_incompleto' | 'sem_elegiveis',
    readonly detalhe: string,
  ) {
    super(detalhe)
    this.name = 'PremiacaoIndisponivel'
  }
}

/**
 * Apura os 12 prêmios da Legislatura 57.
 *
 * Usa o snapshot fixado quando existe; senão o mais recente, marcando
 * `congelado: false` para a página deixar claro que o resultado pode mudar.
 */
export async function apurarPremiacao(env: Env): Promise<Premiacao> {
  const fixado = await getSnapshotFixado(env)
  const snap = await getRankingSnapshot(env, fixado ?? undefined)

  if (!snap) {
    throw new PremiacaoIndisponivel(
      'sem_snapshot',
      fixado
        ? `snapshot fixado ${fixado} não existe em ranking_snapshots`
        : 'nenhum snapshot de ranking disponível',
    )
  }

  if (snap.scores.length < MIN_LINHAS_SNAPSHOT) {
    throw new PremiacaoIndisponivel(
      'snapshot_incompleto',
      `snapshot ${snap.computedAt} tem ${snap.scores.length} linhas, ` +
        `abaixo do mínimo de ${MIN_LINHAS_SNAPSHOT}`,
    )
  }

  const elegiveis = snap.scores.filter((s) => s.elegivelPremio !== false)
  // Régua da vitória: concorre quem exerceu ao menos metade da legislatura.
  const concorrentes = elegiveis.filter(
    (s) => (s.mesesAtivos ?? 0) >= MESES_MIN_VITORIA,
  )
  if (concorrentes.length === 0) {
    throw new PremiacaoIndisponivel(
      'sem_elegiveis',
      `snapshot ${snap.computedAt} não tem ninguém com ` +
        `${MESES_MIN_VITORIA}+ meses de exercício`,
    )
  }

  const defs = [GRANDE_PREMIO, ...categoriasRegionais(), ...TEMATICAS]
  const categorias = defs
    .map((cat) => apurarCategoria(cat, concorrentes))
    .filter((c): c is CategoriaApurada => c !== null)

  return {
    edicao: 'leg57',
    legislatura: 57,
    congelado: fixado !== null,
    snapshot: snap.computedAt,
    apuradoEm: new Date().toISOString(),
    coorte: {
      avaliados: snap.scores.length,
      elegiveis: elegiveis.length,
      concorrentes: concorrentes.length,
      mesesMinimos: MESES_MIN_PREMIO,
      mesesMinimosVitoria: MESES_MIN_VITORIA,
    },
    categorias,
  }
}