TS906 linhas32.903 bytes
apps/worker/src/services/legis.ts
SHA-256
a185799ebb936956261768b9b3130a5c70b0bf73f4ef0a76acc5044c8663b92b
Somente leiturafonte-acf635076da2
import type {
Env,
Senador,
RawDimensions,
SenadorStatus,
Confianca,
SenadorAfastado,
} from '../types'
const CACHE_TTL_SECONDS = 6 * 60 * 60 // 6h
// Legislatura 57 — 01/02/2023 a 31/01/2027
const LEG57_INICIO = '2023-02-01'
const LEG57_FIM = '2027-01-31'
const LEG57_NUMERO = '57'
// Anos a iterar para CEAP/discursos (somar todos)
const LEG57_ANOS = [2023, 2024, 2025, 2026]
/**
* Mínimo de meses em exercício na Leg 57 para concorrer ao prêmio da
* legislatura. Abaixo disso o parlamentar continua no ranking (com score
* calculado contra a distribuição dos elegíveis), mas fora de competição.
*
* 12 meses = um ano legislativo completo. Com o valor atual são 82 elegíveis
* de 104 que exerceram o mandato; deixa de fora suplentes de passagem
* relâmpago (3–4 meses) e os recém-empossados de 2026.
*/
export const MESES_MIN_PREMIO = 12
// Pesos por tipo de proposição (IDS v2 — Produtividade Legislativa)
// Fonte da escala: complexidade típica do trâmite + impacto normativo
const PESO_PROPOSICAO: Record<string, number> = {
PEC: 3.0, // Proposta de Emenda à Constituição
PLP: 2.0, // Projeto de Lei Complementar
PL: 1.5, // Projeto de Lei Ordinário
PLN: 1.5, // Projeto de Lei do Congresso Nacional
PLS: 1.5, // (legado) Projeto de Lei do Senado
PDL: 1.2, // Projeto de Decreto Legislativo
PDS: 1.2, // Projeto de Decreto do Senado
PRS: 1.0, // Projeto de Resolução do Senado
MPV: 1.0, // Apreciação de Medida Provisória
RQS: 0.4, // Requerimento
RQN: 0.4, // Requerimento (numerado)
REQ: 0.4, // Requerimento (genérico)
INC: 0.2, // Indicação
IND: 0.2, // Indicação (legado)
// fallback: 0.5
}
// Pesos por status final (IDS v2 — Efetividade)
const PESO_STATUS = {
LEI: 3.0, // Sancionada / promulgada
APROVADO_SF: 1.5, // Aprovado no Senado (mesmo se ainda em outra casa)
TRAMITACAO_AVANCADA: 0.5, // Em comissão de mérito ou plenário
}
async function cachedFetch(
env: Env,
cacheKey: string,
url: string,
): Promise<unknown> {
const cached = await env.SENADO_CACHE.get(cacheKey, 'json')
if (cached !== null) return cached
const RETRY_DELAYS_MS = [0, 500, 1500]
let lastErr: unknown = null
for (const delay of RETRY_DELAYS_MS) {
if (delay > 0) await new Promise((r) => setTimeout(r, delay))
try {
const res = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(30_000),
})
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
throw new Error(`LEGIS fetch failed: ${url} → ${res.status}`)
}
if (!res.ok) {
lastErr = new Error(`LEGIS fetch transient: ${url} → ${res.status}`)
continue
}
const data = await res.json()
await env.SENADO_CACHE.put(cacheKey, JSON.stringify(data), {
expirationTtl: CACHE_TTL_SECONDS,
})
return data
} catch (err) {
lastErr = err
}
}
throw lastErr ?? new Error(`LEGIS fetch failed: ${url}`)
}
function exerciciosArray(mand: Record<string, unknown>): Record<string, unknown>[] {
const exercicios = mand?.Exercicios as Record<string, unknown> | undefined
if (!exercicios) return []
const lst = exercicios?.Exercicio
return (Array.isArray(lst) ? lst : lst ? [lst] : []) as Record<string, unknown>[]
}
/**
* Normaliza as duas formas em que a API entrega mandatos e devolve só os que
* tocam a Leg 57:
* - `lista/atual.json` → `Mandato` (objeto único, o mandato corrente)
* - `lista/legislatura/57.json` → `Mandatos.Mandato` (array com o histórico
* inteiro do parlamentar, inclusive mandatos de legislaturas antigas)
*
* Filtrar por número de legislatura aqui é o que impede a regressão do commit
* 11e9e8e: Carlos Fávaro carrega um mandato 56→57 cujo exercício terminou em
* 2020, e sem esse corte o mandato velho contamina o cálculo do atual.
*/
function mandatosLeg57(
parlamentar: Record<string, unknown>,
): Record<string, unknown>[] {
const single = parlamentar?.Mandato as Record<string, unknown> | undefined
const multi = (parlamentar?.Mandatos as Record<string, unknown>)?.Mandato
const brutos = (
Array.isArray(multi) ? multi : multi ? [multi] : single ? [single] : []
) as Record<string, unknown>[]
return brutos.filter((m) => {
// Mandato de 8 anos aparece como Primeira+Segunda legislatura; basta uma bater
const legs = [
'PrimeiraLegislaturaDoMandato',
'SegundaLegislaturaDoMandato',
].map((k) => String((m?.[k] as Record<string, unknown>)?.NumeroLegislatura ?? ''))
if (legs.some((n) => n === LEG57_NUMERO)) return true
// Sem número de legislatura: só o `Mandato` corrente do atual.json cai aqui
return legs.every((n) => n === '')
})
}
export interface ResumoExercicio {
/** Meses efetivamente em exercício dentro da janela da Leg 57 (mín. 1). */
meses: number
dataInicio: string
/** Última DataFim de exercício — só preenchida para quem já saiu. */
dataFim?: string
emExercicio: boolean
/** Houve ao menos um exercício real dentro da janela da Leg 57. */
temExercicio: boolean
participacao: string
}
/**
* Soma os meses efetivamente em exercício durante a Leg 57 e diz se o mandato
* segue aberto. Trata uniforme titulares afastados/retornados (Renan Filho:
* licenciado virou ministro e voltou), suplentes alternantes (Jussara Lima:
* 4 exercícios desde 2023-02-06) e mandatos encerrados (Jorge Seif).
*
* A janela fecha em LEG57_FIM: depois de 31/01/2027 ninguém acumula mais
* meses, de modo que o ranking do prêmio congela sozinho no fim da legislatura.
*/
function resumoExercicioLeg57(
parlamentar: Record<string, unknown>,
): ResumoExercicio {
const hoje = todayIso()
const limite = hoje < LEG57_FIM ? hoje : LEG57_FIM
const mandatos = mandatosLeg57(parlamentar)
let meses = 0
let temExercicio = false
let emExercicio = false
let menorInicio = ''
let maiorFim = ''
let participacao = ''
for (const mand of mandatos) {
const desc = String(mand?.DescricaoParticipacao ?? '')
const arr = exerciciosArray(mand)
if (arr.length === 0) {
// Titular sem exercícios listados → assume Leg57 inteira (dado incompleto
// da API, comportamento herdado do IDS v3)
if (desc === 'Titular') {
meses += monthsBetween(LEG57_INICIO, limite)
temExercicio = true
emExercicio = true
participacao = participacao || desc
}
continue
}
for (const e of arr) {
const iniBruto = String(e?.DataInicio ?? '')
if (!iniBruto) continue
const fimBruto = String(e?.DataFim ?? '')
// Intervalo inteiramente fora da janela da Leg 57 (mandato anterior ou
// exercício futuro): ignora sem marcar temExercicio
if ((fimBruto && fimBruto < LEG57_INICIO) || iniBruto > limite) continue
const ini = iniBruto < LEG57_INICIO ? LEG57_INICIO : iniBruto
const fim = !fimBruto || fimBruto > limite ? limite : fimBruto
if (fim < ini) continue
temExercicio = true
meses += monthsBetween(ini, fim)
participacao = desc || participacao
if (!menorInicio || ini < menorInicio) menorInicio = ini
if (!fimBruto || fimBruto > limite) {
emExercicio = true
} else if (fimBruto > maiorFim) {
maiorFim = fimBruto
}
}
}
// Titular sempre datado do início da legislatura (parity com o IDS v3)
const dataInicio =
participacao === 'Titular' ? LEG57_INICIO : menorInicio || LEG57_INICIO
return {
meses: Math.max(meses, 1),
dataInicio,
dataFim: emExercicio ? undefined : maiorFim || undefined,
emExercicio,
temExercicio,
participacao,
}
}
function monthsBetween(startIso: string, endIso: string): number {
const start = new Date(startIso + 'T00:00:00Z')
const end = new Date(endIso + 'T00:00:00Z')
const diff =
(end.getUTCFullYear() - start.getUTCFullYear()) * 12 +
(end.getUTCMonth() - start.getUTCMonth())
return Math.max(diff, 0)
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10)
}
/** Compatibilidade: usado em /admin/warmup. */
export function mesesAtivos(dataInicio: string): number {
return monthsBetween(dataInicio, todayIso()) || 1
}
/**
* Retorna o conjunto de códigos de senadores afastados (ex.: licenciados como
* ministros). São excluídos do ranking principal — não exercem o mandato hoje.
*
* Cruza com lista/atual.json para evitar excluir senadores que aparecem em
* /lista/afastados por mandato anterior mas estão em exercício atualmente
* (ex.: cod 6295 — Carlos Fávaro, término da 2ª legislatura mas reeleito).
*/
async function getAfastadosCods(env: Env): Promise<Set<string>> {
const [lista, emExData] = await Promise.all([
getAfastadosFull(env),
cachedFetch(
env,
'legis:senadores:lista:atual',
`${env.LEGIS_BASE_URL}/senador/lista/atual.json`,
) as Promise<Record<string, unknown>>,
])
const parlamentaresAtivos =
(
(emExData?.ListaParlamentarEmExercicio as Record<string, unknown>)
?.Parlamentares as Record<string, unknown>
)?.Parlamentar ?? []
const arr = Array.isArray(parlamentaresAtivos)
? parlamentaresAtivos
: [parlamentaresAtivos]
const emExercicioCods = new Set(
(arr as Record<string, unknown>[])
.map((p) =>
String(
(p?.IdentificacaoParlamentar as Record<string, unknown>)
?.CodigoParlamentar ?? '',
),
)
.filter(Boolean),
)
// lista/atual.json é autoritativo: quem está em exercício nunca deve ser
// excluído mesmo que apareça em /lista/afastados por mandato anterior
return new Set(
lista.map((s) => s.codigo).filter((cod) => !emExercicioCods.has(cod)),
)
}
/**
* Lista detalhada dos senadores afastados (com motivo, datas) para a aba
* "Afastados" do ranking. Não há score IDS aqui — só estado atual.
*/
export async function getAfastadosFull(env: Env): Promise<SenadorAfastado[]> {
try {
const data = (await cachedFetch(
env,
'legis:senadores:afastados:atual',
`${env.LEGIS_BASE_URL}/senador/lista/afastados`,
)) as Record<string, unknown>
const lista =
(
(data?.AfastamentoAtual as Record<string, unknown>)
?.Parlamentares as Record<string, unknown>
)?.Parlamentar ?? []
const arr = Array.isArray(lista) ? lista : [lista]
return (arr as Record<string, unknown>[])
.map((p) => {
const id = p?.IdentificacaoParlamentar as Record<string, unknown>
const mand = (p?.Mandato as Record<string, unknown>) ?? {}
const participacao = String(mand?.DescricaoParticipacao ?? '')
// Lista oficial mistura titulares licenciados (ministros) com suplentes
// sem exercício corrente. Para suplentes, motivo é o do último exercício
// encerrado. Para titulares licenciados, vem de Mandato.Afastamentos.
const af = (mand?.Afastamentos as Record<string, unknown>) ?? {}
const afItens = af?.Afastamento
const afArr = (Array.isArray(afItens) ? afItens : afItens ? [afItens] : []) as Record<string, unknown>[]
const afAtual = afArr.find((a) => !a?.DataFimAfastamento)
const ex = (mand?.Exercicios as Record<string, unknown>) ?? {}
const exItens = ex?.Exercicio
const exArr = (Array.isArray(exItens) ? exItens : exItens ? [exItens] : []) as Record<string, unknown>[]
const exUltimo = exArr.length > 0 ? exArr[exArr.length - 1] : undefined
let motivo = ''
let dataInicio = ''
let dataFim: string | undefined
if (afAtual) {
motivo = String(afAtual.DescricaoCausaAfastamento ?? 'Licenciado')
dataInicio = String(afAtual.DataInicioAfastamento ?? '')
} else if (participacao !== 'Titular') {
motivo =
exUltimo
? String(exUltimo.DescricaoCausaAfastamento ?? 'Não está em exercício')
: 'Aguardando convocação'
dataInicio = String(exUltimo?.DataFim ?? '')
dataFim = undefined
} else if (exUltimo?.DataFim) {
motivo = String(exUltimo.DescricaoCausaAfastamento ?? 'Fora de exercício')
dataInicio = String(exUltimo.DataFim ?? '')
} else {
motivo = 'Afastado'
}
return {
codigo: String(id?.CodigoParlamentar ?? ''),
nome: String(id?.NomeParlamentar ?? ''),
partido: String(id?.SiglaPartidoParlamentar ?? ''),
uf: String(mand?.UfParlamentar ?? id?.UfParlamentar ?? ''),
fotoUrl: String(id?.UrlFotoParlamentar ?? ''),
motivo,
dataInicio,
dataFim,
}
})
.filter((s) => s.codigo !== '')
} catch {
return []
}
}
/**
* Determina o status taxonômico do parlamentar para fins de ranking/filtro.
* Não considera afastamento (esse caso é tratado fora do ranking principal).
*/
export function computeStatus(
participacao: string,
mesesAtivosLeg57: number,
): SenadorStatus {
if (mesesAtivosLeg57 < 6) return 'recente'
const isTitular = participacao === 'Titular'
if (isTitular) {
return mesesAtivosLeg57 >= 30 ? 'titular_pleno' : 'titular_voltou'
}
return mesesAtivosLeg57 >= 12 ? 'suplente_efetivo' : 'suplente_recente'
}
/**
* Confiança estatística do score: alta ≥24m, média 12-23m, baixa <12m.
* Reflete o quanto o histórico permite avaliar com segurança.
*/
export function computeConfianca(mesesAtivosLeg57: number): Confianca {
if (mesesAtivosLeg57 >= 24) return 'alta'
if (mesesAtivosLeg57 >= 12) return 'media'
return 'baixa'
}
/**
* Coorte da Leg 57 que já deixou o exercício: suplente que devolveu a cadeira
* ao titular, titular licenciado, renúncia, morte. Continuam no ranking porque
* o prêmio é da legislatura inteira — quem trabalhou 3 anos e saiu em abril
* não pode simplesmente desaparecer da avaliação.
*
* `exercicio=S` reduz os 245 registros da legislatura aos 123 que chegaram a
* tomar posse em algum mandato; o filtro por exercício real dentro da janela
* corta o resto (Major Olimpio e Arolde de Oliveira, por exemplo, morreram na
* Leg 56 mas carregam mandato que formalmente alcança a 57).
*
* Aferido em 2026-08-08: 104 com exercício real, dos quais 23 já encerrados.
*/
async function getEncerradosLeg57(
env: Env,
emExercicioCods: Set<string>,
): Promise<Senador[]> {
let data: Record<string, unknown>
try {
data = (await cachedFetch(
env,
'legis:senadores:lista:leg57',
`${env.LEGIS_BASE_URL}/senador/lista/legislatura/${LEG57_NUMERO}.json?exercicio=S`,
)) as Record<string, unknown>
} catch (err) {
// Degrada para o ranking só com quem está em exercício em vez de derrubar
// o cálculo inteiro — melhor um ranking incompleto que nenhum.
console.error('[legis] lista da legislatura indisponível:', err)
return []
}
const lista =
(
(data?.ListaParlamentarLegislatura as Record<string, unknown>)
?.Parlamentares as Record<string, unknown>
)?.Parlamentar ?? []
const arr = (Array.isArray(lista) ? lista : [lista]) as Record<string, unknown>[]
const encerrados: Senador[] = []
for (const p of arr) {
const id = (p?.IdentificacaoParlamentar as Record<string, unknown>) ?? {}
const codigo = String(id?.CodigoParlamentar ?? '')
if (!codigo || emExercicioCods.has(codigo)) continue
const resumo = resumoExercicioLeg57(p)
if (!resumo.temExercicio || resumo.emExercicio) continue
// A lista da legislatura não traz Bloco e nem sempre traz UF na
// identificação — o mandato é a fonte confiável da UF.
const uf =
String(id?.UfParlamentar ?? '') ||
String(mandatosLeg57(p)[0]?.UfParlamentar ?? '')
const partido = String(id?.SiglaPartidoParlamentar ?? '')
encerrados.push({
codigo,
nome: String(id?.NomeParlamentar ?? id?.NomeCompletoParlamentar ?? ''),
partido,
uf,
bloco: partido,
fotoUrl: String(id?.UrlFotoParlamentar ?? ''),
email: String(id?.EmailParlamentar ?? ''),
dataInicioExercicio: resumo.dataInicio,
mesesAtivos: resumo.meses,
// Mantém a taxonomia existente (titular/suplente) e sinaliza o mandato
// encerrado só por `emExercicio`: campo novo, que clientes antigos
// ignoram. Emitir um `status` inédito aqui quebrou o site publicado.
status: computeStatus(resumo.participacao, resumo.meses),
confianca: computeConfianca(resumo.meses),
emExercicio: false,
dataFimExercicio: resumo.dataFim,
})
}
return encerrados
}
/**
* Universo do ranking: a coorte da Legislatura 57, não o retrato de hoje.
* Quem está em exercício vem de `lista/atual.json` (única fonte com bloco e
* e-mail); quem já saiu entra por `getEncerradosLeg57`.
*/
export async function getSenadorList(env: Env): Promise<Senador[]> {
const [data, afastadosCods] = await Promise.all([
cachedFetch(
env,
'legis:senadores:lista:atual',
`${env.LEGIS_BASE_URL}/senador/lista/atual.json`,
) as Promise<Record<string, unknown>>,
getAfastadosCods(env),
])
const lista =
(
(data?.ListaParlamentarEmExercicio as Record<string, unknown>)
?.Parlamentares as Record<string, unknown>
)?.Parlamentar ?? []
const ativos = (lista as Record<string, unknown>[])
.map((p) => {
const id = p?.IdentificacaoParlamentar as Record<string, unknown>
const resumo = resumoExercicioLeg57(p)
return {
codigo: String(id?.CodigoParlamentar ?? ''),
nome: String(id?.NomeParlamentar ?? id?.NomeCompletoParlamentar ?? ''),
partido: String(id?.SiglaPartidoParlamentar ?? ''),
uf: String(id?.UfParlamentar ?? ''),
bloco: String(
(id?.Bloco as Record<string, unknown>)?.NomeBloco ??
id?.SiglaPartidoParlamentar ??
'',
),
fotoUrl: String(id?.UrlFotoParlamentar ?? ''),
email: String(id?.EmailParlamentar ?? ''),
dataInicioExercicio: resumo.dataInicio,
mesesAtivos: resumo.meses,
status: computeStatus(resumo.participacao, resumo.meses),
confianca: computeConfianca(resumo.meses),
emExercicio: true,
}
})
.filter((s) => s.codigo !== '' && !afastadosCods.has(s.codigo))
// Exclui TODOS que aparecem no atual.json, inclusive os cortados como
// afastados: quem já foi avaliado por lá não pode voltar como "encerrado".
const naListaAtual = new Set(
(lista as Record<string, unknown>[])
.map((p) =>
String(
(p?.IdentificacaoParlamentar as Record<string, unknown>)
?.CodigoParlamentar ?? '',
),
)
.filter(Boolean),
)
const encerrados = await getEncerradosLeg57(env, naListaAtual)
console.log(
`[legis] coorte Leg57: ${ativos.length} em exercício + ${encerrados.length} encerrados`,
)
return [...ativos, ...encerrados]
}
/**
* Classifica o status de uma autoria a partir da resposta do endpoint /processo.
* Retorna o peso da proposição (LEI/APROVADO_SF/TRAMITACAO/0) para Efetividade.
*
* O sinal mais confiável é `siglaTipoDeliberacao` (APROVADA_NO_PLENARIO,
* APROVADA_EM_COMISSAO_TERMINATIVA, PREJUDICADO, RETIRADO_PELO_AUTOR, …).
* Fallbacks usam descricaoSituacao/objetivo/tramitando para casos antigos.
*/
function classificarStatus(p: Record<string, unknown>): number {
const delib = String(p?.siglaTipoDeliberacao ?? '').toUpperCase()
const tram = String(p?.tramitando ?? '').toLowerCase()
const obj = String(p?.objetivo ?? '').toLowerCase()
const sit = String(
(p?.identificacaoMateria as Record<string, unknown>)?.descricaoSituacao ??
p?.descricaoSituacao ??
p?.situacao ??
'',
).toLowerCase()
// Virou lei / promulgada / norma jurídica
if (
delib.includes('TRANSFORMAD') ||
delib.includes('SANCIONAD') ||
delib.includes('PROMULGAD') ||
sit.includes('transformad') ||
sit.includes('sancionad') ||
sit.includes('promulgad') ||
sit.includes('norma jur')
) {
return PESO_STATUS.LEI
}
// Aprovado no Senado (plenário ou comissão terminativa)
if (
delib.startsWith('APROVAD') ||
delib === 'DEFERIDO_CDIR' ||
delib === 'APROVADO_CDIR' ||
sit.includes('aprovad') ||
sit.includes('remetid') ||
obj.includes('aprovad')
) {
return PESO_STATUS.APROVADO_SF
}
// Tramitando: vale pouco (ainda pode evoluir)
if (tram === 'sim' || tram === 'true') {
return PESO_STATUS.TRAMITACAO_AVANCADA
}
// Arquivado, prejudicado, retirado, vetado: 0
return 0
}
/**
* Extrai a sigla do tipo de proposição (PEC, PL, RQS, etc.) do item /processo.
* Na listagem `/processo?codigoParlamentarAutor=...` o campo `identificacao`
* vem no formato "PL 1678/2023" — primeira palavra é a sigla.
*/
function extrairSigla(p: Record<string, unknown>): string {
// 1) Detalhe: campo `sigla` direto
const direta = String(p?.sigla ?? '').toUpperCase()
if (direta) return direta
// 2) Listagem: parse de `identificacao` ("PL 1678/2023")
const ident = String(p?.identificacao ?? '')
const match = ident.match(/^([A-Z]+)\s/)
if (match) return match[1]
// 3) Legado
const legacy = p?.identificacaoMateria as Record<string, unknown> | undefined
return String(
legacy?.siglaSubtipoMateria ??
legacy?.siglaTipoMateria ??
p?.siglaSubtipoMateria ??
p?.siglaTipoMateria ??
'',
).toUpperCase()
}
export async function getRawDimensions(
env: Env,
codigo: string,
): Promise<RawDimensions> {
const base = env.LEGIS_BASE_URL
// Discursos: iterar por ano (API quebra com janelas > 1 ano)
const discursosPromises = LEG57_ANOS.map((ano) =>
cachedFetch(
env,
`legis:${codigo}:discursos:${ano}`,
`${base}/senador/${codigo}/discursos.json?dataIni=${ano}0101&dataFim=${ano}1231`,
).catch(() => null),
)
// Apartes: também por ano
const apartesPromises = LEG57_ANOS.map((ano) =>
cachedFetch(
env,
`legis:${codigo}:apartes:${ano}`,
`${base}/senador/${codigo}/apartes.json?dataIni=${ano}0101&dataFim=${ano}1231`,
).catch(() => null),
)
const [
relatoriasResult,
votacoesResult,
votacoesComissaoResult,
liderancasResult,
cargosResult,
...discursosEApartes
] = await Promise.allSettled([
cachedFetch(
env,
`legis:${codigo}:relatorias:leg57`,
`${base}/senador/${codigo}/relatorias.json`,
),
cachedFetch(
env,
`legis:${codigo}:votacoes:leg57`,
`${base}/senador/${codigo}/votacoes.json`,
),
cachedFetch(
env,
`legis:${codigo}:votacaoComissao`,
`${base}/votacaoComissao/parlamentar/${codigo}.json`,
).catch(() => null),
cachedFetch(
env,
`legis:${codigo}:liderancas`,
`${base}/senador/${codigo}/liderancas.json`,
).catch(() => null),
cachedFetch(
env,
`legis:${codigo}:cargos`,
`${base}/senador/${codigo}/cargos.json`,
).catch(() => null),
...discursosPromises,
...apartesPromises,
])
const discursosPorAno = discursosEApartes.slice(0, LEG57_ANOS.length)
const apartesPorAno = discursosEApartes.slice(LEG57_ANOS.length)
// ----- Autorias com peso por tipo + status detalhado -----
let autoriasTotal = 0
let autoriasAprovadas = 0
let produtividadePonderada = 0 // soma de pesos × proposições
let efetividadePonderada = 0 // soma de status × peso_tipo
let efetividadeBase = 0 // soma de peso_tipo (denominador)
// /processo é load-bearing: falha aqui propaga e warmup classifica como
// rejected, evitando cache poisoning com autoriasTotal=0.
const processos = await cachedFetch(
env,
`legis:${codigo}:processos:leg57`,
`${base}/processo?codigoParlamentarAutor=${codigo}&tramitouLegislaturaAtual=S`,
)
if (Array.isArray(processos)) {
autoriasTotal = processos.length
for (const p of processos as Record<string, unknown>[]) {
const sigla = extrairSigla(p)
const peso = PESO_PROPOSICAO[sigla] ?? 0.5
produtividadePonderada += peso
const statusPeso = classificarStatus(p)
if (statusPeso > 0) {
efetividadePonderada += statusPeso * peso
if (statusPeso >= PESO_STATUS.APROVADO_SF) autoriasAprovadas += 1
}
efetividadeBase += peso
}
}
// ----- Relatorias (Leg 57) -----
let relatoriasTotal = 0
if (relatoriasResult.status === 'fulfilled' && relatoriasResult.value) {
const val = relatoriasResult.value as Record<string, unknown>
const mat = val?.MateriasRelatoriaParlamentar as Record<string, unknown>
const parl = mat?.Parlamentar as Record<string, unknown>
const itens = extractList(parl ?? mat ?? val, ['Relatorias', 'Relatoria'])
relatoriasTotal = itens.filter((r) => {
const dt = String((r as Record<string, unknown>)?.DataDesignacao ?? '')
return dt >= LEG57_INICIO
}).length
}
// ----- Votações (Leg 57) -----
let votacoesPresentes = 0
let votacoesTotal = 0
if (votacoesResult.status === 'fulfilled' && votacoesResult.value) {
const val = votacoesResult.value as Record<string, unknown>
const parl = (val?.VotacaoParlamentar as Record<string, unknown>)
?.Parlamentar as Record<string, unknown>
const itens = extractList(parl ?? val, ['Votacoes', 'Votacao'])
const leg57 = itens.filter((v) => {
const dt = String(
((v as Record<string, unknown>)?.SessaoPlenaria as Record<string, unknown>)
?.DataSessao ?? '',
)
return dt >= LEG57_INICIO
})
votacoesTotal = leg57.length
votacoesPresentes = leg57.filter((v) => {
const voto = String((v as Record<string, unknown>)?.SiglaDescricaoVoto ?? '').toLowerCase()
return voto === 'votou' || voto === 'sim' || voto === 'não' || voto === 'abstencao'
}).length
}
// ----- Votações em Comissão (Leg 57) -----
let votacoesComissaoPresentes = 0
let votacoesComissaoTotal = 0
if (votacoesComissaoResult.status === 'fulfilled' && votacoesComissaoResult.value) {
const val = votacoesComissaoResult.value as Record<string, unknown>
const vc = val?.VotacoesComissao as Record<string, unknown>
const itens = extractList(vc ?? val, ['Votacoes', 'Votacao'])
for (const v of itens as Record<string, unknown>[]) {
const dt = String(v?.DataHoraInicioReuniao ?? '').slice(0, 10)
if (dt < LEG57_INICIO) continue
votacoesComissaoTotal += 1
// Procurar voto do parlamentar dentro da lista
const votos = v?.Votos as Record<string, unknown>
const lista = votos ? extractList(votos, ['Voto']) : []
for (const vt of lista as Record<string, unknown>[]) {
if (String(vt?.CodigoParlamentar ?? '') === codigo) {
const qv = String(vt?.QualidadeVoto ?? '').toUpperCase()
// S=Sim, N=Não, A=Abst, P=Presente não votou
if (qv === 'S' || qv === 'N' || qv === 'A' || qv === 'P') {
votacoesComissaoPresentes += 1
}
break
}
}
}
}
// ----- Discursos -----
let discursosTotal = 0
for (const result of discursosPorAno) {
if (result.status !== 'fulfilled' || !result.value) continue
const val = result.value as Record<string, unknown>
const disc = val?.DiscursosParlamentar as Record<string, unknown>
const parl = disc?.Parlamentar as Record<string, unknown>
const pron = parl?.Pronunciamentos as Record<string, unknown>
const itens = pron ? extractList(pron, ['Pronunciamento']) : []
discursosTotal += itens.length
}
// ----- Apartes -----
let apartesTotal = 0
for (const result of apartesPorAno) {
if (result.status !== 'fulfilled' || !result.value) continue
const val = result.value as Record<string, unknown>
const apa = val?.ApartesParlamentar as Record<string, unknown>
const parl = apa?.Parlamentar as Record<string, unknown>
const apartes = parl?.Apartes as Record<string, unknown>
const itens = apartes ? extractList(apartes, ['Aparte']) : []
apartesTotal += itens.length
}
// ----- Cargos de liderança (ajuste estrutural Fiscalização) -----
// Conta lideranças e cargos exercidos na Legislatura 57 — ativos contam
// peso 1.0; cargos já encerrados (mas dentro da legislatura) contam 0.4,
// pois ainda evidenciam acesso estrutural a relatorias durante o período.
const cargosTitulos: string[] = []
let cargosPeso = 0 // soma contínua para o ajuste
const hoje = new Date().toISOString().slice(0, 10)
function classificarPeriodo(
inicio: string,
fim: string,
): 'ativo' | 'leg57_passado' | 'fora' {
if (!inicio || inicio < LEG57_INICIO) {
// Pode ainda estar ativo embora tenha começado antes — vale como ativo
if (!fim || fim > hoje) return 'ativo'
// Terminável antes ou durante a leg
if (fim >= LEG57_INICIO) return 'leg57_passado'
return 'fora'
}
// Iniciado na leg 57
if (!fim || fim > hoje) return 'ativo'
return 'leg57_passado'
}
if (liderancasResult.status === 'fulfilled' && liderancasResult.value) {
const val = liderancasResult.value as Record<string, unknown>
const lid = val?.LiderancaParlamentar as Record<string, unknown>
const parl = lid?.Parlamentar as Record<string, unknown>
const itens = extractList(parl ?? lid ?? val, ['Liderancas', 'Lideranca'])
const titulosVistos = new Set<string>()
for (const it of itens as Record<string, unknown>[]) {
const dataIni = String(it?.DataInicio ?? it?.DataDesignacao ?? '')
const dataFim = String(it?.DataFim ?? it?.DataTermino ?? '')
const periodo = classificarPeriodo(dataIni, dataFim)
if (periodo === 'fora') continue
const desc = String(
it?.DescricaoTipoLideranca ?? it?.DescricaoCargo ?? 'Liderança',
)
const partidoSigla = String(
(it?.Partido as Record<string, unknown>)?.SiglaPartido ?? '',
)
const blocoNome = String(
(it?.Bloco as Record<string, unknown>)?.ApelidoBloco ??
(it?.Bloco as Record<string, unknown>)?.NomeBloco ?? '',
)
const entidade = partidoSigla || blocoNome
const sufixo = periodo === 'ativo' ? '' : ' (encerrado)'
const titulo = entidade ? `${desc} — ${entidade}${sufixo}` : `${desc}${sufixo}`
if (titulosVistos.has(titulo)) continue
titulosVistos.add(titulo)
cargosTitulos.push(titulo)
cargosPeso += periodo === 'ativo' ? 1.0 : 0.4
}
}
if (cargosResult.status === 'fulfilled' && cargosResult.value) {
const val = cargosResult.value as Record<string, unknown>
const car = val?.CargoParlamentar as Record<string, unknown>
const parl = car?.Parlamentar as Record<string, unknown>
const itens = extractList(parl ?? car ?? val, ['Cargos', 'Cargo'])
for (const it of itens as Record<string, unknown>[]) {
const desc = String(it?.DescricaoCargo ?? '')
const dl = desc.toLowerCase()
// Só contar cargos com poder estrutural (presidência, vice, mesa, secretaria)
const relevante =
dl.includes('presidente') ||
dl.includes('mesa') ||
dl.includes('vice') ||
dl.includes('secret')
if (!relevante) continue
const dataIni = String(it?.DataInicio ?? '')
const dataFim = String(it?.DataFim ?? '')
const periodo = classificarPeriodo(dataIni, dataFim)
if (periodo === 'fora') continue
const orgao = String(
(it?.IdentificacaoComissao as Record<string, unknown>)
?.NomeComissao ??
it?.NomeOrgao ??
'',
)
const titulo = orgao ? `${desc} — ${orgao}` : desc
const sufixo = periodo === 'ativo' ? '' : ' (encerrado)'
cargosTitulos.push(titulo + sufixo)
cargosPeso += periodo === 'ativo' ? 1.0 : 0.4
}
}
const cargosLideranca = Math.round(cargosPeso * 10) / 10
return {
autoriasTotal,
autoriasAprovadas,
produtividadePonderada,
efetividadePonderada,
efetividadeBase,
votacoesPresentes,
votacoesTotal: Math.max(votacoesTotal, 1),
votacoesComissaoPresentes,
votacoesComissaoTotal,
relatoriasTotal,
discursosTotal,
apartesTotal,
cargosLideranca,
cargosTitulos: cargosTitulos.slice(0, 10), // máx 10 (inclui histórico recente)
ceapTotalAno: 0,
}
}
function extractList(
obj: Record<string, unknown> | undefined,
path: string[],
): unknown[] {
if (!obj) return []
let cur: unknown = obj
for (const key of path) {
if (cur && typeof cur === 'object') {
cur = (cur as Record<string, unknown>)[key]
} else {
return []
}
}
if (!cur) return []
return Array.isArray(cur) ? cur : [cur]
}