teqo-backup/scripts/import-camara-speeches.mjs

1201 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* C153 import + C155 backfill — populates the speech catalog from the Câmara
* sources: open-data speeches → event on that date → speaker excerpt on the
* event page → async VOD → Deep Infra Whisper segments (minutagem) + facet
* classification (gazetteer + LLM) → idempotent upsert into
* `speech`/`speechSegment`.
*
* Re-running is safe: records are matched by `sourceKey`, metadata is
* refreshed, VOD/ASR is skipped when the excerpt identity and segments are
* already stored, and a `manual` facet curation is never overwritten.
*
* C155 adds the full-archive backfill (`--all`, one process, per-legislature
* report + DB coverage) and the read-only modes `--coverage` and
* `--verify-links <n>`. Media and reports land under `data/camara/`
* (gitignored). The local-DB guard refuses a non-local DATABASE_URL, and any
* write run targeting production (or a remote/override DB) additionally
* requires `CAMARA_IMPORT_CONFIRM=1`.
*
* C160 writes `officialTextUrl` as the direct Diário PDF: the import normalizes
* the legacy `urlTexto` (`dc_20b.asp`/`dc_20.asp`) by following its redirect,
* cached once per publication under `<out>/diario/`, and `--repair-links
* [--dry-run]` fixes the already-imported speeches without reimporting.
*
* Usage:
* pnpm camara:import --legislature 57
* pnpm camara:import --date 2023-02-07 --limit 3
* pnpm camara:import --all
* pnpm camara:import --all --limit 2 # smoke
* pnpm camara:import --coverage
* pnpm camara:import --verify-links 3
* pnpm camara:import --repair-links --dry-run
* CAMARA_IMPORT_CONFIRM=1 pnpm camara:import --repair-links
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { getPayload } from 'payload'
import { assertLocalDatabase } from './assert-local-database.mjs'
import {
DEEPINFRA_COST_PER_MINUTE_USD,
downloadToBuffer,
eventPageUrl,
eventsUrl,
getJson,
getJsonWithBackoff,
getText,
probeLink,
resolveOfficialPdfUrl,
resolveVod,
speechesUrl,
transcribeSpeechAudio,
} from './lib/camaraFetch.mjs'
import {
LEGISLATURE_RANGES,
SOLLA_DEPUTY_ID,
aggregateBackfillRuns,
buildOfficialPdfUrl,
classifyOfficialTextUrl,
dateRangeChunks,
isLegacyDiarioUrl,
legislatureForDate,
matchExcerpt,
officialTextUrlFallback,
parseDurationToSeconds,
parseEventExcerpts,
parseLegacyOfficialUrl,
parseMontaPdfUrl,
parseOfficialKeywords,
parsePresidingOfficerTransitions,
planOfficialLinkRepairs,
publicationCacheKey,
resolvePresidingOfficer,
selectLinkSample,
selectSpeechEvents,
speechContentHash,
speechDate,
speechSourceKey,
} from './lib/camaraSpeeches.mjs'
import {
assertWriteConfirm,
databaseTarget,
dieWithLabel,
ensureCachedDownload,
loadCliEnv,
} from './lib/cli.mjs'
loadCliEnv()
const die = dieWithLabel('camara:import')
const config = (await import('../src/payload.config.ts')).default
const { findSpeechImportState, upsertSpeechBundle } =
await import('../src/utilities/speech/speechImport.ts')
const { classifySpeech } = await import('../src/utilities/speech/speechClassifier.ts')
const { getSpeechCoverage } = await import('../src/utilities/speech/speechCoverage.ts')
const WRITE_CONFIRM_FLAG = 'CAMARA_IMPORT_CONFIRM'
const DEFAULT_OUT_DIR = 'data/camara'
const MAX_EVENT_CANDIDATES = 5
const EVENT_TYPE_PRIORITY = [
'Sessão Deliberativa',
'Breves Comunicações',
'Sessão Não Deliberativa',
'Sessão Solene',
'Outro Evento',
]
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const parseArgs = (argv) => {
const options = {
legislature: null,
all: false,
date: null,
limit: null,
speaker: 'Jorge Solla',
speakerProvided: false,
out: DEFAULT_OUT_DIR,
skipTranscribe: false,
reclassify: false,
coverage: false,
verifyLinks: null,
repairLinks: false,
dryRun: false,
help: false,
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
const value = () => {
const next = argv[++index]
if (next === undefined || next.startsWith('--')) die(`faltou valor para ${arg}.`)
return next
}
if (arg === '--help' || arg === '-h') options.help = true
else if (arg === '--all') options.all = true
else if (arg === '--coverage') options.coverage = true
else if (arg === '--verify-links') options.verifyLinks = Number(value())
else if (arg === '--repair-links') options.repairLinks = true
else if (arg === '--dry-run') options.dryRun = true
else if (arg === '--skip-transcribe') options.skipTranscribe = true
else if (arg === '--reclassify') options.reclassify = true
else if (arg === '--legislature') options.legislature = Number(value())
else if (arg === '--date') options.date = value()
else if (arg === '--limit') options.limit = Number(value())
else if (arg === '--speaker') {
options.speaker = value()
options.speakerProvided = true
} else if (arg === '--out') options.out = value()
else die(`argumento desconhecido: ${arg}`)
}
if (options.help) return options
const readOnlyModes = [options.coverage, options.verifyLinks !== null].filter(Boolean).length
const importModes = [options.all, options.date !== null, options.legislature !== null].filter(
Boolean,
).length
if (options.dryRun && !options.repairLinks) die('--dry-run só vale com --repair-links.')
if (options.repairLinks && readOnlyModes > 0) {
die('--repair-links não combina com --coverage/--verify-links.')
}
if (options.repairLinks && importModes > 0) {
die('--repair-links não combina com --all/--date/--legislature.')
}
if (
options.repairLinks &&
(options.limit !== null ||
options.skipTranscribe ||
options.reclassify ||
options.speakerProvided)
) {
die('--limit/--skip-transcribe/--reclassify/--speaker só valem para os modos de import.')
}
if (readOnlyModes > 1) die('--coverage e --verify-links são modos separados.')
if (readOnlyModes > 0 && importModes > 0) {
die('--coverage/--verify-links não combinam com --all/--date/--legislature.')
}
if (
readOnlyModes > 0 &&
(options.limit !== null || options.skipTranscribe || options.reclassify)
) {
die('--limit/--skip-transcribe/--reclassify só valem para os modos de import.')
}
if (options.all && options.date !== null) die('--all e --date são mutuamente exclusivos.')
if (options.all && options.legislature !== null) {
die('--all e --legislature são mutuamente exclusivos.')
}
if (
options.verifyLinks !== null &&
(!Number.isInteger(options.verifyLinks) || options.verifyLinks < 1)
) {
die('--verify-links exige um inteiro >= 1.')
}
if (!options.repairLinks) {
if (importModes === 0 && readOnlyModes === 0) options.legislature = 57
if (options.legislature !== null && !LEGISLATURE_RANGES[options.legislature]) {
die('informe --legislature <54|55|56|57> ou --date <YYYY-MM-DD> (veja --help).')
}
}
if (options.date !== null && !/^\d{4}-\d{2}-\d{2}$/.test(options.date)) {
die('--date deve ser YYYY-MM-DD.')
}
if (options.limit !== null && (!Number.isInteger(options.limit) || options.limit < 1)) {
die('--limit deve ser >= 1.')
}
if (options.out.includes('..')) {
die('--out não pode escapar do diretório do repo (sem "..").')
}
return options
}
const HELP = `
Uso: pnpm camara:import [--all | --legislature 54|55|56|57 | --date YYYY-MM-DD | --coverage | --verify-links <n> | --repair-links [--dry-run]] [opções]
Modos de import (escrevem no acervo):
--all processa as 54ª–57ª em sequência (relatório por legislatura)
--legislature <n> 54|55|56|57 (default 57 quando nenhum modo é dado; ignorado se --date)
--date <YYYY-MM-DD> discursos de um único dia
Modos read-only:
--coverage imprime a cobertura do banco por legislatura (sem rede/escrita)
--verify-links <n> amostra n discursos por legislatura e testa os links do VOD
--repair-links --dry-run
planeja o reparo do link oficial (PDF direto do Diário) sem escrever
Reparo do link oficial (C160):
--repair-links troca officialTextUrl legado (dc_20b/dc_20.asp/montaPdf) pelo PDF
direto do Diário; resolve uma vez por publicação (cache em <out>/diario)
e exige CAMARA_IMPORT_CONFIRM=1 fora de alvo local (runbook §C160)
--dry-run só com --repair-links: relata o plano sem escrever
Opções de import:
--limit <n> quantos discursos processar por legislatura (default: todos)
--speaker <nome> nome do orador a casar no evento (default "Jorge Solla")
--out <dir> diretório de cache/artefatos (default data/camara)
--skip-transcribe não chama a Deep Infra (preserva os segmentos existentes)
--reclassify refaz a classificação mesmo já tendo facetas da LLM
--help esta ajuda
Escrita em alvo não-local ou com NODE_ENV=production exige CAMARA_IMPORT_CONFIRM=1
(runbook: docs/ops/teqo-1313-deploy.md §C155/§C160).
A página do evento é cacheada em <out>/events; apague-a para reler a Câmara.
`
// ---------------------------------------------------------------------------
// Caches
// ---------------------------------------------------------------------------
const eventsByDate = new Map()
const eventPages = new Map()
async function getEventsForDate(date) {
const cached = eventsByDate.get(date)
if (cached) return cached
const events = (await getJson(eventsUrl(date))).dados ?? []
eventsByDate.set(date, events)
return events
}
async function loadEventPage(eventId, options) {
const cached = eventPages.get(eventId)
if (cached) return cached
const path = join(options.out, 'events', `${eventId}.html`)
let html
try {
html = await readFile(path, 'utf8')
} catch (error) {
if (error?.code !== 'ENOENT') throw error
html = await getText(eventPageUrl(eventId), { timeoutMs: 60_000 })
await mkdir(dirname(path), { recursive: true })
await writeFile(path, html)
}
eventPages.set(eventId, html)
return html
}
async function fetchSpeechesRange(deputyId, from, to) {
const speeches = []
for (let page = 1; ; page += 1) {
const body = await getJsonWithBackoff(`${speechesUrl(deputyId, from, to)}&pagina=${page}`, {
label: 'camara:import',
})
const batch = body.dados ?? []
speeches.push(...batch)
const hasNext = (body.links ?? []).some((link) => link?.rel === 'next')
if (!hasNext || batch.length === 0) break
await sleep(500)
}
return speeches
}
/**
* Lista a faixa inteira; se a API recusar uma página funda (500/hang
* persistente, visto na 55ª em 2026-09-13), cai para a listagem por ano e,
* se um ano inteiro falhar, desce ao mês — cada chunk é raso e um mês
* quebrado não derruba os demais. Os chunks que falharem são reportados via
* `onWarning` (o run os registra como falha de `list`).
*/
async function fetchSpeeches(deputyId, from, to, onWarning = () => {}) {
try {
return await fetchSpeechesRange(deputyId, from, to)
} catch (error) {
onWarning(`listagem ${from}..${to} falhou (${error?.message}); tentando por ano`)
const speeches = []
let listedChunks = 0
for (const [yearFrom, yearTo] of dateRangeChunks(from, to)) {
try {
speeches.push(...(await fetchSpeechesRange(deputyId, yearFrom, yearTo)))
listedChunks += 1
continue
} catch (yearError) {
onWarning(`listagem ${yearFrom}..${yearTo} falhou: ${yearError?.message}; tentando por mês`)
}
for (const [monthFrom, monthTo] of dateRangeChunks(yearFrom, yearTo, 'month')) {
try {
speeches.push(...(await fetchSpeechesRange(deputyId, monthFrom, monthTo)))
listedChunks += 1
} catch (monthError) {
onWarning(`listagem ${monthFrom}..${monthTo} falhou: ${monthError?.message}`)
}
}
}
if (listedChunks === 0) throw error
return speeches
}
}
// ---------------------------------------------------------------------------
// Pipeline
// ---------------------------------------------------------------------------
const eventPriority = (event) => {
const index = EVENT_TYPE_PRIORITY.indexOf(event?.descricaoTipo)
return index === -1 ? EVENT_TYPE_PRIORITY.length : index
}
/** Nearest event of the speech date, preferred session types first. */
async function resolveSpeechContext(speech, options) {
const candidates = selectSpeechEvents(
await getEventsForDate(speechDate(speech.dataHoraInicio)),
speech.dataHoraInicio,
)
const ordered = [...candidates].sort((left, right) => eventPriority(left) - eventPriority(right))
for (const event of ordered.slice(0, MAX_EVENT_CANDIDATES)) {
const html = await loadEventPage(event.id, options)
const excerpt = matchExcerpt(
parseEventExcerpts(html, event.id),
speech.dataHoraInicio,
options.speaker,
)
if (excerpt) return { event, html, excerpt }
}
return { event: ordered[0] ?? null, html: null, excerpt: null }
}
async function transcribeExcerpt(eventId, excerpt, options) {
const vod = await resolveVod(eventId, excerpt)
if (vod.status.state !== 'PRONTO' || !vod.status.video?.downloadUrl) {
return {
vodState: vod.status.state,
failure: `VOD não ficou PRONTO (estado=${vod.status.state})`,
}
}
const cached = await ensureCachedDownload({
label: 'camara:import',
key: `${eventId}-${excerpt.audioId}-${excerpt.tMs}`,
url: vod.status.video.downloadUrl,
ext: 'mp4',
cacheDir: options.out,
download: downloadToBuffer,
})
const { transcription, elapsedMs } = await transcribeSpeechAudio(cached.buffer)
return {
vodState: vod.status.state,
segments: transcription.segments.map((segment) => ({
startSeconds: segment.start,
endSeconds: segment.end,
text: segment.text,
})),
durationSeconds: transcription.duration,
playbackUrl: vod.status.video.playbackUrl,
downloadUrl: vod.status.video.downloadUrl,
audioSeconds: transcription.duration ?? 0,
elapsedMs,
}
}
// ---------------------------------------------------------------------------
// Official Diário link (C160)
// ---------------------------------------------------------------------------
const DIARIO_CACHE_DIR = 'diario'
const OFFICIAL_LINK_FAILURE_STAGE = 'official-link'
/**
* One publication, one network resolution, ever: the cached PDF archive file
* name of a `(date, collection, supplement)` in `<out>/diario/`. The injected
* download follows the legacy redirect and probes the direct PDF with a Range
* GET, so a failure throws before anything is cached.
*
* @param {{ date: string, collection: string | null, supplement: string | null }} publication
* @param {string} lookupUrl
* @param {{ out: string }} options
* @returns {Promise<string>}
*/
async function resolvePublicationPdf(publication, lookupUrl, options) {
const key = publicationCacheKey(publication)
if (key === null) throw new Error('publicação sem data — link oficial não resolvível')
const cached = await ensureCachedDownload({
label: 'camara:import',
key,
url: lookupUrl,
ext: 'json',
cacheDir: join(options.out, DIARIO_CACHE_DIR),
download: async () => {
const resolved = await resolveOfficialPdfUrl(lookupUrl)
const probe = await probeLink(resolved.pdfUrl, { timeoutMs: 60_000 })
if (!probe.ok || !String(probe.contentType ?? '').includes('application/pdf')) {
throw new Error(`PDF oficial inacessível (${probe.note}) em ${resolved.pdfUrl}`)
}
return Buffer.from(
JSON.stringify({
archiveFileName: resolved.archiveFileName,
page: resolved.page,
finalUrl: resolved.finalUrl,
checkedAt: new Date().toISOString(),
}),
)
},
})
const archiveFileName = String(cached.json?.archiveFileName ?? '').trim()
if (archiveFileName === '') throw new Error(`cache sem archiveFileName para ${key}`)
return archiveFileName
}
/**
* Resolves one raw `urlTexto` to the direct Diário PDF. Never returns the
* legacy visualizer or the `montaPdf` hop: on failure it preserves a previously
* stored direct URL (null otherwise, so the UI falls back to YouTube/the hidden
* button) and reports.
*
* @param {unknown} rawUrl
* @param {unknown} existingUrl
* @param {{ out: string }} options
* @returns {Promise<{ url: string | null, state: 'none' | 'direct' | 'resolved' | 'failed' | 'unknown', error?: string }>}
*/
async function resolveOfficialTextUrl(rawUrl, existingUrl, options) {
const kind = classifyOfficialTextUrl(rawUrl)
if (kind === 'none') return { url: null, state: 'none' }
if (kind === 'direct') return { url: String(rawUrl).trim(), state: 'direct' }
if (kind === 'montaPdf') {
const parsed = parseMontaPdfUrl(rawUrl)
return { url: buildOfficialPdfUrl(parsed.archiveFileName, parsed.page), state: 'resolved' }
}
if (kind === 'unknown') {
if (!isLegacyDiarioUrl(rawUrl)) return { url: String(rawUrl).trim(), state: 'unknown' }
return {
url: officialTextUrlFallback(existingUrl),
state: 'failed',
error: `link legado não resolvível: ${String(rawUrl).slice(0, 160)}`,
}
}
const parsed = parseLegacyOfficialUrl(rawUrl)
try {
const archiveFileName = await resolvePublicationPdf(
parsed.publication,
parsed.lookupUrl,
options,
)
return { url: buildOfficialPdfUrl(archiveFileName, parsed.page), state: 'resolved' }
} catch (error) {
return {
url: officialTextUrlFallback(existingUrl),
state: 'failed',
error: error?.message ?? String(error),
}
}
}
async function processSpeech(payload, speech, options, run) {
// Identity: the API triple is the key; a genuinely distinct speech sharing
// the triple (seen 2026-06-17T17:16) gets a content-hash suffix instead of
// overwriting the first one.
const baseKey = speechSourceKey(speech)
const contentHash = speechContentHash(speech.sumario, speech.transcricao)
const baseState = await findSpeechImportState(payload, baseKey)
const suffixedKey =
Boolean(baseState) &&
contentHash !== speechContentHash(baseState.summary, baseState.officialTranscript)
const sourceKey = suffixedKey ? `${baseKey}#${contentHash}` : baseKey
const existing = suffixedKey ? await findSpeechImportState(payload, sourceKey) : baseState
const report = {
sourceKey,
suffixedKey,
speechAt: speech.dataHoraInicio,
eventId: null,
excerpt: null,
vodState: null,
presidingOfficer: null,
classifiedBy: null,
segmentCount: 0,
created: false,
manualFacetsPreserved: false,
officialLink: null,
failures: [],
}
const keywords = parseOfficialKeywords(speech.keywords)
const date = speechDate(speech.dataHoraInicio)
const year = /^\d{4}-\d{2}-\d{2}$/.test(date) ? Number(date.slice(0, 4)) : null
let context = { event: null, html: null, excerpt: null }
try {
context = await resolveSpeechContext(speech, options)
} catch (error) {
report.failures.push({ stage: 'event', message: error?.message ?? String(error) })
}
const { event, excerpt } = context
if (event) report.eventId = event.id
if (excerpt) {
report.excerpt = { audioId: excerpt.audioId, tMs: excerpt.tMs, startTime: excerpt.startTime }
}
const transitions = context.html ? parsePresidingOfficerTransitions(context.html) : []
report.presidingOfficer = excerpt ? resolvePresidingOfficer(transitions, excerpt.tMs) : null
// Identity/media carry-over: a run that failed to resolve the event page must
// not wipe the stored excerpt or force a re-transcription next time.
const excerptChanged =
Boolean(excerpt) &&
Boolean(existing) &&
(existing.audioId !== excerpt.audioId || existing.excerptTMs !== excerpt.tMs)
const preserveStoredMedia = !excerpt || !excerptChanged
const needsTranscription =
Boolean(excerpt) &&
!options.skipTranscribe &&
(!existing || excerptChanged || existing.segmentCount === 0)
let media = {}
if (needsTranscription) {
try {
media = await transcribeExcerpt(event.id, excerpt, options)
if (media.failure) report.failures.push({ stage: 'vod', message: media.failure })
if (media.segments) {
run.asr.calls += 1
run.asr.audioSeconds += media.audioSeconds
run.asr.elapsedMs += media.elapsedMs ?? 0
}
} catch (error) {
run.asr.failures += 1
report.failures.push({ stage: 'asr', message: error?.message ?? String(error) })
}
}
const skipClassification =
existing?.classifiedBy === 'manual' || (existing?.classifiedBy === 'llm' && !options.reclassify)
let facets
if (skipClassification) {
report.classifiedBy = existing.classifiedBy
} else {
const classification = await classifySpeech({
transcript: speech.transcricao ?? '',
summary: speech.sumario ?? null,
keywords,
})
run.llm.calls += 1
if (classification.llm.used) {
facets = classification.facets
report.classifiedBy = classification.facets.classifiedBy
run.llm.used += 1
run.llm.totalTokens += classification.llm.totalTokens ?? 0
run.llm.estimatedCostUsd += classification.llm.estimatedCostUsd ?? 0
} else if (existing?.classifiedBy === 'llm') {
// Never downgrade a stored LLM classification to the gazetteer fallback
// (that would drop the extracted people/programs/projects).
report.classifiedBy = existing.classifiedBy
report.failures.push({
stage: 'classify',
message: `LLM indisponível (${classification.llm.error ?? 'erro'}); facetas anteriores preservadas`,
})
run.llm.failed += 1
} else {
facets = classification.facets
report.classifiedBy = classification.facets.classifiedBy
run.llm.failed += 1
if (classification.llm.error && run.llm.sampleError === null) {
run.llm.sampleError = classification.llm.error
}
}
}
const durationSeconds =
media.durationSeconds ??
(preserveStoredMedia ? (existing?.durationSeconds ?? null) : null) ??
parseDurationToSeconds(excerpt?.duration)
const official = await resolveOfficialTextUrl(
speech.urlTexto ?? null,
existing?.officialTextUrl ?? null,
options,
)
report.officialLink = official.state
if (official.error) {
report.failures.push({ stage: OFFICIAL_LINK_FAILURE_STAGE, message: official.error })
}
const bundle = {
sourceKey,
speechAt: String(speech.dataHoraInicio ?? '').trim(),
year,
legislature: legislatureForDate(speech.dataHoraInicio),
type: speech.tipoDiscurso ?? null,
phase: speech.faseEvento?.titulo ?? null,
durationSeconds,
summary: speech.sumario ?? null,
officialTranscript: speech.transcricao ?? null,
officialTextUrl: official.url,
keywords,
eventId: event?.id ?? null,
eventType: event?.descricaoTipo ?? null,
eventStartAt: event?.dataHoraInicio ?? null,
eventEndAt: event?.dataHoraFim ?? null,
youtubeUrl: event?.urlRegistro ?? null,
presidingOfficer: report.presidingOfficer,
audioId: excerpt?.audioId ?? (preserveStoredMedia ? (existing?.audioId ?? null) : null),
excerptTMs: excerpt?.tMs ?? (preserveStoredMedia ? (existing?.excerptTMs ?? null) : null),
vodPlaybackUrl:
media.playbackUrl ?? (preserveStoredMedia ? (existing?.vodPlaybackUrl ?? null) : null),
vodDownloadUrl:
media.downloadUrl ?? (preserveStoredMedia ? (existing?.vodDownloadUrl ?? null) : null),
facets,
segments: media.segments,
}
try {
const counts = await upsertSpeechBundle(payload, bundle)
report.created = counts.created
report.manualFacetsPreserved = counts.manualFacetsPreserved
report.segmentCount = media.segments ? media.segments.length : (existing?.segmentCount ?? 0)
run.totals.created += counts.created ? 1 : 0
run.totals.updated += counts.created ? 0 : 1
} catch (error) {
report.failures.push({ stage: 'upsert', message: error?.message ?? String(error) })
}
run.totals.withExcerpt += excerpt ? 1 : 0
run.totals.withoutExcerpt += excerpt ? 0 : 1
run.totals.withSegments += report.segmentCount > 0 ? 1 : 0
run.totals.failed += report.failures.length > 0 ? 1 : 0
return report
}
// ---------------------------------------------------------------------------
// Report
// ---------------------------------------------------------------------------
const printReport = (run) => {
const { totals, asr, llm, failures } = run
console.log('\n=== Relatório do import (camara:import) ===')
console.log(
`discursos: ${totals.listed} listados, ${totals.processed} processados ` +
`(${totals.created} criados, ${totals.updated} atualizados)`,
)
console.log(
`vídeo: ${totals.withExcerpt} com trecho, ${totals.withoutExcerpt} sem trecho; ` +
`${totals.withSegments} com segmentos`,
)
console.log(
`ASR: ${asr.calls} chamadas, ${(asr.audioSeconds / 60).toFixed(1)} min de áudio, ` +
`~US$ ${((asr.audioSeconds * DEEPINFRA_COST_PER_MINUTE_USD) / 60).toFixed(4)}, ` +
`${(asr.elapsedMs / 1000).toFixed(0)}s de processamento, ${asr.failures} falhas`,
)
console.log(
`LLM: ${llm.used}/${llm.calls} usadas, ${llm.totalTokens} tokens, ` +
`~US$ ${llm.estimatedCostUsd.toFixed(4)}${llm.failed > 0 ? `, ${llm.failed} falhas` : ''}`,
)
console.log(`tempo total: ${(run.elapsedMs / 1000).toFixed(1)}s; falhas: ${failures.length}`)
for (const failure of failures) {
console.log(` ✗ ${failure.speechAt} [${failure.stage}] ${failure.message}`)
}
}
const pad = (value, width) => String(value).padStart(width)
const padEnd = (value, width) => String(value).padEnd(width)
const printCoverage = (coverage) => {
console.log('\n=== Cobertura do acervo (por legislatura) ===')
console.log(
`${padEnd('leg', 5)}${pad('total', 7)}${pad('trecho', 8)}${pad('vídeo', 7)}` +
`${pad('segm.', 7)}${pad('sem trecho', 11)}${pad('sem segm.', 10)}${pad('fallback YT', 12)}`,
)
for (const row of coverage.byLegislature) {
console.log(
`${padEnd(row.legislature ?? '—', 5)}${pad(row.total, 7)}${pad(row.withExcerpt, 8)}` +
`${pad(row.withVideo, 7)}${pad(row.withSegments, 7)}${pad(row.withoutExcerpt, 11)}` +
`${pad(row.withoutSegments, 10)}${pad(row.fallbackYoutube, 12)}`,
)
}
const { totals } = coverage
console.log(
`${padEnd('total', 5)}${pad(totals.total, 7)}${pad(totals.withExcerpt, 8)}` +
`${pad(totals.withVideo, 7)}${pad(totals.withSegments, 7)}${pad(totals.withoutExcerpt, 11)}` +
`${pad(totals.withoutSegments, 10)}${pad(totals.fallbackYoutube, 12)}`,
)
}
const printBackfillReport = (combined) => {
console.log('\n=== Relatório do backfill (54ª–57ª) ===')
for (const run of combined.legislatures) {
const { totals, asr } = run
console.log(
`${run.legislature}ª: ${totals.listed} listados, ${totals.processed} processados ` +
`(${totals.created} criados, ${totals.updated} atualizados) · ` +
`${totals.withExcerpt} com trecho / ${totals.withoutExcerpt} sem · ` +
`${totals.withSegments} com segmentos · ` +
`ASR ${(asr.audioSeconds / 60).toFixed(1)} min ` +
`~US$ ${((asr.audioSeconds * DEEPINFRA_COST_PER_MINUTE_USD) / 60).toFixed(4)} · ` +
`${(run.elapsedMs / 1000).toFixed(0)}s` +
`${run.failures.length > 0 ? ` · ${run.failures.length} falha(s)` : ''}` +
`${run.aborted ? ' · ABORTADA' : ''}`,
)
}
const { totals, asr, llm } = combined
console.log(
`combinado: ${totals.processed} processados (${totals.created} criados, ` +
`${totals.updated} atualizados) · ${totals.withSegments} com segmentos · ` +
`ASR ${(asr.audioSeconds / 60).toFixed(1)} min ~US$ ` +
`${((asr.audioSeconds * DEEPINFRA_COST_PER_MINUTE_USD) / 60).toFixed(4)} · ` +
`LLM ${llm.used}/${llm.calls} ~US$ ${llm.estimatedCostUsd.toFixed(4)}`,
)
console.log(
`tempo total: ${(combined.elapsedMs / 1000).toFixed(1)}s; falhas: ${combined.failures.length}`,
)
for (const failure of combined.failures) {
console.log(
` ✗ [${failure.legislature ?? '-'}] ${failure.speechAt} [${failure.stage}] ${failure.message}`,
)
}
}
const speechLine = (report) => {
const parts = [
report.created ? 'criado' : 'atualizado',
report.excerpt ? `trecho a=${report.excerpt.audioId} t=${report.excerpt.tMs}` : 'sem trecho',
]
if (report.suffixedKey) parts.push('chave sufixada por colisão')
if (report.vodState) parts.push(`VOD ${report.vodState}`)
if (report.segmentCount > 0) parts.push(`${report.segmentCount} segmentos`)
if (report.presidingOfficer) parts.push(`presidiu ${report.presidingOfficer}`)
if (report.classifiedBy) parts.push(`facetas ${report.classifiedBy}`)
if (report.officialLink === 'resolved') parts.push('link oficial resolvido')
if (report.officialLink === 'failed') parts.push('link oficial falhou')
if (report.failures.length > 0) parts.push(`${report.failures.length} falha(s)`)
return parts.join(' · ')
}
// ---------------------------------------------------------------------------
// Runs
// ---------------------------------------------------------------------------
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const stamp = (runAt) => runAt.replace(/[:.]/g, '-')
const writeReport = async (options, filename, report) => {
const reportPath = join(options.out, 'reports', filename)
await mkdir(dirname(reportPath), { recursive: true })
await writeFile(reportPath, JSON.stringify(report, null, 2))
return reportPath
}
const runOptions = (options) => ({
// A `--date` run is not a legislature run — C153 shape kept.
legislature: options.all || options.date ? null : options.legislature,
all: options.all,
date: options.date,
limit: options.limit,
speaker: options.speaker,
skipTranscribe: options.skipTranscribe,
reclassify: options.reclassify,
})
const createRun = (options, { legislature, from, to }) => ({
runAt: new Date().toISOString(),
mode: options.date ? 'date' : 'legislature',
legislature,
range: { from, to },
options: runOptions(options),
totals: {
listed: 0,
processed: 0,
created: 0,
updated: 0,
withExcerpt: 0,
withoutExcerpt: 0,
withSegments: 0,
failed: 0,
},
asr: { calls: 0, audioSeconds: 0, elapsedMs: 0, failures: 0 },
llm: { calls: 0, used: 0, failed: 0, totalTokens: 0, estimatedCostUsd: 0, sampleError: null },
elapsedMs: 0,
speeches: [],
failures: [],
})
const processTargets = async (payload, options, targets, run) => {
for (const [index, speech] of targets.entries()) {
const report = await processSpeech(payload, speech, options, run)
run.totals.processed += 1
run.speeches.push(report)
for (const failure of report.failures) {
run.failures.push({ sourceKey: report.sourceKey, speechAt: report.speechAt, ...failure })
}
console.log(`[${index + 1}/${targets.length}] ${report.speechAt} — ${speechLine(report)}`)
}
}
/**
* One legislature end-to-end: list from the API, slice `--limit`, process,
* close the run. Throws on a list failure or an empty range — the caller owns
* the policy (single range dies; `--all` marks the legislature aborted and
* continues with the next).
*/
const runLegislature = async (payload, options, { legislature, from, to, label }) => {
const run = createRun(options, { legislature, from, to })
const startedAt = Date.now()
console.log(`\n[camara:import] === ${label} (${from}..${to}) ===`)
const speeches = await fetchSpeeches(SOLLA_DEPUTY_ID, from, to, (message) => {
run.failures.push({ sourceKey: null, speechAt: null, stage: 'list', message })
})
const targets = options.limit === null ? speeches : speeches.slice(0, options.limit)
console.log(`[camara:import] ${speeches.length} discursos na API; processando ${targets.length}`)
if (targets.length === 0) throw new Error(`nenhum discurso em ${from}..${to}.`)
run.totals.listed = speeches.length
await processTargets(payload, options, targets, run)
run.elapsedMs = Date.now() - startedAt
return run
}
const runSingleRange = async (payload, options) => {
const from = options.date ?? LEGISLATURE_RANGES[options.legislature][0]
const to = options.date ?? LEGISLATURE_RANGES[options.legislature][1]
console.log(
`[camara:import] deputado ${SOLLA_DEPUTY_ID} | ${from}..${to} | speaker "${options.speaker}"`,
)
const run = await runLegislature(payload, options, {
legislature: options.date ? null : options.legislature,
from,
to,
label: options.date ? `date ${options.date}` : `legislatura ${options.legislature}ª`,
})
run.coverage = await getSpeechCoverage(payload)
printReport(run)
printCoverage(run.coverage)
const reportPath = await writeReport(options, `import-${stamp(run.runAt)}.json`, run)
console.log(`\n[camara:import] relatório JSON: ${reportPath}`)
}
const runBackfill = async (payload, options) => {
const startedAt = Date.now()
const combined = {
runAt: new Date().toISOString(),
mode: 'all',
options: runOptions(options),
legislatures: [],
...aggregateBackfillRuns([]),
failures: [],
coverage: null,
}
const legislatures = Object.keys(LEGISLATURE_RANGES)
.map(Number)
.sort((left, right) => left - right)
for (const legislature of legislatures) {
const [from, to] = LEGISLATURE_RANGES[legislature]
const rangeStartedAt = Date.now()
let run
try {
run = await runLegislature(payload, options, {
legislature,
from,
to,
label: `legislatura ${legislature}ª`,
})
} catch (error) {
run = createRun(options, { legislature, from, to })
run.aborted = true
run.elapsedMs = Date.now() - rangeStartedAt
run.failures.push({
sourceKey: null,
speechAt: null,
stage: 'legislature',
message: error?.message ?? String(error),
})
console.error(
`[camara:import] legislatura ${legislature}ª abortada: ${error?.message ?? error}`,
)
}
combined.legislatures.push(run)
combined.failures.push(...run.failures.map((failure) => ({ legislature, ...failure })))
const partial = aggregateBackfillRuns(combined.legislatures)
combined.totals = partial.totals
combined.asr = partial.asr
combined.llm = partial.llm
combined.elapsedMs = Date.now() - startedAt
// Checkpoint after each legislature: a network drop loses at most one.
await writeReport(options, `backfill-${stamp(combined.runAt)}.json`, combined)
}
combined.coverage = await getSpeechCoverage(payload)
printBackfillReport(combined)
printCoverage(combined.coverage)
const reportPath = await writeReport(options, `backfill-${stamp(combined.runAt)}.json`, combined)
console.log(`\n[camara:import] relatório JSON: ${reportPath}`)
if (combined.legislatures.some((run) => run.aborted)) {
die('legislatura(s) abortada(s) — reexecute --all ou --legislature <n> para completar.')
}
}
const runCoverage = async (payload, options) => {
const startedAt = Date.now()
const coverage = await getSpeechCoverage(payload)
printCoverage(coverage)
const report = {
runAt: new Date().toISOString(),
mode: 'coverage',
options: runOptions(options),
coverage,
elapsedMs: Date.now() - startedAt,
}
const reportPath = await writeReport(options, `coverage-${stamp(report.runAt)}.json`, report)
console.log(`\n[camara:import] relatório JSON: ${reportPath}`)
}
const runVerifyLinks = async (payload, options) => {
const startedAt = Date.now()
const found = await payload.find({
collection: 'speech',
where: { or: [{ vodPlaybackUrl: { exists: true } }, { vodDownloadUrl: { exists: true } }] },
pagination: false,
depth: 0,
select: { legislature: true, speechAt: true, vodPlaybackUrl: true, vodDownloadUrl: true },
// Intentional bypass: the import CLI is a trusted actor with no session.
overrideAccess: true,
})
const sample = selectLinkSample(found.docs, options.verifyLinks)
console.log(
`[camara:import] amostra: ${sample.length} discursos (${options.verifyLinks} por legislatura)`,
)
const checked = []
for (const row of sample) {
const links = [
['playback', row.vodPlaybackUrl],
['download', row.vodDownloadUrl],
]
for (const [kind, url] of links) {
if (!url) continue
const probe = await probeLink(url)
checked.push({ legislature: row.legislature, speechId: row.id, kind, ...probe })
console.log(
`[camara:import] ${row.legislature ?? '—'} #${row.id} ${kind}: ` +
`${probe.ok ? 'ok' : 'WARN'} ${probe.note}`,
)
await sleep(250)
}
}
const warnings = checked.filter((entry) => !entry.ok).length
const report = {
runAt: new Date().toISOString(),
mode: 'verify-links',
options: runOptions(options),
sample: { perLegislature: options.verifyLinks, total: sample.length },
checked,
ok: checked.length - warnings,
warnings,
elapsedMs: Date.now() - startedAt,
}
if (checked.length === 0) {
die('nenhum link de VOD verificado — amostra vazia (banco sem links?).')
}
console.log(
`[camara:import] links: ${report.ok}/${checked.length} ok` +
`${warnings > 0 ? `, ${warnings} warning(s)` : ''}`,
)
const reportPath = await writeReport(options, `verify-links-${stamp(report.runAt)}.json`, report)
console.log(`\n[camara:import] relatório JSON: ${reportPath}`)
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
/** Resolves every publication of the repair plan once (cache + probe). */
async function resolveRepairPublications(plan, options) {
const publications = []
const archiveNames = new Map()
let failedRows = 0
for (const group of plan.groups) {
try {
const archiveFileName = await resolvePublicationPdf(
group.publication,
group.lookupUrl,
options,
)
archiveNames.set(group.key, archiveFileName)
publications.push({
key: group.key,
lookupUrl: group.lookupUrl,
archiveFileName,
state: 'resolved',
rows: group.rows.length,
pages: group.rows.map((row) => row.page),
})
} catch (error) {
failedRows += group.rows.length
publications.push({
key: group.key,
lookupUrl: group.lookupUrl,
archiveFileName: null,
state: 'failed',
rows: group.rows.length,
error: error?.message ?? String(error),
})
console.log(`[camara:import] ✗ ${group.key}: ${error?.message ?? error}`)
}
}
return { publications, archiveNames, failedRows }
}
/** Offline `montaPdf` conversions + the rows of every resolved publication. */
const buildRepairUpdates = (plan, archiveNames) => [
...plan.directUpdates,
...plan.groups.flatMap((group) => {
const archiveFileName = archiveNames.get(group.key)
if (!archiveFileName) return []
return group.rows.map((row) => ({
id: row.id,
previousUrl: row.previousUrl,
nextUrl: buildOfficialPdfUrl(archiveFileName, row.page),
speechAt: row.speechAt,
legislature: row.legislature,
}))
}),
]
async function applyRepairUpdates(payload, updates) {
let updated = 0
const failures = []
for (const update of updates) {
try {
await payload.update({
collection: 'speech',
id: update.id,
data: { officialTextUrl: update.nextUrl },
depth: 0,
// Intentional bypass: the import CLI is a trusted actor with no session.
overrideAccess: true,
})
updated += 1
} catch (error) {
failures.push({ id: update.id, message: error?.message ?? String(error) })
}
}
return { updated, failures }
}
const runRepairLinks = async (payload, options) => {
const startedAt = Date.now()
const found = await payload.find({
collection: 'speech',
where: { officialTextUrl: { exists: true } },
pagination: false,
depth: 0,
select: { legislature: true, speechAt: true, officialTextUrl: true },
// Intentional bypass: the import CLI is a trusted actor with no session.
overrideAccess: true,
})
const total = await payload.count({
collection: 'speech',
// Intentional bypass: the import CLI is a trusted actor with no session.
overrideAccess: true,
})
const plan = planOfficialLinkRepairs(found.docs)
const legacyRows = plan.groups.reduce((sum, group) => sum + group.rows.length, 0)
const totalLegacy = legacyRows + plan.directUpdates.length
console.log(
`[camara:import] falas: ${found.docs.length} com link ` +
`(${plan.alreadyDirect} diretas, ${legacyRows} legadas em ${plan.groups.length} publicações, ` +
`${plan.directUpdates.length} montaPdf, ${plan.unresolvable.length} malformadas, ` +
`${plan.unknown.length} desconhecidas); ` +
`${total.totalDocs - found.docs.length} sem link`,
)
const { publications, archiveNames, failedRows } = await resolveRepairPublications(plan, options)
const updates = buildRepairUpdates(plan, archiveNames)
const { updated, failures } = options.dryRun
? { updated: 0, failures: [] }
: await applyRepairUpdates(payload, updates)
const failedPublications = publications.filter((entry) => entry.state === 'failed').length
const unresolved = plan.unresolvable.length
const report = {
runAt: new Date().toISOString(),
mode: 'repair-links',
dryRun: options.dryRun,
options: runOptions(options),
scanned: found.docs.length,
withoutLink: total.totalDocs - found.docs.length,
alreadyDirect: plan.alreadyDirect,
unknown: plan.unknown,
unresolvable: plan.unresolvable,
publications,
updates,
failures,
totals: {
legacy: totalLegacy,
unresolved,
updated,
failedPublications,
failedRows,
failedUpdates: failures.length,
remainingLegacy: Math.max(0, totalLegacy + unresolved - updated),
},
elapsedMs: Date.now() - startedAt,
}
console.log(
options.dryRun
? `[camara:import] dry-run: ${updates.length} falas seriam atualizadas, ` +
`${failedRows + unresolved} pendente(s) em ${failedPublications} publicação(ões)`
: `[camara:import] atualizadas: ${updated}; legadas restantes: ${report.totals.remainingLegacy}`,
)
const reportPath = await writeReport(options, `repair-links-${stamp(report.runAt)}.json`, report)
console.log(`\n[camara:import] relatório JSON: ${reportPath}`)
if (!options.dryRun && (failedPublications > 0 || failures.length > 0 || unresolved > 0)) {
die('reparo incompleto — publicações/falas pendentes listadas no relatório.')
}
}
const modeLabel = (options) => {
if (options.coverage) return 'coverage (read-only)'
if (options.verifyLinks !== null) return `verify-links n=${options.verifyLinks} (read-only)`
if (options.repairLinks) {
return options.dryRun ? 'repair-links dry-run (read-only)' : 'repair-links'
}
if (options.all) return 'backfill 54ª–57ª'
if (options.date) return `date ${options.date}`
return `legislature ${options.legislature}`
}
async function main() {
const options = parseArgs(process.argv.slice(2))
if (options.help) {
console.log(HELP)
process.exit(0)
}
const readOnly =
options.coverage || options.verifyLinks !== null || (options.repairLinks && options.dryRun)
if (readOnly) {
if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.')
} else {
// Confirm first: a non-local host is exactly what the flag is for; the
// local-DB guard below still blocks a remote host without ALLOW_REMOTE_DB.
assertWriteConfirm({
label: 'camara:import',
flag: WRITE_CONFIRM_FLAG,
command: 'pnpm camara:import',
hint: 'Runbook: docs/ops/teqo-1313-deploy.md §C155/§C160.',
})
assertLocalDatabase(
'camara:import',
'O import escreve no acervo; produção exige CAMARA_IMPORT_CONFIRM=1 (runbook §C155/§C160).',
)
}
console.log(`[camara:import] alvo: ${databaseTarget()} | modo: ${modeLabel(options)}`)
const payload = await getPayload({ config })
if (options.coverage) await runCoverage(payload, options)
else if (options.verifyLinks !== null) await runVerifyLinks(payload, options)
else if (options.repairLinks) await runRepairLinks(payload, options)
else if (options.all) await runBackfill(payload, options)
else await runSingleRange(payload, options)
process.exit(0)
}
main().catch((error) => {
console.error(error)
die(error?.message || String(error))
})