teqo-backup/scripts/pilot-camara-speeches.mjs
Francisco Solla aca3ea6188 feat(C153): catálogo de falas do Solla — schema, access, import idempotente e busca
Collections speech/speechSegment no grupo Comunicação, papel communicator
(leitura restrita) e índice trigram normalizado para a busca por segmento.
Import idempotente da Câmara (discurso→evento→trecho→VOD→ASR) com facetas
gazetteer+LLM, proveniência e preservação de curadoria manual; parser de
quem presidia; reusa a lógica pura do piloto C152 via scripts/lib.

Related #955
2026-09-13 02:08:55 -03:00

333 lines
12 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.

/**
* C152 pilot: processes 2–3 Câmara speeches end-to-end to de-risk the "acervo
* de falas" source before C153 models it. No DB, no Payload, no collection —
* this is a disposable-data spike whose durable outputs are the pure module
* (`scripts/lib/camaraSpeeches.mjs`), its unit tests and the viability report
* (`docs/research/piloto-fonte-videos-camara.md`).
*
* Flow per speech: open-data discursos → event on that date → speaker excerpt
* on the event page (server-rendered `a`/`t` anchors) → `video-sob-demanda`
* poll (async VOD transcoding) → MP4 download → Deep Infra Whisper large-v3
* (OpenAI-compat `verbose_json`, segments with start/end) → report.
*
* Media/JSON artifacts land under `data/camara/` (gitignored), never in git.
*
* Usage:
* pnpm camara:pilot --legislature 57 --limit 1
* pnpm camara:pilot --date 2016-03-10
* pnpm camara:pilot --legislature 56 --skip-transcribe
*/
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
DEEPINFRA_COST_PER_MINUTE_USD,
downloadToBuffer,
eventPageUrl,
eventsUrl,
getJson,
getText,
resolveVod,
speechesUrl,
transcribeSpeechAudio,
} from './lib/camaraFetch.mjs'
import {
LEGISLATURE_RANGES,
SOLLA_DEPUTY_ID,
matchExcerpt,
parseEventExcerpts,
selectSpeechEvents,
} from './lib/camaraSpeeches.mjs'
import { dieWithLabel, ensureCachedDownload, loadCliEnv } from './lib/cli.mjs'
loadCliEnv()
const die = dieWithLabel('camara:pilot')
const DEFAULT_OUT_DIR = 'data/camara'
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const parseArgs = (argv) => {
const options = {
legislature: null,
date: null,
limit: 1,
speaker: 'Jorge Solla',
out: DEFAULT_OUT_DIR,
skipTranscribe: false,
skipDownload: false,
help: false,
}
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]
const value = () => {
const next = argv[++i]
if (next === undefined || next.startsWith('--')) die(`faltou valor para ${arg}.`)
return next
}
if (arg === '--help' || arg === '-h') options.help = true
else if (arg === '--skip-transcribe') options.skipTranscribe = true
else if (arg === '--skip-download') options.skipDownload = 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()
else if (arg === '--out') options.out = value()
else die(`argumento desconhecido: ${arg}`)
}
if (!options.help && !options.date && !LEGISLATURE_RANGES[options.legislature]) {
die('informe --legislature <54|55|56|57> ou --date <YYYY-MM-DD> (veja --help).')
}
if (!options.help && options.date && !/^\d{4}-\d{2}-\d{2}$/.test(options.date)) {
die('--date deve ser YYYY-MM-DD.')
}
if (!Number.isInteger(options.limit) || options.limit < 1) die('--limit deve ser >= 1.')
if (!options.help && options.out.includes('..')) {
die('--out não pode escapar do diretório do repo (sem "..").')
}
return options
}
const HELP = `
Uso: pnpm camara:pilot [--legislature 54|55|56|57] [--date YYYY-MM-DD] [opções]
Opções:
--legislature <n> 54|55|56|57 (range de datas; ignorado se --date)
--date <YYYY-MM-DD> discursos de um único dia (sobrepõe --legislature)
--limit <n> quantos discursos processar (default 1)
--speaker <nome> nome do orador a casar no evento (default "Jorge Solla")
--out <dir> diretório de cache/artefatos (default data/camara)
--skip-download não baixa o MP4 (resolve e registra o VOD)
--skip-transcribe não chama a Deep Infra
--help esta ajuda
`
// ---------------------------------------------------------------------------
// Pipeline steps
// ---------------------------------------------------------------------------
const fileExists = async (path) => {
try {
await access(path)
return true
} catch {
return false
}
}
const resolveRange = (options) => {
if (options.date) return { from: options.date, to: options.date }
const [from, to] = LEGISLATURE_RANGES[options.legislature]
return { from, to }
}
// ---------------------------------------------------------------------------
// Per-speech orchestration
// ---------------------------------------------------------------------------
async function processSpeech(speech, options) {
const report = {
speechAt: speech.dataHoraInicio,
type: speech.tipoDiscurso,
phase: speech.faseEvento?.titulo ?? null,
summary: speech.sumario ?? null,
officialTranscriptChars: (speech.transcricao ?? '').length,
oficialKeywords: (speech.keywords ?? '').split(/[\r\n]+/).filter(Boolean).length,
event: null,
eventUrl: null,
youtube: null,
excerpt: null,
vodState: null,
vodUrl: null,
vodAttempts: null,
downloadUrl: null,
mp4: null,
transcription: null,
failures: [],
}
try {
const date = speech.dataHoraInicio.slice(0, 10)
const events = (await getJson(eventsUrl(date))).dados ?? []
const candidates = selectSpeechEvents(events, speech.dataHoraInicio)
if (candidates.length === 0) {
report.failures.push(`nenhum evento em ${date}`)
return report
}
for (const event of candidates) {
const html = await getText(eventPageUrl(event.id), { timeoutMs: 60_000 })
const excerpt = matchExcerpt(
parseEventExcerpts(html, event.id),
speech.dataHoraInicio,
options.speaker,
)
if (!excerpt) continue
report.event = {
id: event.id,
type: event.descricaoTipo,
start: event.dataHoraInicio,
status: event.situacao,
}
report.eventUrl = eventPageUrl(event.id)
report.youtube = event.urlRegistro || null
report.excerpt = excerpt
break
}
if (!report.excerpt) {
report.failures.push(`trecho de "${options.speaker}" não encontrado no(s) evento(s) do dia`)
return report
}
const cacheKey = `${report.event.id}-${report.excerpt.audioId}-${report.excerpt.tMs}`
const cachePath = join(options.out, `${cacheKey}.mp4`)
if (await fileExists(cachePath)) {
report.mp4 = { path: cachePath, bytes: (await readFile(cachePath)).length, cached: true }
} else {
const vod = await resolveVod(report.event.id, report.excerpt)
report.vodState = vod.status.state
report.vodUrl = vod.url
report.vodAttempts = vod.attempts
report.downloadUrl = vod.status.video?.downloadUrl ?? null
if (report.vodState !== 'PRONTO' || !report.downloadUrl) {
report.failures.push(`VOD não ficou PRONTO (estado=${report.vodState})`)
return report
}
if (!options.skipDownload) {
const cached = await ensureCachedDownload({
label: 'camara:pilot',
key: cacheKey,
url: report.downloadUrl,
ext: 'mp4',
cacheDir: options.out,
download: downloadToBuffer,
})
report.mp4 = {
path: join(options.out, `${cacheKey}.mp4`),
bytes: cached.buffer.length,
sha256: cached.hash,
cached: false,
}
}
}
if (!options.skipTranscribe && report.mp4) {
const buffer = await readFile(report.mp4.path)
const { transcription, elapsedMs } = await transcribeSpeechAudio(buffer)
const estimatedCostUsd = transcription.duration
? (transcription.duration / 60) * DEEPINFRA_COST_PER_MINUTE_USD
: null
report.transcription = {
language: transcription.language,
durationSeconds: transcription.duration,
segmentCount: transcription.segments.length,
firstSegment: transcription.segments[0] ?? null,
lastSegment: transcription.segments.at(-1) ?? null,
textPreview: transcription.text.slice(0, 300),
elapsedMs,
estimatedCostUsd,
}
}
} catch (error) {
report.failures.push(error?.message || String(error))
}
return report
}
// ---------------------------------------------------------------------------
// Report
// ---------------------------------------------------------------------------
const seconds = (ms) => `${(ms / 1000).toFixed(1)}s`
function printReport(reports) {
console.log('\n=== Relatório do piloto C152 ===')
for (const report of reports) {
console.log(`\n• discurso ${report.speechAt} — ${report.type ?? '?'} (${report.phase ?? '?'})`)
console.log(` resumo: ${(report.summary ?? '').slice(0, 90)}`)
console.log(
` transcrição oficial: ${report.officialTranscriptChars} chars | keywords: ${report.oficialKeywords}`,
)
if (report.event) {
console.log(
` evento: ${report.event.id} ${report.event.type} @ ${report.event.start} (${report.event.status})`,
)
console.log(` página: ${report.eventUrl}`)
console.log(` youtube: ${report.youtube ?? '—'}`)
}
if (report.excerpt) {
console.log(
` trecho: a=${report.excerpt.audioId} t=${report.excerpt.tMs} ` +
`${report.excerpt.speaker} ${report.excerpt.party ?? ''} ` +
`${report.excerpt.startTime ?? '?'} ${report.excerpt.duration ?? '?'}`,
)
}
if (report.vodState) console.log(` VOD: ${report.vodState} após ${report.vodAttempts} poll(s)`)
if (report.downloadUrl) console.log(` download: ${report.downloadUrl}`)
if (report.mp4)
console.log(` MP4: ${report.mp4.path} (${(report.mp4.bytes / 1e6).toFixed(1)} MB)`)
if (report.transcription) {
const t = report.transcription
console.log(
` ASR: ${t.segmentCount} segmentos | ${t.durationSeconds?.toFixed(1) ?? '?'}s | ` +
`${seconds(t.elapsedMs)} | ~US$ ${t.estimatedCostUsd?.toFixed(5) ?? '?'}`,
)
if (t.firstSegment)
console.log(
` primeiro: [${t.firstSegment.start}s–${t.firstSegment.end}s] ${t.firstSegment.text}`,
)
if (t.lastSegment)
console.log(
` último: [${t.lastSegment.start}s–${t.lastSegment.end}s] ${t.lastSegment.text}`,
)
}
if (report.failures.length > 0) console.log(` FALHAS: ${report.failures.join(' | ')}`)
}
const ok = reports.filter((r) => r.transcription?.segmentCount > 0).length
console.log(`\nResultado: ${ok}/${reports.length} discurso(s) com transcrição segmentada.`)
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const options = parseArgs(process.argv.slice(2))
if (options.help) {
console.log(HELP)
process.exit(0)
}
if (!options.skipTranscribe && !process.env.DEEPINFRA_API_KEY) {
die('DEEPINFRA_API_KEY ausente — configure no .env.local ou no env do processo.')
}
const { from, to } = resolveRange(options)
console.log(
`[camara:pilot] deputado ${SOLLA_DEPUTY_ID} | ${from}..${to} | speaker "${options.speaker}"`,
)
const speeches = (await getJson(speechesUrl(SOLLA_DEPUTY_ID, from, to))).dados ?? []
const targets = speeches
.filter((s) => (s.transcricao ?? '').trim() !== '')
.slice(0, options.limit)
if (targets.length === 0) die(`nenhum discurso com transcrição em ${from}..${to}.`)
const reports = []
for (const speech of targets) {
reports.push(await processSpeech(speech, options))
}
printReport(reports)
const reportPath = join(options.out, 'pilot-report.json')
await mkdir(options.out, { recursive: true })
await writeFile(reportPath, JSON.stringify(reports, null, 2))
console.log(`\n[camara:pilot] relatório JSON: ${reportPath}`)
}
main().catch((error) => die(error?.message || String(error)))