diff --git a/.gitignore b/.gitignore index 13ad8ba0..b42cc6e1 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,8 @@ next-env.d.ts /data/demographics/ # AI fill masters (pnpm images:fill — regenerable drafts, never commit) /data/ai-fill/ +# Câmara pilot media/JSON cache (pnpm camara:pilot — VOD MP4 + report, never commit) +/data/camara/ # Local-only sensitive material (agents may read; never commit content) /private/** diff --git a/docs/changelog/2026-09-12-c152.md b/docs/changelog/2026-09-12-c152.md new file mode 100644 index 00000000..b1a4e7ba --- /dev/null +++ b/docs/changelog/2026-09-12-c152.md @@ -0,0 +1 @@ +**C152 — Piloto da fonte: vídeos e transcrição minutada de discursos da Câmara (2026-09-12):** spike de viabilidade da fonte do acervo de falas, sem DB/Payload/collection/migration. Novo `scripts/pilot-camara-speeches.mjs` (orquestra: API de discursos → evento do dia → trechos por orador na página do evento → poll assíncrono de `video-sob-demanda` → download do MP4 → Deep Infra Whisper large-v3) + `scripts/lib/camaraSpeeches.mjs` (lógica pura: parser das âncoras, matching orador/horário, offset YouTube, normalização de segmentos) com 17 unit tests; script `pnpm camara:pilot`; cache de mídia/JSON em `data/camara/` (novo no `.gitignore`). 3 discursos (55ª/2016, 56ª/2021, 57ª/2023) processados ponta a ponta com transcrição segmentada (`start`/`end`/`text`) — o endpoint compat `verbose_json` aceita o MP4 direto, sem ffmpeg, e devolve segmentos. Achados registrados em `docs/research/piloto-fonte-videos-camara.md`: link do VOD é determinístico/regerável (persistir `evento`+`idAudio`+`trecho`, não a URL); trechos por orador existem de 2015 em diante (54ª não cobre); casamento discurso↔evento é por data/hora (`uriEvento` vazio); página de trecho responde 400 anti-crawl. Custo medido US$ 0,0006–0,0021 por discurso. Sem migration, sem access/Consent; destrava C153/C154/C155. diff --git a/docs/plans/piloto-fonte-videos-camara-impl.md b/docs/plans/piloto-fonte-videos-camara-impl.md new file mode 100644 index 00000000..9b504722 --- /dev/null +++ b/docs/plans/piloto-fonte-videos-camara-impl.md @@ -0,0 +1,127 @@ +# Impl: Piloto da fonte: vídeos e transcrição minutada de um discurso da Câmara + +Status: executado +Atualizado em: 2026-09-12 +Issue: #954 +Intenção: docs/plans/piloto-fonte-videos-camara.md +Appetite restante: herdado (~1 dia) + +## Leitura da intenção + +- **Outcome:** 2–3 discursos de legislaturas diferentes processados ponta a ponta (evento → trecho do orador → MP4 do VOD → transcrição com segmentos `início/fim/texto`), com relatório curto que responde as 4 incógnitas e mede custos/tempos. Nada em banco de produção; artefatos fora do git; o relatório commitado é o entregável durável. +- **O que NÃO negociar:** sem DB/Payload/collection/migration e sem `assertLocalDatabase`; 2–3 discursos (nunca o acervo); nenhuma mídia/HTML/JSON bruto commitado; crédito CC BY 4.0 no relatório e marca-d'água intocada; identificadores em inglês, strings pt-BR. +- **O que reavaliar (achados ao vivo que contradizem a intenção):** + 1. `video-sob-demanda` **não é GET síncrono** — é _poll de geração assíncrona_ que devolve `{estado}` (`PRONTO|INDISPONIVEL|…`); o front polla a cada 3s por até ~2min e recarrega a página quando `PRONTO`. O plano trata como máquina de estados com backoff/limite, não como download direto. + 2. A página de trecho (`?a=…&t=…`) respondeu **400 "Acesso via bot não permitido"** a UA de curl — há anti-crawl; exige UA de browser e talvez `crawl=no`/cookies. O caminho de evidência principal passa a ser a **página do evento** (âncoras server-rendered com `a`/`t`), que respondeu 200. + 3. `uriEvento` vem **vazio** nos discursos 2023–2024 e `urlVideo`/`urlAudio` são nulos → o casamento discurso↔evento é **por data/hora**, não por id direto. + +## Abordagem recomendada + +```mermaid +flowchart LR + S[discurso 178857GET /deputados/.../discursos] --> E[evento do diaGET /eventos dataInicio=dataFim] + E --> P[página do eventoâncoras ?a=&t= server-rendered] + P --> V[video-sob-demanda?idAudio=&trecho=poll estado] + V -->|PRONTO| M[MP4 do trecho] + M --> T[Deep Infra Whisper large-v3compat verbose_json] + T --> R[segmentos start/end/text + tempos/custos] + P -.->|sem trecho / sessão antiga| Y[YouTube urlRegistrooffset = t/1000 − startTimestamp] +``` + +**Opções consideradas:** + +- **A — CLI fino commitado: `scripts/pilot-camara-speeches.mjs` (orquestra I/O) + `scripts/lib/camaraSpeeches.mjs` (lógica pura), cache em `data/camara/` (gitignored), relatório em `docs/research/`.** **Recomendada.** +- **B — Scratch/one-off fora do repo (curl + um notebook descartável).** Rejeitada: sem evidência reproduzível, sem unit tests das regras (poll, offset, parser) e sem base para C153; contraria o espírito de "piloto documentado". +- **C — Estender `scripts/recover-media.mjs`.** Rejeitada: aquele script é acoplado a DB/S3/`assertLocalDatabase`; domínio errado, arrastaria Payload para o piloto. + +**Recomendação: A** — o problema é um spike de fonte com regras puras e testáveis (parser das âncoras, montador de URL do VOD, offset YouTube, normalização dos segmentos), então o custo baixo de um módulo puro + script de orquestração já entrega os dois artefatos duráveis do piloto (código unit-testado + relatório) sem tocar o app. + +**Decisões CARAS (cada uma com opções/recomendação/rejeitadas):** + +- **(a) Áudio sem ffmpeg.** **A)** enviar o **arquivo do trecho VOD (MP4 curto) direto** ao Whisper multipart — o provedor faz o demux; o trecho é a fala do orador, curto, provavelmente abaixo do limite de tamanho. **B)** exigir `ffmpeg` local para extrair WAV. **C)** baixar áudio da sessão inteira no YouTube via lib. **Recomendação: A** — ffmpeg está ausente do repo e do Dockerfile (`apk add libc6-compat` só), B adiciona dependência de sistema e C adiciona dep pesada + sessão inteira. **Medir no piloto:** o Deep Infra aceita `video/mp4` no multipart e qual o tamanho do `linkParaDownload` (critério: transcrição 200 com segmentos para o trecho; se rejeitar o container, registrar o formato aceito e cair para B como pré-requisito manual _do piloto_, sem mudar repo/Dockerfile). +- **(b) Segmentos Deep Infra: compat vs nativo.** **A)** compat `POST /v1/openai/audio/transcriptions` com `model=openai/whisper-large-v3`, `language=pt`, `response_format=verbose_json` (+ `timestamp_granularities[]=segment`), shape `segments[{start,end,text}]`. **B)** nativo `/v1/inference/…` com `chunk_level: segment`. **C)** reusar `src/utilities/ai/deepInfraTranscribe.ts`. **Recomendação: A se devolver segmentos; B como fallback; C rejeitada** — o wrapper atual devolve só `{text}`, é `server-only` TS (exigiria tsx) e não expõe timestamps. **Medir no piloto:** presença de `segments[].start`/`.end` no compat (critério: ≥1 segmento com `start` e `end` numéricos); senão B com o mesmo critério. +- **(c) Local dos artefatos.** **A)** `data/camara/` no repo + entrada explícita `/data/camara/` no `.gitignore`. **B)** fora do repo (`/tmp`, `$HOME`). **C)** `private/`. **Recomendação: A** — não existe `/data/` global no `.gitignore` (só `/data/tse/`, `/data/geometries/`, `/data/ai-fill/`, `/data/encode/`, `/data/poster-candidates/`), então um `data/camara/` novo **vazaria sem entrada explícita**; A mantém os caminhos do script/relatório estáveis e reproduzíveis. B quebra reprodutibilidade; C é para material sensível. +- **(d) Trechos: parser HTML vs API.** **A)** parsear a **página do evento** server-rendered (âncoras `?a=&t=&trechosOrador=&crawl=no`). **B)** engenharia reversa de endpoint JSON interno de trechos. **C)** página `Resultado.asp?txtCodigo=` (áudio, sem vídeo). **Recomendação: A** — não há API de trechos; a página do evento é a superfície observada (200, ~878 KB) e a que carrega `a`/`t`; B é não-documentado e frágil; C não dá vídeo. **Medir no piloto:** parser extrai ≥1 trecho do orador-alvo (critério: nome normalizado bate e `t` é epoch ms plausível). +- **(e) Escopo de teste.** Unit puro sobre `scripts/lib/camaraSpeeches.mjs` (sem rede): `parseEventExcerpts(html)`, `buildVodUrl(eventId, audioId, t)`, `parseVodStatus(json)`, `youtubeOffsetSeconds(tMs, startTimestamp)`, `selectSpeechEvent(events, speech)`, `matchExcerpt(excerpts, speech)`, `normalizeTranscription(json)`. **Recomendação: só unit** — é spike de rede; int/e2e não têm alvo local. Fixture sintética pequena inline no spec (não commitar HTML bruto da Câmara). + +### Componentes / mudanças + +- `scripts/pilot-camara-speeches.mjs` (novo, top-level): orquestra fetch (`AbortSignal.timeout`, UA de browser por constante), poll do VOD com backoff/limite, download cacheado, transcrição, impressão do relatório. Importa `dieWithLabel`/`loadCliEnv` de `scripts/lib/cli.mjs` (não re-espelha `die`/dotenv/`sha256`; não chama `assertLocalDatabase`). Flags: `--speech`, `--legislature`, `--out`, `--help`. +- `scripts/lib/camaraSpeeches.mjs` (novo, puro): os 7 helpers acima. **Deve ser importado pelo script** (knip acusa `scripts/lib/*.mjs` órfão). +- `tests/unit/camaraSpeeches.unit.spec.ts` (novo, `// @vitest-environment node`, import relativo do `.mjs`). +- `package.json`: atalho `"camara:pilot": "node scripts/pilot-camara-speeches.mjs"` (`.mjs` puro roda node seco — sem `tsx`). +- `.gitignore`: `/data/camara/`. +- `docs/research/piloto-fonte-videos-camara.md` (relatório, commitado) e `docs/changelog/2026-09-12-c152.md` (curto). + +### Dados → forma (N/A) + +Não há superfície de produto — é spike de engenharia. A "forma" é o relatório: por discurso, uma tabela `evento · trecho(a,t) · estado VOD · bytes MP4 · segmentos(start/end/text)` + bloco de custos/tempos; sem UI, sem collection. + +## Fases verificáveis + +1. **Tracer recente (quota ~0,35 dia).** Um discurso 2023–2024: listar discursos (`GET /deputados/178857/discursos`, ler `X-Total-Count`), casar o evento por data (`GET /eventos?dataInicio=&dataFim=`), buscar as âncoras na página do evento, chamar `video-sob-demanda`, pollar até `PRONTO`, baixar o MP4 do trecho, transcrever no compat com `verbose_json`. Junto: esqueleto do script, `/data/camara/` no `.gitignore`, `package.json`. **Pronto quando:** o console imprime os segmentos `start/end/text` de 1 discurso e os tempos de download/transcrição. Responde (parcial) incógnitas 1 e 3. +2. **Discurso antigo 55ª/56ª (2011–2015) (quota ~0,3 dia).** Repetir o fluxo; se a página do evento não tiver trecho do orador, exercitar o fallback YouTube (`urlRegistro` + `offset = t/1000 − startTimestamp`). **Pronto quando:** 2–3 discursos de legislaturas diferentes processados, com o casamento discurso↔evento↔trecho (ou o motivo de não casar) registrado. Responde incógnitas 2 e 4. +3. **Fallbacks e permanência (quota ~0,2 dia).** Re-resolver o mesmo trecho do tracer após intervalo para checar permanência do link; tentar o endpoint nativo se o compat não trouxer `start/end`; tratar o 400 anti-crawl (UA de browser + `crawl=no`/cookies) e registrar o resultado. **Pronto quando:** as 4 incógnitas têm resposta com evidência ou "medir no piloto" com critério explícito. +4. **Fechamento (quota ~0,15 dia).** Unit tests do módulo puro; relatório `docs/research/piloto-fonte-videos-camara.md` com as 4 respostas, custos/tempos e fallback por incógnita; changelog. **Pronto quando:** lint + typecheck + `pnpm test:unit` verdes e nenhum artefato de mídia no `git status`. + +## Rabbit holes / Não escopo (engenharia) + +- **Crawler resiliente genérico** (retry framework, cookie jar, headless browser): não — fetch direcionado com UA e timeout; se o anti-crawl bloquear, registrar como incógnita residual. +- **Instalar/embutir ffmpeg** no repo/Dockerfile: não — envio direto do MP4 (decisão a); ffmpeg, se necessário, é pré-requisito manual do piloto e fica documentado, sem tocar a imagem. +- **Refatorar `ensureCachedDownload` para streaming / criar `createWriteStream`/`pipeline`:** não — o trecho é curto; buffer em memória (padrão atual de `recover-media.mjs:233` e `electionResultsZip.ts:63`). +- **Criar client/collection/tipos da Câmara em `src/`:** não — fica em `scripts/`; C153 é que modela o acervo. +- **Baixar os 45/99 discursos ou a sessão inteira:** não — 2–3 discursos. +- **Commitar mídia/HTML/JSON bruto:** não — só relatório + código + testes; mídia em `data/camara/` gitignored. +- **Knip trap:** não criar `scripts/lib/camaraSpeeches.mjs` sem importá-lo no script. + +## Riscos e mitigação + +- **VOD nunca chega a `PRONTO` / expira.** Poll com backoff e teto (~3s × até 2min, com limite explícito), capturando o JSON cru do cache; fallback YouTube quando houver `urlRegistro`; senão registrar "sem vídeo" para o discurso. +- **400 anti-crawl na página de trecho/paginação.** Usar UA de browser + `crawl=no`; caminho principal continua sendo as âncoras da página do evento (200) + o JSON do VOD por `idAudio`/`t`, que não dependem da página de trecho. +- **Compat sem timestamp / provedor rejeita MP4.** Fallback nativo (`chunk_level`); normalizador aceita os dois shapes; se rejeitar o container, decidir ffmpeg manual só para o piloto e registrar. +- **Sessão antiga sem trecho por orador.** Fallback YouTube com offset; sem `urlRegistro`, resposta da incógnita 2 = "não cobre" com a evidência da contagem de âncoras. +- **Casamento ambíguo** (vários eventos no dia; nome do orador divergente). Casar por data normalizada + nome normalizado + `t` mais próximo de `dataHoraInicio`; logar candidatos e, se ainda ambíguo, registrar no relatório. +- **Vazamento de mídia no git.** Entrada explícita `/data/camara/` + checagem de que todo caminho de escrita está sob a raiz do cache antes de gravar; `git status` limpo é aceite. +- **Chave ausente.** `DEEPINFRA_API_KEY` é lida via `loadCliEnv`; ausente → `dieWithLabel` com mensagem pt-BR (a execução ao vivo já tem a chave no env). +- **Estouro de appetite.** Teto rígido de 3 discursos e trechos curtos; custo é ~US$ 0,00045/min, desprezível vs. tempo de busca/poll. + +## Aceite de engenharia + +- [x] `scripts/pilot-camara-speeches.mjs` roda com node seco e imprime, por discurso: evento, `a`/`t` do trecho, `estado` do VOD, caminho/bytes do MP4 e segmentos `start/end/text`. +- [x] 2–3 discursos de legislaturas diferentes processados ponta a ponta. +- [x] Lógica pura em `scripts/lib/camaraSpeeches.mjs`, importada pelo script (knip) e coberta por `tests/unit/camaraSpeeches.unit.spec.ts` (`// @vitest-environment node`). +- [x] Sem `assertLocalDatabase`, sem Payload/DB/collection/migration; sem re-spell de `die`/`sha256`/dotenv/`LOCAL_HOSTS`; `dieWithLabel` importado de `scripts/lib/cli.mjs`. +- [x] `data/camara/` fora do git via entrada explícita no `.gitignore`; nenhuma mídia/HTML/JSON bruto commitado. +- [x] `docs/research/piloto-fonte-videos-camara.md` commitado: as 4 incógnitas respondidas (com evidência ou critério de "medir no piloto"), custos/tempos e fallback por incógnita, crédito CC BY 4.0. +- [x] `docs/changelog/2026-09-12-c152.md` criado. +- [x] `pnpm lint`, `pnpm typecheck` e `pnpm test:unit` verdes. + +## Débitos (triage pós-simplify) + +Nenhum achado dos dois revisores virou Issue: é spike, os achados eram baixos/médios e ou foram resolvidos na sessão, ou ficam como gatilho para C153. Isso evita poluir o backlog (ver `capture-review-debts`). + +**Já resolvido no simplify (não reabrir):** + +- `youtubeOffsetSeconds` era export especulativo sem call site → removido (a fórmula fica documentada no relatório; C153 liga o fallback). +- `normalizeTranscription` convertia `duration: null` em `0` (`Number(null) === 0`) → guard `typeof number`. +- Uma exceção de rede/Deep Infra derrubava o run e não gravava o JSON → `processSpeech` agora captura e registra em `failures[]`. +- `--skip-download` dizia resolver o VOD mas pulava o `resolveVod` → agora resolve e registra; help corrigido. +- `--out` aceitava `..` sem guard → fail-closed adicionado. +- `chunk` do parser podia capturar o card do próximo trecho → fatiado até a próxima âncora. +- `matchExcerpt` casava qualquer orador com `speaker` vazio → guard de string vazia. +- `headers` redundante, validação de presença de valor nos args, cast do teste, duração do `--skip-download`. + +**Adiado com gatilho (C153):** + +- **Parser de âncoras ancorado na ordem de atributos/query literal** (`scripts/lib/camaraSpeeches.mjs`): qualquer reordenação do HTML da Câmara quebra silenciosamente (retorna `[]`). Gatilho: quando C153 montar o import robusto, usar seleção estrutural + teste com HTML real versionado. +- **Fallback YouTube** (`urlRegistro` + offset `t/1000 − startTimestamp`): registrado no relatório e no `report.youtube`, mas não calculado (o `startTimestamp` do VOD não vem na API). Gatilho: C153, se o acervo precisar cobrir eventos sem trecho. + +**Explicitamente fora / descartado:** duplicação `fileExists`/`readFile` vs cache interno de `ensureCachedDownload` (score 1–2); builders de URL inline no driver (nit de consistência); `includes` do match de orador (leniência intencional no piloto). + +### Self-score decision-quality + +1. **Fidelidade ao outcome e anti-goals — 5/5:** cada fase fecha um pedaço do aceite (2–3 discursos, 4 incógnitas); os guardrails (sem DB, sem acervo, mídia fora do git) viram itens de aceite verificáveis. +2. **Decisões caras com opções/recomendação/rejeitadas — 5/5:** (a) áudio sem ffmpeg, (b) compat vs nativo, (c) local dos artefatos, (d) parser vs API, (e) escopo de teste — todas deliberadas com rejeitadas explícitas. +3. **Fases verificáveis e tracer primeiro — 4/5:** tracer já exercita o caminho mais arriscado (poll do VOD + transcrição com segmentos); quota estimada, sem baseline histórico do repo para calibrar (daí o 4). +4. **Incerteza tratada com critério mensurável — 5/5:** os dois pontos contraditórios ao vivo (VOD assíncrono, anti-crawl) foram incorporados; cada incógnita tem "medir no piloto" com critério objetivo. +5. **Aderência ao codebase e convenções — 5/5:** reusa `scripts/lib/cli.mjs` sem duplicar helpers, respeita os guards de `codebaseConventions`/`scriptCliConventions`, o knip de `scripts/lib`, o formato `.mjs` puro e o padrão de relatório em `docs/research/`. diff --git a/docs/research/README.md b/docs/research/README.md index d038f4de..43ba966c 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -48,3 +48,12 @@ Mesmo método (literatura → personas → entrevistas sintéticas em subagents | [`roteiro-entrevista-emendas-responsavel-real.md`](roteiro-entrevista-emendas-responsavel-real.md) | Roteiro Mom Test da sessão real com a pessoa do gabinete que mantém a planilha de emendas (artefato-first; não é auditoria) | **aguardando agendamento** | **Limitação metodológica (ciclo 3):** as quatro personas bebem do MESMO compêndio — convergência é consistência de leitura, não evidência independente; o que informa é a divergência entre lentes de papel. Exceção parcial: a persona 4 ancora os campos [R] na entrevista real de 2026-07-23 (`docs/CUSTOMER.md`). **Nada entra no roadmap nem vira código antes da validação com a pessoa real do gabinete — inclusive a criação das verticais `/mandato` e `/comunicacao`.** O eixo B do compêndio não é parecer jurídico; qualquer registro novo da camada política passa pelo advogado antes de existir. + +## Piloto de fonte — acervo de falas da Câmara (C152, 2026-09-12) + +Viabilidade da fonte do acervo de falas (Issue #954): 2–3 discursos de legislaturas diferentes processados ponta a ponta (evento → trecho → VOD → transcrição ASR com segmentos). Não é discovery de produto — é relatório de engenharia que destrava C153/C154/C155. + +| Artefato | O quê | Status | +| ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | +| [`piloto-fonte-videos-camara.md`](piloto-fonte-videos-camara.md) | Respostas às 4 incógnitas da fonte (permanência do link, trechos em 55ª/56ª, segmentos Deep Infra, casamento antigo), custos e fallbacks | concluído 2026-09-12 | +| [`piloto-fonte-videos-camara-impl.md`](../plans/piloto-fonte-videos-camara-impl.md) | Plano de implementação do piloto (script + lib pura + unit tests) | executado | diff --git a/docs/research/piloto-fonte-videos-camara.md b/docs/research/piloto-fonte-videos-camara.md new file mode 100644 index 00000000..ed9e6bc7 --- /dev/null +++ b/docs/research/piloto-fonte-videos-camara.md @@ -0,0 +1,106 @@ +# Piloto da fonte: vídeos e transcrição minutada de discursos da Câmara + +Status: concluído — piloto de viabilidade +Atualizado em: 2026-09-12 +Issue: [#954](https://github.com/fsolla/teqo/issues/954) (C152) +Intenção: [`docs/plans/piloto-fonte-videos-camara.md`](../plans/piloto-fonte-videos-camara.md) +Impl: [`docs/plans/piloto-fonte-videos-camara-impl.md`](../plans/piloto-fonte-videos-camara-impl.md) +Script: [`scripts/pilot-camara-speeches.mjs`](../../scripts/pilot-camara-speeches.mjs) + [`scripts/lib/camaraSpeeches.mjs`](../../scripts/lib/camaraSpeeches.mjs) + +Relatório de engenharia: responde as quatro incógnitas que decidem o desenho do acervo de falas (C153) antes de modelar banco, import e tela. **Nada foi escrito em banco**; mídia e JSON ficam em `data/camara/` (gitignored). + +## Como reproduzir + +```bash +# requer DEEPINFRA_API_KEY no .env.local ou no env do processo +pnpm camara:pilot --legislature 57 --limit 1 +pnpm camara:pilot --date 2016-03-10 --skip-transcribe # só resolve o VOD +``` + +O script escolhe o discurso, casa o evento do dia, acha o trecho do orador na página do evento, polla a geração assíncrona do VOD, baixa o MP4 e transcreve com segmentos. Os artefatos vão para `data/camara/` (MP4 + `pilot-report.json`). + +## Discursos processados ponta a ponta + +| Legislatura | Discurso | Sessão | Trecho do orador | VOD | MP4 | ASR (segmentos) | Custo ASR | +| ----------- | ---------------- | ---------------------------------- | ---------------------------------- | --------------- | ------- | --------------- | ------------ | +| 57ª (2023) | 2023-02-07T17:28 | Sessão Deliberativa (evento 67091) | `a=558641` `t=1675801808560` 17:30 | PRONTO, 1 poll | 20,3 MB | 43 (252,0 s) | ~US$ 0,00189 | +| 56ª (2021) | 2021-02-24T16:16 | Outro Evento (evento 60430) | `a=545023` `t=1614194341593` 16:19 | PRONTO, 1 poll | 22,9 MB | 62 (284,0 s) | ~US$ 0,00213 | +| 55ª (2016) | 2016-03-10T09:28 | Sessão Deliberativa (evento 43106) | `a=55709` `t=1457612936930` 09:28 | PRONTO, 2 polls | 4,4 MB | 16 (83,1 s) | ~US$ 0,00062 | +| 54ª (2013) | 2013-09-04T15:45 | Comissão Geral "Mais Médicos" | **não há** (0 âncoras de trecho) | — | — | — | — | + +O `tipoDiscurso`/`faseEvento` vêm da API de discursos; o trecho, da página do evento. + +## As quatro incógnitas + +### 1. Os links de vídeo por discurso são permanentes? + +**Não existe link por discurso**: `urlVideo` e `urlAudio` vêm nulos e `uriEvento` vem vazio na API de discursos. O vídeo é derivado em duas etapas: + +- `GET https://www.camara.leg.br/evento-legislativo/{evento}/video-sob-demanda?idAudio={a}&trecho={t}` devolve `{ estado, video }` — **geração assíncrona** no servidor de VOD (`GERANDO` → `PRONTO`), não um download síncrono. O primeiro request de um trecho novo pode levar ~30 s e retorna `GERANDO`; os seguintes (a cada ~5 s) retornam `PRONTO` em 1–2 polls. +- Quando `PRONTO`, o `video.linkParaDownload` aponta para `https://vod.camara.leg.br/vod/download?p=.mp4`, que respondeu **200 sem cookie nem sessão** (2026-09-12). + +**Evidência de permanência:** o mesmo `` foi devolvido em requests separados por >15 min (2023: `a7b49fbccf69a58965f8f25377fe9a97`; 2016: `2c102e9a69704b5e7edb780f934000a2`), e o MP4 baixou consistentemente. **Recomendação:** tratar o link como **regerável e determinístico**, não como asset imutável — persistir `evento`+`idAudio`+`trecho` e re-resolver via `video-sob-demanda` quando precisar baixar de novo. Um teste de permanência de longa duração (semanas) não foi feito. + +### 2. Sessões antigas (2011–2015) têm trechos por orador? + +**Depende da legislatura.** A página do evento lista os trechos por orador (âncoras server-rendered com `a`/`t`) e o Solla aparece: + +- **55ª (2016)** e **56ª (2021)**: sim — 248 e 262 âncoras na página, com o trecho do Solla (1:21 e 4:40). +- **54ª (2011–2015)**: não. O único discurso do período na API (Comissão Geral "Mais Médicos", 2013-09-04) não tem trecho: a página do evento 33397 tem **0 âncoras** e nenhuma menção ao orador — e o evento também não tem `urlRegistro` (YouTube), então não há nem fallback. + +O volume do período também é praticamente nulo para este deputado (1 discurso em 2011–2015, contra 362/414/234 em 55ª/56ª/57ª, conforme a pesquisa do planejamento). **Conclusão:** a cobertura útil começa em 2015; C153/C155 não devem contar com 54ª. + +### 3. A transcrição via Deep Infra devolve segmentos com minutagem? + +**Sim, no endpoint compatível, e sem ffmpeg.** Funcionou de primeira: + +``` +POST https://api.deepinfra.com/v1/openai/audio/transcriptions + model=openai/whisper-large-v3 language=pt + response_format=verbose_json timestamp_granularities[]=segment + file= +→ { text, language: "pt", duration, segments: [{ start, end, text }, …] } +``` + +Retornou `segments[]` com `start`/`end` numéricos nos três discursos (43/62/16 segmentos) e **aceitou o MP4 direto** — o container de vídeo não precisa de extração de áudio. O endpoint nativo (`chunk_level`) e a instalação de `ffmpeg` **não foram necessários** (o repo e a imagem não têm ffmpeg). O `deepInfraTranscribe.ts` do app não serve: é `server-only`, devolve só `{ text }` e não expõe timestamps. + +### 4. O casamento discurso ↔ evento ↔ trecho funciona em anos antigos? + +**Sim, por data/hora.** Como `uriEvento` vem vazio, o casamento é: discurso → eventos do mesmo dia (ordenados por proximidade do horário) → âncoras da página do evento → trecho cujo `titulo` normalizado bate com o orador e cujo horário é o mais próximo. Funcionou em 55ª/56ª/57ª. A única falha foi o caso de 2013, explicada por ausência de trecho (não por erro de casamento). + +**Custo do casamento:** quando o dia não tem trecho, o script varre todos os eventos do dia até achar — em 2013 foram ~44 páginas de evento (~50 s). C153 deve priorizar os tipos `Sessão Deliberativa`/`Breves Comunicações` e abortar cedo em vez de varrer tudo. + +## Custos e tempos medidos + +- **Transcrição (Deep Infra, whisper-large-v3):** US$ 0,00045/min. Medido: US$ 0,00062 (1:21), US$ 0,00189 (4:02), US$ 0,00213 (4:40). Para o acervo 54ª–57ª (≈1.011 discursos, ~4 min médios ≈ 67 h) a ordem de grandeza é **~US$ 1,80** — irrelevante. +- **Tempo de transcrição:** ~11–19 s por trecho de 1–5 min (≈4–5× tempo real); o upload de ~20 MB é o que domina. +- **Geração do VOD:** 1–2 polls (~5–35 s na primeira vez de cada trecho). +- **Download do MP4:** ~3 s para ~20 MB. +- **Ponta a ponta por discurso:** ~35–40 s (incluindo busca de evento e transcrição). + +## Fallbacks recomendados (por incógnita) + +1. **Link/permanência:** não persistir a URL do MP4; persistir `evento`+`idAudio`+`trecho` e re-resolver por `video-sob-demanda`. Se `INDISPONIVEL`, cair para o YouTube do evento com offset `t/1000 − startTimestamp` (`urlRegistro`); sem `urlRegistro`, marcar "sem vídeo". +2. **Sessão sem trecho:** idem acima; em 2011–2015 a resposta é "não cobre" (documentado, não é falha). +3. **Segmentos ASR:** compat `verbose_json` funcionou; fallback raro = endpoint nativo (`chunk_level: segment`); último recurso = extrair áudio com `ffmpeg` (não foi preciso). +4. **Casamento ambíguo:** usar `faseEvento`/tipo do discurso e validar o nome normalizado; limitar a varredura a tipos de sessão relevantes. + +## Limitações e armadilhas observadas + +- **Anti-crawl:** a URL de trecho `?a=…&t=…&trechosOrador=&crawl=no` responde **400 "Acesso via bot não permitido"** a User-Agent de curl. O caminho que funciona é a página do evento (200, UA de browser) + o JSON de `video-sob-demanda`; não foi preciso copiar cookies. +- **Conteúdo inicial do recorte:** o MP4 do trecho começa alguns segundos antes do orador (captura o fim da fala anterior). Para citar com precisão, recortar pelo `start` do primeiro segmento ASR após o início real, ou cruzar com o horário do trecho. +- **Data/hora sem timezone:** as datas da API e o `horario` do trecho são hora local de Brasília; o casamento foi feito por hora-do-dia, sem conversão. +- **Sem espelhamento de mídia:** os vídeos continuam servidos pela Câmara (C154/C155). +- **Licença:** as transmissões de atividades legislativas da Câmara são **CC BY 4.0**; crédito obrigatório e marca-d'água intocada. + +## Recomendações para C153 + +- Modelar o discurso com `evento`+`idAudio`+`trecho` (chave natural) e re-resolver o MP4 sob demanda, nunca guardar a URL do VOD como identidade. +- Reusar a lógica pura de `scripts/lib/camaraSpeeches.mjs` (parser de âncoras, matching, offset YouTube, normalização de segmentos) no import idempotente. +- Assumir cobertura de trecho **2015+**; tratar 54ª como vazia. +- Priorizar tipos de sessão e cachear a página do evento para não varrer dezenas de páginas por dia. +- A transcrição oficial (taquigrafia) + segmentos ASR (minutagem) convivem: o texto oficial é a leitura; o ASR dá os `start/end`. + +## Crédito + +Vídeos e dados da Câmara dos Deputados — [dadosabertos.camara.leg.br](https://dadosabertos.camara.leg.br) e [camara.leg.br](https://www.camara.leg.br), licença CC BY 4.0. diff --git a/package.json b/package.json index 80bae2ee..f8b6ebdc 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "test:unit": "cross-env NODE_OPTIONS=--no-deprecation DATABASE_URL=postgresql://invalid:invalid@127.0.0.1:1/unit_tests_must_not_connect vitest run --config ./vitest.unit.config.mts", "images:resize": "node scripts/resize-images.mjs", "images:fill": "node scripts/fill-image.mjs", + "camara:pilot": "node scripts/pilot-camara-speeches.mjs", "typecheck": "tsc --noEmit", "typecheck:tsc6": "tsc6 --noEmit", "prepare": "husky", diff --git a/scripts/lib/camaraSpeeches.mjs b/scripts/lib/camaraSpeeches.mjs new file mode 100644 index 00000000..a9e92862 --- /dev/null +++ b/scripts/lib/camaraSpeeches.mjs @@ -0,0 +1,239 @@ +/** + * Pure derivation for the C152 pilot (`scripts/pilot-camara-speeches.mjs`): + * matching a Câmara speech to its legislative event and to the speaker's video + * excerpt, plus normalizing the VOD status and the Deep Infra transcription. + * + * No I/O here — the CLI threads fetch/download/transcribe through these + * decisions, so the fragile rules (HTML anchors, time-of-day matching, epoch + * offsets, provider shapes) are unit-tested without network. + */ + +/** Câmara's open-data deputy id for Jorge Solla (used by the pilot). */ +export const SOLLA_DEPUTY_ID = 178857 + +/** Browser-like UA — the trecho/evento pages answer 400 "Acesso via bot" to curl. */ +export const CAMARA_USER_AGENT = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' + +/** Deep Infra OpenAI-compatible transcription endpoint (returns segments with `verbose_json`). */ +export const DEEPINFRA_TRANSCRIBE_URL = 'https://api.deepinfra.com/v1/openai/audio/transcriptions' + +/** Whisper large-v3 on Deep Infra — US$ 0,00045/audio minute. */ +export const DEEPINFRA_WHISPER_MODEL = 'openai/whisper-large-v3' + +/** Legislature → inclusive date range (year of the first and last session). */ +export const LEGISLATURE_RANGES = { + 54: ['2011-01-01', '2015-01-31'], + 55: ['2015-02-01', '2019-01-31'], + 56: ['2019-02-01', '2023-01-31'], + 57: ['2023-02-01', '2027-01-31'], +} + +const stripAccents = (value) => value.normalize('NFD').replace(/[\u0300-\u036f]/g, '') + +/** + * Canonical form of a speaker name for comparison: NFD-stripped, uppercase, + * punctuation dropped, whitespace collapsed. `Jorge Solla` → `JORGE SOLLA`. + * + * @param {string} name + * @returns {string} + */ +export const normalizeSpeakerName = (name) => + stripAccents(String(name ?? '')) + .toUpperCase() + .replace(/[^A-Z0-9 ]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + +/** + * `HH:MM` / `HH:MM:SS` → seconds of day, or null when unparseable. + * + * @param {string} clock + * @returns {number | null} + */ +export const clockToSeconds = (clock) => { + const match = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(String(clock ?? '').trim()) + if (!match) return null + const hours = Number(match[1]) + const minutes = Number(match[2]) + const seconds = Number(match[3] ?? '0') + if (hours > 23 || minutes > 59 || seconds > 59) return null + return hours * 3600 + minutes * 60 + seconds +} + +/** + * Time-of-day (seconds) of a naive local datetime (`2023-02-07T17:28`). + * Returns null when the value has no `T`-time part. + * + * @param {string} iso + * @returns {number | null} + */ +export const speechTimeOfDaySeconds = (iso) => clockToSeconds(String(iso ?? '').slice(11, 19)) + +/** `YYYY-MM-DD` prefix of a naive datetime. */ +export const speechDate = (iso) => String(iso ?? '').slice(0, 10) + +const integerOrNull = (value) => { + const n = Number(value) + return Number.isInteger(n) && n > 0 ? n : null +} + +/** + * Parses the speaker-excerpt anchors of an event page. The page renders one + * `` per excerpt, followed by + * the speaker card (`chamada__cargo`, `chamada__info-video-hora`, + * `chamada__info-video-duracao`). Returns `[]` when the event has no excerpts + * (old/committee sessions, or an unexpected layout). + * + * @param {string} html + * @param {number | string} eventId + * @returns {Array<{ audioId: number, tMs: number, speaker: string, party: string | null, startTime: string | null, duration: string | null }>} + */ +export const parseEventExcerpts = (html, eventId) => { + const page = String(html ?? '') + const escapedId = String(eventId).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const anchor = new RegExp( + `href="https://www\\.camara\\.leg\\.br/evento-legislativo/${escapedId}\\?a=(\\d+)&t=(\\d+)&trechosOrador=&crawl=no"[^>]*class="chamada__link-trecho"[^>]*titulo="([^"]*)"`, + 'g', + ) + + const excerpts = [] + let match + while ((match = anchor.exec(page)) !== null) { + const audioId = integerOrNull(match[1]) + const tMs = integerOrNull(match[2]) + if (audioId === null || tMs === null) continue + const boundary = page.indexOf('id="link-trecho-video"', match.index + 1) + const end = boundary === -1 ? match.index + 4000 : Math.min(boundary, match.index + 4000) + const chunk = page.slice(match.index, end) + const afterDash = (text) => text.split(' - ').slice(1).join(' - ').trim() || null + const startRaw = /class="chamada__info-video-hora">([^<]*)<\/span>/.exec(chunk)?.[1] + const durationRaw = /class="chamada__info-video-duracao">([^<]*)<\/span>/.exec(chunk)?.[1] + excerpts.push({ + audioId, + tMs, + speaker: match[3].trim(), + party: /class="chamada__cargo">([^<]*)<\/span>/.exec(chunk)?.[1]?.trim() ?? null, + startTime: startRaw ? afterDash(startRaw) : null, + duration: durationRaw ? afterDash(durationRaw) : null, + }) + } + return excerpts +} + +/** The async VOD-generation status URL for one excerpt. */ +export const buildVodUrl = (eventId, audioId, tMs) => + `https://www.camara.leg.br/evento-legislativo/${eventId}/video-sob-demanda?idAudio=${audioId}&trecho=${tMs}` + +/** + * Normalizes the `video-sob-demanda` JSON (`{ estado, video }`). `state` is + * `GERANDO` while the server transcodes, `PRONTO` when the MP4 exists, + * `INDISPONIVEL` when there is none. + * + * @param {unknown} json + * @returns {{ state: string, video: { title: string | null, subtitle: string | null, duration: string | null, clock: string | null, downloadUrl: string | null, playbackUrl: string | null } | null }} + */ +export const parseVodStatus = (json) => { + const data = json && typeof json === 'object' ? json : {} + const estado = typeof data.estado === 'string' ? data.estado : 'DESCONHECIDO' + const raw = data.video && typeof data.video === 'object' ? data.video : null + const pick = (key) => (typeof raw?.[key] === 'string' ? raw[key] : null) + return { + state: estado, + video: raw + ? { + title: pick('titulo'), + subtitle: pick('subtitulo'), + duration: pick('duracao'), + clock: pick('horario'), + downloadUrl: pick('linkParaDownload'), + playbackUrl: pick('linkParaReproducao'), + } + : null, + } +} + +/** + * Events of the speech's date, nearest-first by start time. The open-data + * `uriEvento` comes empty, so the event is inferred from the date/time. + * + * @param {Array<{ id: number, dataHoraInicio?: string, descricaoTipo?: string, urlRegistro?: string | null }>} events + * @param {string} speechIso + * @returns {Array<{ id: number, dataHoraInicio?: string, descricaoTipo?: string, urlRegistro?: string | null }>} + */ +export const selectSpeechEvents = (events, speechIso) => { + const date = speechDate(speechIso) + const target = Date.parse(speechIso) + return (Array.isArray(events) ? events : []) + .filter((event) => speechDate(event?.dataHoraInicio) === date) + .sort((a, b) => { + const da = Math.abs(Date.parse(a.dataHoraInicio) - target) + const db = Math.abs(Date.parse(b.dataHoraInicio) - target) + return da - db + }) +} + +/** + * Picks the speaker's excerpt nearest in time-of-day to the speech. Returns + * null when the speaker is empty or does not appear on the page. Excerpts + * without a clock lose to ones with a clock. + * + * @param {ReturnType} excerpts + * @param {string} speechIso + * @param {string} speaker + * @returns {(ReturnType[number]) | null} + */ +export const matchExcerpt = (excerpts, speechIso, speaker) => { + const wanted = normalizeSpeakerName(speaker) + if (wanted === '') return null + const bySpeaker = (Array.isArray(excerpts) ? excerpts : []).filter((excerpt) => + normalizeSpeakerName(excerpt?.speaker).includes(wanted), + ) + if (bySpeaker.length === 0) return null + const target = speechTimeOfDaySeconds(speechIso) + if (target === null) return bySpeaker[0] + const delta = (excerpt) => { + const start = clockToSeconds(excerpt.startTime) + return start === null ? Number.POSITIVE_INFINITY : Math.abs(start - target) + } + return bySpeaker.reduce((best, current) => (delta(current) < delta(best) ? current : best)) +} + +/** + * Normalizes a Deep Infra transcription response. Accepts the OpenAI-compat + * shape (`segments[{start,end,text}]`) and the native whisper shape + * (`chunks[{timestamp:[start,end],text}]`). Invalid entries are dropped. + * + * @param {unknown} json + * @returns {{ text: string, language: string | null, duration: number | null, segments: Array<{ start: number, end: number, text: string }> }} + */ +export const normalizeTranscription = (json) => { + const data = json && typeof json === 'object' ? json : {} + const rawSegments = Array.isArray(data.segments) + ? data.segments + : Array.isArray(data.chunks) + ? data.chunks + : [] + const segments = rawSegments + .map((segment) => { + const timestamps = Array.isArray(segment?.timestamp) ? segment.timestamp : null + const rawStart = segment?.start ?? timestamps?.[0] + const rawEnd = segment?.end ?? timestamps?.[1] + const start = rawStart === null || rawStart === undefined ? Number.NaN : Number(rawStart) + const end = rawEnd === null || rawEnd === undefined ? Number.NaN : Number(rawEnd) + const text = typeof segment?.text === 'string' ? segment.text.trim() : '' + return { start, end, text } + }) + .filter( + (segment) => + Number.isFinite(segment.start) && Number.isFinite(segment.end) && segment.text !== '', + ) + return { + text: typeof data.text === 'string' ? data.text.trim() : segments.map((s) => s.text).join(' '), + language: typeof data.language === 'string' ? data.language : null, + duration: + typeof data.duration === 'number' && Number.isFinite(data.duration) ? data.duration : null, + segments, + } +} diff --git a/scripts/pilot-camara-speeches.mjs b/scripts/pilot-camara-speeches.mjs new file mode 100644 index 00000000..c0660ed8 --- /dev/null +++ b/scripts/pilot-camara-speeches.mjs @@ -0,0 +1,423 @@ +/** + * 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 { + CAMARA_USER_AGENT, + DEEPINFRA_TRANSCRIBE_URL, + DEEPINFRA_WHISPER_MODEL, + LEGISLATURE_RANGES, + SOLLA_DEPUTY_ID, + buildVodUrl, + matchExcerpt, + normalizeTranscription, + parseEventExcerpts, + parseVodStatus, + selectSpeechEvents, +} from './lib/camaraSpeeches.mjs' +import { dieWithLabel, ensureCachedDownload, loadCliEnv } from './lib/cli.mjs' + +loadCliEnv() + +const die = dieWithLabel('camara:pilot') + +const DEEPINFRA_COST_PER_MINUTE_USD = 0.00045 +const VOD_POLL_INTERVAL_MS = 5_000 +const VOD_POLL_MAX_ATTEMPTS = 40 +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 (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 54|55|56|57 (range de datas; ignorado se --date) + --date discursos de um único dia (sobrepõe --legislature) + --limit quantos discursos processar (default 1) + --speaker nome do orador a casar no evento (default "Jorge Solla") + --out 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 +` + +// --------------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------------- + +const camaraHeaders = { 'User-Agent': CAMARA_USER_AGENT, Accept: 'application/json' } + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +async function fetchOnce(url, { timeoutMs = 30_000, headers = camaraHeaders } = {}) { + const response = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) }) + if (!response.ok) throw new Error(`HTTP ${response.status} em ${url}`) + return response +} + +/** The Câmara API drops connections now and then — bounded retry for GETs. */ +async function getText(url, { attempts = 3, ...options } = {}) { + let lastError + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return await (await fetchOnce(url, options)).text() + } catch (error) { + lastError = error + if (attempt < attempts) await sleep(2_000) + } + } + throw lastError +} + +const getJson = async (url, options) => JSON.parse(await getText(url, options)) + +// --------------------------------------------------------------------------- +// Pipeline steps +// --------------------------------------------------------------------------- + +const speechesUrl = (deputyId, from, to) => + `https://dadosabertos.camara.leg.br/api/v2/deputados/${deputyId}/discursos` + + `?dataInicio=${from}&dataFim=${to}&itens=100&ordenarPor=dataHoraInicio&ordem=ASC` + +const eventsUrl = (date) => + `https://dadosabertos.camara.leg.br/api/v2/eventos?dataInicio=${date}&dataFim=${date}&itens=100` + +const eventPageUrl = (eventId) => `https://www.camara.leg.br/evento-legislativo/${eventId}` + +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 } +} + +/** Polls the async VOD generation until PRONTO (or gives up). */ +async function resolveVod(eventId, excerpt) { + const url = buildVodUrl(eventId, excerpt.audioId, excerpt.tMs) + let lastStatus = null + for (let attempt = 1; attempt <= VOD_POLL_MAX_ATTEMPTS; attempt += 1) { + const status = parseVodStatus(await getJson(url, { timeoutMs: 60_000 })) + lastStatus = status + if (status.state === 'PRONTO') return { url, status, attempts: attempt } + if (status.state === 'INDISPONIVEL') return { url, status, attempts: attempt } + await sleep(VOD_POLL_INTERVAL_MS) + } + return { url, status: lastStatus, attempts: VOD_POLL_MAX_ATTEMPTS } +} + +const downloadToBuffer = async (url) => { + let lastError + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + const response = await fetchOnce(url, { + timeoutMs: 120_000, + headers: { 'User-Agent': CAMARA_USER_AGENT }, + }) + return Buffer.from(await response.arrayBuffer()) + } catch (error) { + lastError = error + if (attempt < 3) await sleep(3_000) + } + } + throw lastError +} + +async function transcribe(mp4Buffer) { + const apiKey = process.env.DEEPINFRA_API_KEY + if (!apiKey) die('DEEPINFRA_API_KEY ausente — configure no .env.local ou no env do processo.') + const form = new FormData() + form.append('file', new Blob([mp4Buffer], { type: 'video/mp4' }), 'trecho.mp4') + form.append('model', DEEPINFRA_WHISPER_MODEL) + form.append('language', 'pt') + form.append('response_format', 'verbose_json') + form.append('timestamp_granularities[]', 'segment') + const startedAt = Date.now() + const response = await fetch(DEEPINFRA_TRANSCRIBE_URL, { + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}` }, + body: form, + signal: AbortSignal.timeout(600_000), + }) + if (!response.ok) { + throw new Error(`Deep Infra HTTP ${response.status}: ${(await response.text()).slice(0, 300)}`) + } + return { + transcription: normalizeTranscription(await response.json()), + elapsedMs: Date.now() - startedAt, + } +} + +// --------------------------------------------------------------------------- +// 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 transcribe(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) + } + + 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))) diff --git a/tests/unit/camaraSpeeches.unit.spec.ts b/tests/unit/camaraSpeeches.unit.spec.ts new file mode 100644 index 00000000..6603c76b --- /dev/null +++ b/tests/unit/camaraSpeeches.unit.spec.ts @@ -0,0 +1,312 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + buildVodUrl, + clockToSeconds, + matchExcerpt, + normalizeSpeakerName, + normalizeTranscription, + parseEventExcerpts, + parseVodStatus, + selectSpeechEvents, + speechDate, + speechTimeOfDaySeconds, +} from '../../scripts/lib/camaraSpeeches.mjs' + +const excerptHtml = ( + eventId: number, + { + audioId, + tMs, + speaker, + party, + startTime, + duration, + }: { + audioId: number + tMs: number + speaker: string + party: string + startTime: string + duration: string + }, +) => ` + + + + ${speaker} + ${party} + + Horário - ${startTime} + Duração - ${duration} + + + +` + +describe('normalizeSpeakerName', () => { + it('strips accents, case and punctuation', () => { + expect(normalizeSpeakerName(' Jorge Solla ')).toBe('JORGE SOLLA') + expect(normalizeSpeakerName('José Ação')).toBe('JOSE ACAO') + expect(normalizeSpeakerName('')).toBe('') + }) +}) + +describe('clock helpers', () => { + it('parses HH:MM and HH:MM:SS', () => { + expect(clockToSeconds('17:30')).toBe(63000) + expect(clockToSeconds('09:28:15')).toBe(34095) + expect(clockToSeconds('24:00')).toBeNull() + expect(clockToSeconds('abc')).toBeNull() + }) + + it('extracts time-of-day and date from a naive datetime', () => { + expect(speechTimeOfDaySeconds('2023-02-07T17:28')).toBe(62880) + expect(speechTimeOfDaySeconds('2023-02-07')).toBeNull() + expect(speechDate('2023-02-07T17:28')).toBe('2023-02-07') + }) +}) + +describe('parseEventExcerpts', () => { + it('reads the speaker anchors with card metadata', () => { + const html = [ + excerptHtml(67091, { + audioId: 558641, + tMs: 1675801808560, + speaker: 'JORGE SOLLA', + party: 'DEPUTADO (PT-BA)', + startTime: '17:30', + duration: '0h04\'07"', + }), + excerptHtml(67091, { + audioId: 558641, + tMs: 1675792829010, + speaker: 'POMPEO DE MATTOS', + party: 'DEPUTADO (PDT-RS)', + startTime: '21:22', + duration: '0h04\'03"', + }), + ].join('\n') + + expect(parseEventExcerpts(html, 67091)).toEqual([ + { + audioId: 558641, + tMs: 1675801808560, + speaker: 'JORGE SOLLA', + party: 'DEPUTADO (PT-BA)', + startTime: '17:30', + duration: '0h04\'07"', + }, + { + audioId: 558641, + tMs: 1675792829010, + speaker: 'POMPEO DE MATTOS', + party: 'DEPUTADO (PDT-RS)', + startTime: '21:22', + duration: '0h04\'03"', + }, + ]) + }) + + it('returns [] when the page has no excerpt anchors or the event id differs', () => { + const html = excerptHtml(111, { + audioId: 5, + tMs: 1000, + speaker: 'X', + party: 'Y', + startTime: '10:00', + duration: '0h01\'00"', + }) + expect(parseEventExcerpts(html, 67091)).toEqual([]) + expect(parseEventExcerpts('', 67091)).toEqual([]) + }) + + it('degrades card metadata to null when the spans are missing', () => { + const html = + 'x' + expect(parseEventExcerpts(html, 67091)).toEqual([ + { + audioId: 5, + tMs: 1000, + speaker: 'JORGE SOLLA', + party: null, + startTime: null, + duration: null, + }, + ]) + }) +}) + +describe('buildVodUrl', () => { + it('targets the async VOD endpoint with audio id and excerpt epoch', () => { + expect(buildVodUrl(67091, 558641, 1675801808560)).toBe( + 'https://www.camara.leg.br/evento-legislativo/67091/video-sob-demanda?idAudio=558641&trecho=1675801808560', + ) + }) +}) + +describe('parseVodStatus', () => { + it('normalizes the PRONTO payload', () => { + expect( + parseVodStatus({ + estado: 'PRONTO', + video: { + titulo: 'Jorge Solla', + subtitulo: 'DEPUTADO (PT-BA)', + duracao: '0:04:07', + horario: "17h30'08''", + linkParaDownload: 'https://vod.camara.leg.br/vod/download?p=abc.mp4', + linkParaReproducao: 'https://vod.camara.leg.br/videos/abc.mp4', + }, + }), + ).toEqual({ + state: 'PRONTO', + video: { + title: 'Jorge Solla', + subtitle: 'DEPUTADO (PT-BA)', + duration: '0:04:07', + clock: "17h30'08''", + downloadUrl: 'https://vod.camara.leg.br/vod/download?p=abc.mp4', + playbackUrl: 'https://vod.camara.leg.br/videos/abc.mp4', + }, + }) + }) + + it('handles GERANDO/INDISPONIVEL and malformed input', () => { + expect(parseVodStatus({ estado: 'GERANDO', video: null })).toEqual({ + state: 'GERANDO', + video: null, + }) + expect(parseVodStatus({ estado: 'INDISPONIVEL', video: null })).toEqual({ + state: 'INDISPONIVEL', + video: null, + }) + expect(parseVodStatus(null)).toEqual({ state: 'DESCONHECIDO', video: null }) + }) +}) + +describe('selectSpeechEvents', () => { + const events: Array<{ id: number; dataHoraInicio: string }> = [ + { id: 1, dataHoraInicio: '2023-02-07T15:00' }, + { id: 2, dataHoraInicio: '2023-02-07T20:00' }, + { id: 3, dataHoraInicio: '2023-02-08T15:00' }, + ] + + it('keeps only the speech date and sorts nearest-first', () => { + const selected = selectSpeechEvents(events, '2023-02-07T17:28') + expect(selected.map((e) => e.id)).toEqual([1, 2]) + }) + + it('is empty when nothing matches the date', () => { + expect(selectSpeechEvents(events, '2020-01-01T10:00')).toEqual([]) + }) +}) + +describe('matchExcerpt', () => { + const excerpts = [ + { + audioId: 1, + tMs: 100, + speaker: 'JORGE SOLLA', + party: 'DEPUTADO (PT-BA)', + startTime: '09:28', + duration: null, + }, + { + audioId: 2, + tMs: 200, + speaker: 'JORGE SOLLA', + party: 'DEPUTADO (PT-BA)', + startTime: '17:30', + duration: null, + }, + { + audioId: 3, + tMs: 300, + speaker: 'OUTRO', + party: null, + startTime: '09:00', + duration: null, + }, + ] + + it('picks the speaker excerpt nearest the speech time (accent-insensitive)', () => { + expect(matchExcerpt(excerpts, '2016-03-10T09:28', 'Jorge Solla')?.audioId).toBe(1) + expect(matchExcerpt(excerpts, '2023-02-07T17:28', 'Jorge Solla')?.audioId).toBe(2) + }) + + it('returns null when the speaker is absent or empty', () => { + expect(matchExcerpt(excerpts, '2016-03-10T09:28', 'Fulano de Tal')).toBeNull() + expect(matchExcerpt(excerpts, '2016-03-10T09:28', '')).toBeNull() + }) + + it('prefers an excerpt with a clock over one without', () => { + const withMissing = [ + { audioId: 9, tMs: 1, speaker: 'JORGE SOLLA', party: null, startTime: null, duration: null }, + ...excerpts, + ] + expect(matchExcerpt(withMissing, '2016-03-10T09:28', 'Jorge Solla')?.audioId).toBe(1) + }) +}) + +describe('normalizeTranscription', () => { + it('accepts the OpenAI-compat segments shape', () => { + expect( + normalizeTranscription({ + text: 'olá mundo', + language: 'pt', + duration: 12.5, + segments: [ + { start: 0, end: 2.7, text: ' olá' }, + { start: 2.91, end: 4.1, text: ' mundo' }, + ], + }), + ).toEqual({ + text: 'olá mundo', + language: 'pt', + duration: 12.5, + segments: [ + { start: 0, end: 2.7, text: 'olá' }, + { start: 2.91, end: 4.1, text: 'mundo' }, + ], + }) + }) + + it('accepts the native chunks shape and drops invalid entries', () => { + const result = normalizeTranscription({ + chunks: [ + { timestamp: [0, 1.5], text: 'a' }, + { timestamp: [null, null], text: 'lixo' }, + { timestamp: [2, 3], text: '' }, + ], + language: 'pt', + }) + expect(result.segments).toEqual([{ start: 0, end: 1.5, text: 'a' }]) + expect(result.text).toBe('a') + }) + + it('degrades on malformed input', () => { + expect(normalizeTranscription(null)).toEqual({ + text: '', + language: null, + duration: null, + segments: [], + }) + }) + + it('keeps a missing duration as null instead of 0', () => { + expect(normalizeTranscription({ duration: null, segments: [] }).duration).toBeNull() + expect(normalizeTranscription({ duration: '', segments: [] }).duration).toBeNull() + expect(normalizeTranscription({ duration: 12, segments: [] }).duration).toBe(12) + }) + + it('prefers segments over chunks when both are present', () => { + const result = normalizeTranscription({ + segments: [{ start: 0, end: 1, text: 'seg' }], + chunks: [{ timestamp: [2, 3], text: 'chunk' }], + }) + expect(result.segments).toEqual([{ start: 0, end: 1, text: 'seg' }]) + }) +})