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,
}
}