feat(C215): ingestão de falas da internet no acervo — origem web, espelho privado e catálogo (Closes #1291)

This commit is contained in:
Francisco Solla 2026-09-24 02:36:34 -03:00
parent 772a5cf050
commit 8318fb5fce
45 changed files with 22185 additions and 297 deletions

2
.gitignore vendored
View file

@ -78,6 +78,8 @@ next-env.d.ts
/data/ai-fill/
# Câmara pilot media/JSON cache (pnpm camara:pilot — VOD MP4 + report, never commit)
/data/camara/
# C215 — web-speech ingestion batch cache + reports (pnpm falas-web:import)
/data/falas-web/
# Solla × Ceuci report HTML cache (scripts/build-solla-ceuci-salvador-report.mjs)
/data/solla-ceuci-salvador/

View file

@ -0,0 +1 @@
**C215 — Ingestão de falas da internet no acervo (2026-09-24):** a esteira que recebe um lote de achados da web (contrato do C218), baixa/espelha a mídia, transcreve com timestamps, classifica e grava de forma idempotente, com relatório honesto. O `speech` deixou de ser só Câmara: ganhou o discriminador `origin` (`camara` default | `web`) e os campos web nullable (`platform` youtube|instagram|radio|audio, `externalId`, `sourceUrl`, `title`, `channel`, `mirroredMedia`/`thumbnail` → nova collection privada `internetSpeechMedia`), numa migration aditiva (`20260924_042028_add_web_speech_origin`, `origin` NOT NULL DEFAULT `'camara'` — as linhas existentes ficam intactas) e a collection privada registrada no plugin S3 (`internetSpeechMedia: true`; sem isso, produção cairia no disco efêmero), com `read = canReadSpeech` (o gate do acervo, fail-closed), escrita `payloadAdminOnly` (o CLI usa o bypass justificado) e cascade no `beforeDelete` do `Speech` (mídia + capa). A identidade da fala web é namespaced (`web:<platform>:<externalId|url canônica>`, nunca colide com o `sourceKey` da Câmara) e o contrato puro vive em `src/lib/webSpeech.ts` (taxonomia + labels pt-BR, schema zod do lote `{ generatedAt?, findings[] }` com `mediaUrl` obrigatória para rádio/áudio e `publishedAt` normalizado para o `speechAt` wall-clock, canonicalização de URL que colapsa youtu.be/shorts/live/embed e reels e remove tracking, e o `webSpeechSourceKey` estável). A aquisição tem dono único por ferramenta: `src/utilities/media/ytdlp.ts` (YouTube/Instagram via `YTDLP_PATH` → PATH, probe `--version` fail-closed com hint de `pipx install yt-dlp`, `--dump-json` preenchendo lacunas de título/canal/duração/id/capa e download capado em ≤720p H.264/AAC com `--merge-output-format mp4` e `--ffmpeg-location` do binário resolvido) e `src/utilities/media/downloadToFile.ts` (rádio/áudio direto, streaming com timeout); a resolução/probe/runner genérico de binários saiu de `scripts/lib/reelFfmpeg.mjs` para `scripts/lib/mediaBinaries.mjs` (o reel re-exporta — API e unit pins intactos) e o script resolve o ffmpeg com o fallback empacotado, setando `FFMPEG_PATH`. O pipeline `src/utilities/speech/webSpeechIngest.ts` (deps injetáveis para os specs) reusa os donos existentes — `deepInfraTranscribeSegments` + chunk/merge/`searchText` do C199, `classifySpeech` (gazetteer + LLM), `withPayloadTransaction` e o `upsertSpeechBundle` estendido com os campos web e `options.req` (o caminho Câmara segue idêntico) — com a capa em best-effort fora da transação (imagem quebrada nunca perde a fala) e limpeza best-effort do artefato substituído no `--reprocess`. O CLI `pnpm falas-web:import --findings <lote.json> [--out data/falas-web] [--limit n] [--dry-run] [--reprocess]` (relatórios gitignored em `data/falas-web/reports/`) tem as guardas do C155 (`assertLocalDatabase` + `FALAS_WEB_IMPORT_CONFIRM=1` para alvo não-local/produção; `--dry-run` é read-only e não exige a flag), dedupe por `sourceKey` no lote, item inválido vira falha do item (nunca derruba o lote) e o relatório declara achados/novos/atualizados/ignorados/falhas (com estágio e motivo)/tempo/segundos e custo de ASR/custo LLM — exit 1 quando nenhum achado entrou. Blindagem obrigatória das superfícies existentes: `origin: camara` no where da lista/opções da Câmara (`speechListFilters`/`speechPageData`), na cobertura (`speechCoverage`) e no tool do Sollinha (`findSpeechExcerpts`) — a fala web nasce invisível até o C216 abrir a fonte própria; o serving/player fica no C216 (`buildPrivateMediaResponse`) e o corte no C217 (`downloadPrivateMediaToFile`), nada de rota nova aqui e nenhuma URL pública de mídia. Sem UI (Impeccable A), sem PII/`Consent` novo (canal/autor é texto de origem; menções continuam texto), sem segundo cadastro de pessoa. Verificação: unit (`webSpeech` — schema/sourceKey/canonicalização/labels; `webSpeeches` — plano/dedupe/relatório; `ytdlp` — resolução/parse/args; pins de `speechListFilters` e `speechExcerptsTool` atualizados), int (`webSpeechImport` — round-trip dos campos web, idempotência com facetas/segmentos preservados, curadoria `manual` vencendo, cascade da mídia no delete; `webSpeechIngest` — pipeline com fakes de acquirer/ffmpeg/transcriber/classificador, segundo run só atualiza metadados, `--reprocess` troca o artefato e apaga o antigo, falhas honestas por estágio sem linha fantasma, thumbnail espelhada, achado de rádio com `mediaUrl`), smoke local do CLI (`--dry-run` com inválido/duplicado e execução real com yt-dlp ausente → relatório + exit 1), `pnpm gate:fast`, `pnpm test:int` e o e2e afetado local. O run real de um lote em produção é operação do C218 (o PR não escreve no acervo de produção).

View file

@ -0,0 +1,242 @@
# Impl: C215 — Ingestão de falas da internet no acervo (descoberta, espelho e catálogo)
Status: executado
Atualizado em: 2026-09-24
Issue: #1291
Intenção: docs/plans/acervo-falas-web-ingestao.md
Appetite restante: herdado (~3–4 dias de engenharia + tempo de máquina; um outcome verificável — um lote de falas encontradas na web entra no catálogo com transcrição, facetas e mídia espelhada, reexecutável sem duplicar e com relatório honesto)
## Leitura da intenção
- **Outcome:** dado um lote de achados da web (plataforma + URL + metadados que a descoberta — C218 — já trouxe), cada fala vira registro no acervo com data, duração, transcrição com timestamps, temas/alcance/menções (mesmas facetas do "Falas da Câmara"), proveniência da classificação, origem (plataforma + URL) e canal/autor; a mídia é espelhada privada e preservada. Reexecutar não duplica nem sobrescreve curadoria manual e cada execução imprime relatório honesto (achados, novos, atualizados, ignorados, falhas, tempo e custo de ASR).
- **O que NÃO negociar (lockdowns):**
- Mídia espelhada é privada e preservada: nunca URL pública, nunca `/api/media/file`; leitura só pelo gate do acervo (`canReadSpeech`/`canReadCommunicationCatalog`, fail-closed; `advisor`/`leader` negados).
- Nenhum segundo pipeline de ASR/classificação/upsert e nenhum segundo cadastro de pessoa (menções continuam texto; sem `Contact`, sem `Consent` novo — não há PII nova).
- `sourceKey` da Câmara é identidade por contrato: a fala web nasce em namespace próprio (`web:*`) e não toca os registros existentes.
- Guardas de execução: `assertLocalDatabase` + `FALAS_WEB_IMPORT_CONFIRM=1` para alvo não-local/override; `--dry-run` sem flag.
- Não tocar feed do site público, Central de Conteúdos (C211 é dona da peça), publicação externa nem score de vaidade.
- Curadoria manual nunca sobrescrita (hook `preserveManualFacets` existente é o pino).
- **O que reavaliar (hipóteses da intenção vs. exploração):**
- A intenção deixou "ferramenta de download e destino do espelho" para a implementação: ficam **yt-dlp** (YouTube/Instagram) + download HTTP direto (rádio/áudio) e uma collection privada nova `internetSpeechMedia` — decisões D2/D4.
- "Modelo de dados" também estava em aberto: a exploração confirma que `Speech` não tem origem/plataforma/URL/canal/título/thumb/mídia espelhada e que **quatro superfícies leem `speech` sem discriminador** (`speechPageData`, `speechListFilters`, `speechCoverage`, `findSpeechExcerpts`) — sem blindagem a fala web vazaria para "Falas da Câmara" com crédito errado (D10).
- `speechPosterJob`/`speechCutJob` são by-id (VOD Câmara) e degradam sozinhos — não entram nesta fatia.
## Abordagem recomendada
```mermaid
flowchart LR
L["lote de achados (JSON)<br/>generatedAt + findings[]"] --> CLI["scripts/import-web-speeches.mjs<br/>zod + dedupe por sourceKey"]
CLI --> PLAN["plano do lote<br/>novo · atualizar · ignorar"]
PLAN -->|youtube/instagram| YTDLP["yt-dlp --dump-json<br/>YTDLP_PATH fail-closed"]
PLAN -->|radio/audio| HTTP["download HTTP do mediaUrl"]
YTDLP --> MEDIA[("internetSpeechMedia<br/>upload privado S3/disco")]
HTTP --> MEDIA
MEDIA --> FFMPEG["ffmpeg: mp3 mono 16k + chunks<br/>buildRecordingAudioFfmpegArgs"]
FFMPEG --> ASR["deepInfraTranscribeSegments<br/>merge de offsets"]
ASR --> CLS["classifySpeech<br/>gazetteer + LLM"]
CLS --> UPSERT["upsertSpeechBundle estendido<br/>tx: speech + speechSegment + searchText"]
UPSERT --> DB[("speech origin=web<br/>sourceKey web:...")]
CLI --> REPORT["relatório stdout + JSON<br/>data/falas-web/reports"]
DB -.->|"origin: camara nas consultas"| CAM["superfícies Câmara<br/>lista · opções · cobertura · Sollinha"]
DB -.-> C216["C216: lista/detalhe/player<br/>buildPrivateMediaResponse"]
DB -.-> C217["C217: cortes<br/>downloadPrivateMediaToFile"]
```
**Opções consideradas:** A) estender `speech` com discriminador de origem + collection privada própria de mídia + aquisição yt-dlp/HTTP + pipeline com dependências injetadas | B) collection nova `internetSpeech` (gêmea) | C) reusar `recordingMedia` e/ou servir a mídia no próprio C215.
**Recomendação:** A — porque preserva o contrato prod da Câmara (`sourceKey`, facetas, segmentos, busca) sem criar uma segunda cópia de todo o conhecimento (classificador, ASR, upsert, busca) e sem forçar relação polimórfica no `SpeechCut` que o C217 vai estender.
**Rejeitadas:**
- **B (collection nova)** porque duplica facets/segmentos/classificador/import/busca e obriga `SpeechCut.speech` (relação fixa a `speech`) a virar polimorfismo, acoplando o C217 sem ganho.
- **C (reusar `recordingMedia` / servir no C215)** porque `recordingMedia` é o artefato da gravação enviada (domínio e access próprios), e o serving pertence ao C216 (detalhe/player/baixar) e ao C217 (corte), que já consomem `privateMediaResponse.ts`; rota nova aqui seria trabalho jogado fora.
### Decisões de engenharia
**D1 — Modelo de dados: estender `speech` com discriminador de origem; NÃO criar collection de fala nova.**
`Decisão: A — novos campos nullable em Speech (só origin obrigatório) + sourceKey namespaced web:*; Câmara intacta.`
`Por quê: reusa facetas, segmentos, classificador, upsert, busca trigram e access já existentes; a fala web é a mesma unidade de catálogo ("mesmas facetas, mesmos filtros" — intenção).`
`Rejeitadas: B) collection nova internetSpeech porque é gêmea de facets/segmentos/classificador/import/busca e força relação polimórfica no SpeechCut (C217); C) sem migration — impossível: mídia espelhada + origem/canal exigem schema.`
- Campos novos: `origin` (select `camara` default | `web`, required, index — backfill dos existentes pelo default da migration); `platform` (select `youtube|instagram|radio|audio`, index, web-only); `externalId`; `sourceUrl`; `title`; `channel`; `mirroredMedia` (upload → `internetSpeechMedia`); `thumbnail` (upload → mesma collection).
- Reusados sem mudança: `speechAt` (data de publicação no caso web; `deriveSpeechYear` continua), `durationSeconds`, `searchText`, facets, `classifiedBy`, `speechSegment`. `legislature`/`type`/`phase`/VOD ficam nulos na origem web (já são nullable).
- `sourceKey` namespaced: `web:<platform>:<externalId>` quando houver id externo; senão `web:<platform>:<url canônica normalizada>`. Câmara permanece exatamente como está (o índice unique já existe e o namespace elimina colisão).
**D2 — Mídia privada: collection de upload nova `internetSpeechMedia`.**
`Decisão: A — collection privada própria; read = canReadSpeech; create/update/delete = payloadAdminOnly (o CLI usa overrideAccess justificado); registro explícito no plugin S3; cascade no beforeDelete do Speech.`
`Por quê: a mídia da fala web tem gate do acervo, não o gate da gravação; e o dono do ciclo de vida é o próprio Speech (a mídia não existe sem a fala).`
`Rejeitadas: reusar recordingMedia (domínio/labels/access da gravação enviada — faria o modelo mentir); usar media pública (read anônimo por contrato — vazaria mídia de terceiro).`
- Sem registro no plugin S3 (`internetSpeechMedia: true`), produção cai no disco efêmero do container — mesmo modo de falha documentado no C199.
- Cascade: estender o `beforeDelete` do `Speech` (hoje apaga `speechSegment`) para apagar `mirroredMedia`/`thumbnail` quando existirem, com o mesmo bypass justificado (cascade é do dono; roda dentro do delete autorizado do Speech).
**D3 — Servir o arquivo é do C216/C217, não daqui.**
`Decisão: A — C215 entrega só o artefato armazenado + a collection; C216 usa buildPrivateMediaResponse (detalhe/player/baixar) e C217 usa downloadPrivateMediaToFile (corte).`
`Por quê: o dono único de I/O privado já existe (privateMediaResponse.ts, C193/C199) e a superfície de leitura nasce no C216; rota nova aqui não tem consumidor.`
`Rejeitadas: rota de arquivo no C215 (sem UI/consumidor, morreria no C216); signed URLs do S3 (já rejeitado no C193: endpoint interno inalcançável do browser).`
**D4 — Aquisição: yt-dlp (YouTube/Instagram) + download HTTP direto (rádio/áudio).**
`Decisão: A — yt-dlp via YTDLP_PATH → PATH, fail-closed com hint de instalação; radio/audio exigem mediaUrl no lote; qualidade suficiente (≤720p, H.264/AAC, merge mp4) e áudio direto preservado; metadados do --dump-json preenchem lacunas; resolver de ffmpeg extraído de reelFfmpeg.mjs para módulo neutro.`
`Por quê: yt-dlp é a ferramenta que cobre as duas plataformas confirmadas no gate; o lote do C218 já traz URL e (quando houver) mídia direta; o probe/resolução de binário já existe e testado nos reels — extrair o dono é melhor que um segundo resolver.`
`Rejeitadas: gallery-dl/TikTok/Facebook/X (fora das plataformas v1); baixar em qualidade máxima/master (disco e tempo sem necessidade); transcode forçado (o ASR extrai o áudio e o player/corte consomem o arquivo como veio); segunda cópia do resolver de ffmpeg (twin).`
- `YTDLP_PATH` é o seam de teste (padrão do `FFMPEG_PATH`): os testes injetam um fake pelo env. Sem binário e sem caminho → falha fechada com hint (ex.: `pipx install yt-dlp`), nunca "segue sem baixar".
- Achado `radio`/`audio` sem `mediaUrl` → falha honesta no relatório (estágio `validation`/`download`), o lote continua.
- ffmpeg: extrair `runProcess`/`probeFfmpeg`/`resolveFfmpeg` de `scripts/lib/reelFfmpeg.mjs` para `scripts/lib/mediaBinaries.mjs`; `reelFfmpeg.mjs` re-exporta (API dos reels intacta e unit tests dos reels verdes). O script novo resolve o binário e seta `FFMPEG_PATH` resolvido quando o env não veio explícito, para que `src/utilities/media/ffmpeg.ts` (dono de execução) use o mesmo binário.
**D5 — Donos únicos reusados (proibido gêmeo).**
`Decisão: A — ASR deepInfraTranscribeSegments + chunking/merge puros de recordingTranscription.ts; classificação classifySpeech; upsertSpeechBundle ESTENDIDO com campos web opcionais; privateMediaResponse.ts; withPayloadTransaction.`
`Por quê: são os donos já testados e usados pelo Câmara/C199; a fala web tem o mesmo problema (áudio longo → chunks → segmentos com timestamps) e a mesma curadoria.`
`Rejeitadas: chamar deepInfraTranscribe sem timestamps (não entrega o aceite); portar o normalizador do CLI da Câmara para um módulo novo (já é dono em recordingTranscription.ts); escrever o upsert da web à parte (dois escritores da mesma tabela = facetas divergindo).`
- `upsertSpeechBundle` ganha os campos web **opcionais** no bundle (origin/platform/externalId/sourceUrl/title/channel/mirroredMedia/thumbnail) e `findSpeechImportState` passa a devolver o estado web (origin, platform, externalId, sourceUrl, mirroredMedia, thumbnail) para as decisões de skip/reprocess. O caminho Câmara (bundle sem os campos web) continua idêntico; o `metadataData` da Câmara não muda.
- Escrita multi-collection: a ingestão cria o upload `internetSpeechMedia` e roda o upsert **na mesma transação** (`withPayloadTransaction`), passando o `req` ao upsert; sem `req`, o upsert abre a própria transação (Câmara segue igual). Objeto S3 órfão em rollback é aceito e registrado (best-effort de limpeza no catch).
- O artefato espelhado é o input do ffmpeg: a ingestão baixa o objeto para temp com `downloadPrivateMediaToFile` (mesmo dono do job de gravação), extrai/dividi com `buildRecordingAudioFfmpegArgs` e mescla com `mergeChunkTranscriptions`/`recordingSearchText`.
**D6 — Contrato do lote (interface do C218).**
`Decisão: A — JSON { generatedAt, findings: [...] }, schema zod puro em src/lib/webSpeech.ts; finding com platform/url/mediaUrl?/externalId?/title?/channel?/publishedAt (obrigatório)/durationSeconds?/thumbnailUrl?; mediaUrl obrigatória para radio|audio; dedupe por sourceKey dentro do lote.`
`Por quê: o C218 descobre e cura; a ingestão valida — o contrato é a fronteira entre os dois e precisa ser puro (client-safe, unit-testável) e explícito sobre a obrigatoriedade de mediaUrl nas plataformas sem extrator.`
`Rejeitadas: aceitar qualquer JSON sem validação (falha silenciosa vira lixo no catálogo); exigir todos os metadados (a descoberta não garante tudo — o yt-dlp completa as lacunas).`
- O envelope é validado uma vez; cada finding é validado individualmente (`safeParse`) para que um achado inválido vire falha do achado no relatório, sem derrubar o lote (espírito do aceite do C218: falha de um não derruba o lote).
- Duplicado dentro do lote (mesmo sourceKey) = ignorado, contado no relatório.
**D7 — Idempotência.**
`Decisão: A — reexecução pula item completo (segmentos + mídia espelhada) atualizando metadados; --reprocess força re-download/re-ASR; curadoria manual nunca sobrescrita; --dry-run planeja sem rede e sem escrita (lê estado do banco); --limit.`
`Por quê: o aceite exige reexecutar sem duplicar; o padrão já é o do import da Câmara (skip por estado) e do C199 (retry reusa o que existe).`
`Rejeitadas: re-baixar/re-transcrever sempre (tempo e custo de ASR sem ganho); apagar-e-recriar o registro (perderia curadoria manual e quebraria a identidade sourceKey).`
**D8 — Relatório por execução.**
`Decisão: A — stdout + JSON em <out>/reports/<timestamp>.json (out default data/falas-web/, gitignored, padrão data/camara/): achados, novos, atualizados, ignorados (duplicados/já completos), falhas (com estágio e motivo), tempo total, segundos de ASR, custo ASR (constante DeepInfra existente) e custo LLM quando houver.`
`Por quê: falha não é maquiada e o operador precisa decidir o que reprocessar; o formato segue o CLI da Câmara (mesma família de relatórios gitignored).`
`Rejeitadas: só stdout (não auditável); gravar falha como estado do item no catálogo (a intenção cortou: item que falhou fica fora da lista e aparece no relatório).`
**D9 — Guardas de execução.**
`Decisão: A — assertLocalDatabase + FALAS_WEB_IMPORT_CONFIRM=1 para alvo não-local/override (mesmo padrão de CAMARA_IMPORT_CONFIRM); --dry-run é modo read-only e não exige a flag.`
`Por quê: a ingestão escreve no acervo real e baixa mídia de terceiro; o guard de intenção explícita é o mesmo contrato já auditado no C155/C160.`
`Rejeitadas: confiar só no host (o proxy do homeserver reescreve para 127.0.0.1 e NODE_ENV=production é o sinal honesto — o guard compartilhado já cobre); --dry-run exigindo a flag (travaria o planejamento inofensivo).`
**D10 — Blindagem das superfícies Câmara (obrigatória).**
`Decisão: A — adicionar origin: camara ao where da lista/opções (speechListFilters.ts/speechPageData.ts), ao speechCoverage.ts (SQL) e ao findSpeechExcerpts.ts; testes pinam.`
`Por quê: a fala web nasce invisível às superfícies existentes; sem isso a ingestão vaza para "Falas da Câmara" com crédito errado e o Sollinha sugeriria trechos com link de player da Câmara que não toca o arquivo. C216 abre a fonte própria e C219 cuida da paridade.`
`Rejeitadas: filtrar no client/view-model (não protege paginação/contagem/cobertura/SQL); abrir a fonte web nessas superfícies agora (é o C216, com contrato de URL próprio).`
**D11 — Nomes/arquivos.**
`Decisão: A — src/lib/webSpeech.ts (puro: plataformas+labels, schema zod do lote, webSpeechSourceKey, normalização de URL canônica, INTERNET_SPEECH_MEDIA_SLUG); src/utilities/media/ytdlp.ts (dono do binário/execução/metadados); src/utilities/speech/webSpeechIngest.ts (pipeline por achado com deps injetadas — testável); scripts/import-web-speeches.mjs + scripts/lib/webSpeeches.mjs (helpers puros do CLI); src/collections/InternetSpeechMedia.ts; script pnpm falas-web:import.`
`Por quê: cada concern cai no dono existente (lib puro, utilities/media, utilities/speech) sem módulo top-level novo em utilities/ (regra do codebase-map); o CLI mantém o padrão scripts/<verbo>-<objeto>.mjs + lib pura.`
`Rejeitadas: um único módulo gigante (não testável por camada); pipeline dentro do CLI (não reaproveitável e sem injeção de deps); nome em português para identificadores (convenção: copy pt-BR, identificadores em inglês).`
**D12 — Migration.**
`Decisão: A — pnpm migrate:create add_web_speech_origin (campos + collection + índices); nunca editar migrations existentes; HARD-STOP humano antes de criar/aplicar, mesmo no modo --auto.`
`Por quê: schema muda só por migration committed; o build aplica pendentes em produção, então a migration é o ponto de não-retorno e exige confirmação explícita.`
`Rejeitadas: push automático (push: false é invariante); editar a migration inicial/prod (história congelada); SQL manual no banco (não sobrevive a deploy).`
**D13 — Testes.**
`Decisão: A — unit de src/lib/webSpeech.ts (schema/sourceKey/URL canônica) + unit dos helpers puros do script (plano/relatório); int do upsert estendido (round-trip dos campos web, idempotência, manual preservado, mídia privada) e do pipeline com fakes (YTDLP_PATH fake, transcriber/classificador injetados, FFMPEG_PATH fake do repo); unit dos reels continua verde após a extração do resolver. Sem e2e novo (sem UI); rodar pnpm test:e2e:affected se o classifier selecionar specs do acervo.`
`Por quê: não há superfície nova; as camadas inferiores cobrem o risco real (schema, idempotência, blindagem, download).`
`Rejeitadas: e2e de script (não há harness para scripts — o padrão é unit da lib pura + int do upsert); e2e de UI (não há UI no C215).`
- Atualizar `scripts/lib/e2e-affected-manifest.mjs` (entry do acervo, ~linha 434) com `src/lib/webSpeech`, `src/collections/Speech.ts` e `src/collections/InternetSpeechMedia.ts` para não cair no smoke fallback. (`src/utilities/speech`, `src/utilities/media` e `src/lib/speech*` já estão mapeados; `src/lib/webSpeech` e as collections não casam prefixo nenhum hoje.)
### Componentes / mudanças
- **`Speech`** (`src/collections/Speech.ts`, editar): campos de origem/mídia (D1); `beforeDelete` estendido para apagar `mirroredMedia`/`thumbnail` (D2); `admin.description`/`defaultColumns` passam a citar as duas origens. Nenhum campo da Câmara muda.
- **`InternetSpeechMedia`** (`src/collections/InternetSpeechMedia.ts`, novo): upload privado; `alt` obrigatório (preenchido pelo pipeline); access D2; `admin.group: 'Comunicação'` com descrição de privacidade (forma de `RecordingMedia.ts`).
- **`src/lib/webSpeech.ts`** (novo, puro): `WEB_SPEECH_PLATFORMS` + labels pt-BR ("YouTube", "Instagram", "Rádio", "Áudio"), schema zod do envelope/finding, `webSpeechSourceKey`, `canonicalWebSpeechUrl`, `INTERNET_SPEECH_MEDIA_SLUG`.
- **`src/utilities/media/ytdlp.ts`** (novo): resolução fail-closed do binário (`YTDLP_PATH` → PATH), execução e parse do `--dump-json` (título/canal/data/duração/id externo/thumb), args de qualidade ≤720p H.264/AAC `--merge-output-format mp4`.
- **`src/utilities/speech/webSpeechIngest.ts`** (novo): pipeline por achado com deps injetadas (downloader, transcriber, classifier, runner de ffmpeg) — reusa os donos do D5 e devolve o resultado por achado (novo/atualizado/ignorado/falha com estágio).
- **`src/utilities/speech/speechImport.ts`** (editar): bundle + state com os campos web opcionais; `upsertSpeechBundle` aceita `req` de transação aberta (Câmara sem `req` segue abrindo a própria).
- **`scripts/lib/mediaBinaries.mjs`** (novo): extração de `runProcess`/`probeFfmpeg`/`resolveFfmpeg`; `scripts/lib/reelFfmpeg.mjs` re-exporta sem mudar a API.
- **`scripts/lib/webSpeeches.mjs`** (novo, puro): plano do lote (dedupe/ordem/skip por estado), agregação e linhas do relatório.
- **`scripts/import-web-speeches.mjs`** (novo): CLI (`--findings <arquivo>` — interface do C218 —, `--out`, `--limit`, `--dry-run`, `--reprocess`), guardas D9, caches de download no padrão `ensureCachedDownload`, relatório D8.
- **`package.json`** (editar): script `falas-web:import` (padrão do `camara:import`).
- **`.gitignore`** (editar): `data/falas-web/`.
- **`payload.config.ts`** (editar): registrar `InternetSpeechMedia` na lista de collections e `internetSpeechMedia: true` no plugin S3.
- **`scripts/lib/e2e-affected-manifest.mjs`** (editar): prefixos do D13.
- **Migration:** `pnpm migrate:create add_web_speech_origin` — enum `origin`/`platform`, colunas em `speech` (`origin` NOT NULL DEFAULT `'camara'`, demais nullable), tabela `internet_speech_media` + FKs + índices (`origin`, `platform`) e as rels de locked documents. Revisar o SQL gerado; **não** criar índice trigram novo (o `speech_search_text_trgm_idx` da C154 cobre as duas origens). **HARD-STOP humano (mesmo em `--auto`), local-only** antes de criar/aplicar; nunca editar migrations antigas.
- **Access / Consent:** `read = canReadSpeech`; `create/update/delete = payloadAdminOnly`; o CLI/pipeline usam `overrideAccess: true` com comentário justificado (ator confiável sem sessão, padrão do import da Câmara). Sem `Consent` novo (sem PII/opt-in; menções continuam texto).
- **UI:** N/A — sem UI (C216 é dono da tela; Impeccable A no plano de intenção).
### Dados → forma (se aplicável)
- **N/A por decisão da intenção** (`acervo-falas-web-ingestao.md:35`): aqui nenhum agregado é apresentado; o relatório de execução é insumo operacional de quem roda o CLI, não superfície de usuário — sem dashboard de vaidade nem score. Nada a apresentar; nenhuma forma nova.
## Fases verificáveis
1. **Tracer / schema + server** (~1 dia).
- `src/lib/webSpeech.ts`; `InternetSpeechMedia`; campos/`beforeDelete` no `Speech`; registro em `payload.config.ts`; `pnpm generate:types`; `upsertSpeechBundle`/`findSpeechImportState` estendidos.
- Unit: schema do lote (envelope/finding, `mediaUrl` obrigatória em radio|audio), `webSpeechSourceKey`, URL canônica, labels.
- Int: round-trip dos campos web (create/update), idempotência do upsert, `manual` preservado, cascade da mídia no delete do Speech.
- **Migration `add_web_speech_origin` — HARD-STOP humano, local-only** (confirmação explícita antes de criar/aplicar).
2. **Aquisição + pipeline** (~1 dia).
- `scripts/lib/mediaBinaries.mjs` (extração; unit dos reels verde); `src/utilities/media/ytdlp.ts`; `src/utilities/speech/webSpeechIngest.ts`.
- Unit: resolução fail-closed do yt-dlp + parse do metadata (fake via `YTDLP_PATH`); args de qualidade; plano do pipeline.
- Int: pipeline com fakes (`YTDLP_PATH` fake, transcriber/classificador injetados, `FFMPEG_PATH` fake) — happy path com espelho + segmentos + searchText; falha de download/ASR → falha no relatório sem row fantasma; `--reprocess` re-baixa/re-transcreve; segunda execução não re-baixa nem re-transcreve.
3. **CLI + relatório** (~0,75 dia).
- `scripts/lib/webSpeeches.mjs` + `scripts/import-web-speeches.mjs`; `package.json`; `.gitignore`.
- Unit: dedupe por sourceKey, plano (novo/atualizar/ignorar), agregação do relatório, falhas por estágio.
- Int/manual: `--dry-run` sem rede e sem escrita (lê estado do banco); guardas (`FALAS_WEB_IMPORT_CONFIRM`/`assertLocalDatabase`); JSON em `<out>/reports/<timestamp>.json`.
4. **Blindagem Câmara** (~0,25 dia).
- `origin: camara` em `buildSpeechFacetWhere` (cobre `buildSpeechListWhere` e `buildSpeechListWhereIncludingCutOrigins`), `loadSpeechFilterOptions`, `speechCoverage.ts` (SQL) e `findSpeechExcerpts.ts`.
- Unit: where/coverage com o discriminador; int: fala web não aparece na lista, nas opções, na cobertura nem no Sollinha; cobertura da Câmara inalterada.
5. **Testes + gates** (~0,5 dia).
- Manifest e2e; `pnpm gate:fast`; `pnpm test` (unit + int); `pnpm test:e2e:affected` (se o classifier selecionar specs do acervo); `pnpm push`.
Quota: ~3,5 dias eng + tempo de máquina (download/ASR), dentro do appetite herdado. Se apertar, o corte é o enriquecimento de metadados pelo `--dump-json` (o lote já traz os campos) — nunca as garantias: idempotência, guardas, relatório e blindagem.
## Rabbit holes / Não escopo (engenharia)
- Descoberta/crawler/monitoramento e agendamento (C218); varredura de perfis.
- TikTok/Facebook/X, gallery-dl, login/cookies para conteúdo privado.
- Segundo pipeline de ASR/classificação/upsert; segundo serving de mídia (C216/C217); segundo resolver de ffmpeg.
- Publicar mídia/URL pública; reusar `media` pública; reusar `recordingMedia`; signed URLs.
- Transcode/qualidade máxima/masters; geração de thumbnail (só armazena a da origem quando o lote/yt-dlp trouxer; C216 mostra placeholder sem ela); ffprobe.
- UI (C216), cortes (C217), paridade de filtros (C219), diarização, edição de transcrição, publicação.
- Merge multi-fonte/busca global; item de nav novo; tocar o feed do site ou a Central de Conteúdos (C211).
- Dedupe entre plataformas (mesma fala no YouTube e no Instagram) — identidade é por origem; cross-platform é item próprio.
- Limpeza de mídia órfã fora do cascade do Speech; retenção/expurgo.
- Alterar `sourceKey`/dados da Câmara; editar migrations antigas; `push` de schema.
## Riscos e mitigação
- **yt-dlp ausente ou quebrado por mudança de plataforma.** Fail-closed com hint de instalação; falha por achado no relatório com estágio; rerodar após atualizar; testes injetam fake por `YTDLP_PATH`.
- **Instagram bloqueia/expira sem login.** Falha honesta no relatório; fluxo de cookies/login fora da v1 (declarado); o lote pode trazer `mediaUrl` quando a descoberta tiver o arquivo direto.
- **Custo/tempo de ASR em horas de vídeo.** `--limit` e `--dry-run`; relatório com segundos e custo; o cap de qualidade evita baixar master (o ASR extrai áudio, não precisa de 4K).
- **Disco efêmero em produção (`internetSpeechMedia: true` esquecido).** Registro no plugin no mesmo PR + checklist de aceite; `resolveS3StorageEnv` falha fechado em config S3 parcial; dev/test em disco local.
- **Vazamento da fala web para as superfícies Câmara (crédito errado).** D10 + pins unit/int; a fala web nasce invisível à lista/opções/cobertura/Sollinha.
- **Duplicata por URL não canônica (tracking params, host, barra final).** `canonicalWebSpeechUrl` unit-pinada + dedupe no lote + `sourceKey` namespaced (índice unique já existente).
- **Migração em prod (coluna NOT NULL).** `origin` nasce com default `'camara'` na migration — linhas existentes cobertas; HARD-STOP humano; nunca editar migrations antigas; o deploy aplica pendentes.
- **Regressão da Câmara pelo upsert estendido.** Campos web opcionais; `metadataData` da Câmara intocado; os int existentes (`speechImport.int.spec.ts`) são o pino.
- **Curadoria manual sobrescrita em `--reprocess`.** Hook `preserveManualFacets` é o pino; int cobre o re-run sobre registro `manual`.
- **PR high-risk (migration) roda só o conjunto curado de e2e**, que não inclui `campaignSpeechAcervo` — a blindagem e o pipeline ficam pinados por unit/int; incluir o spec no curado é mudança deliberada de pin, a decidir se a cobertura e2e for exigida.
- **Objeto S3 órfão em rollback da transação de upload+upsert.** Best-effort de limpeza no catch; órfão é inofensivo (bucket privado) e reexecutar reusa/sobrescreve.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: cada fala com data/duração/transcrição com timestamps/facetas/menções/proveniência/origem/canal; mídia espelhada privada e preservada; reexecução não duplica nem sobrescreve curadoria; relatório honesto (achados/novos/atualizados/ignorados/falhas/tempo/custo ASR); sem espelho público, sem segundo cadastro, sem publicação.
- [ ] Invariantes AGENTS/engineering-standards: `overrideAccess: false` + `user` onde há ator (leitura C216/C217 nasce no C216; aqui o CLI é ator confiável com bypass justificado); escrita multi-collection em transação (`withPayloadTransaction` + `req`); sem `Consent`/PII novos; `sourceKey` da Câmara intacto; sem rota/URL pública nova; migração nova sem editar antigas; copy pt-BR / identificadores em inglês; `pnpm gate:fast` e `pnpm push`.
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam: schema/sourceKey/URL canônica; upsert estendido (round-trip, idempotência, `manual`, mídia privada); pipeline com fakes; cascade da mídia; blindagem Câmara (lista/opções/cobertura/Sollinha); reels unit verde após a extração do resolver.
- [ ] Migration `add_web_speech_origin` com HARD-STOP humano (mesmo em `--auto`), local-only, revisada antes de aplicar.
- [ ] `internetSpeechMedia: true` no plugin S3 (sem isso, produção cai no disco efêmero).
- [ ] Manifest e2e atualizado (`src/lib/webSpeech`, `src/collections/Speech.ts`, `src/collections/InternetSpeechMedia.ts`) para não cair no smoke fallback.
## Execução — desvios do plano (2026-09-24)
- **Thumbnail fora da transação principal (D5 refinado).** O upload da capa roda em best-effort **antes** da transação (media + upsert) e é limpo se a transação falhar: uma imagem quebrada (o sharp do Payload reprocessa imagens) nunca derruba a fala — o teste int cobre o caminho com um JPEG real. No `--reprocess`, se a capa nova falhar, a antiga é preservada (só é apagada quando há substituta).
- **`publishedAt` é obrigatório no schema** (o aceite exige data por fala) e um valor com fuso explícito é convertido para `America/Bahia` antes de virar `speechAt`; data impossível falha o achado em vez de virar mentira no card.
- **Pós-simplify:** o `defaultRun` do `reelFfmpeg` (referência órfã após a extração) foi reconectado; `downloadUrlToFile` ganhou teto de bytes (4 GiB); `databaseTarget`/`assertWriteConfirm` viraram helpers do `scripts/lib/cli.mjs` usados pelos dois CLIs; o predicado de completude foi para `src/lib/webSpeech.ts`; a constante de custo do ASR passou a ter dono único (o CLI da Câmara re-exporta a do app).
- **`ytDlpBinary` não usa o resolver de `mediaBinaries.mjs`.** Não existe binário empacotado de yt-dlp; o dono `src/utilities/media/ytdlp.ts` resolve `YTDLP_PATH` → PATH com probe `--version` fail-closed. O resolver extraído de `reelFfmpeg.mjs` continua sendo usado só para o ffmpeg (o script resolve com fallback empacotado e seta `FFMPEG_PATH`).
- **`upsertSpeechBundle` ganhou `options.req`** e `origin` no bundle (default `'camara'` no create) em vez de um writer novo — o caminho Câmara não mudou.
- **Testes existentes pinados:** `speechListFilters.unit.spec.ts` e `speechExcerptsTool.unit.spec.ts` passam a esperar `origin: camara`; os creates de `speech` em int/e2e ganharam `origin: 'camara'` (o campo é required no tipo gerado).
- **`scripts/lib/mediaBinaries.mjs` e `scripts/lib/webSpeeches.mjs` entraram no `SCRIPTS_SPEC_PINNED`** (invariante OPS119++ de blast radius).
- **Smoke local:** `pnpm falas-web:import --findings … --dry-run` (plano com inválido/duplicado) e a execução real com yt-dlp ausente (falha honesta no relatório + exit 1) rodados contra o banco local do worktree. O run real com mídia é do operador (C218) — a esteira não é executada em produção por este PR.
## Self-score de decision-quality
**4,5/5.**
- **Decisões caras com rejeitadas (1):** as 13 decisões registradas têm opções e rejeições ancoradas em file:line/contrato (`sourceKey` unique da Câmara, relação fixa `SpeechCut.speech`, `preserveManualFacets`, `reelFfmpeg` já testado, `resolveS3StorageEnv` fail-closed, `push: false`, ausência de yt-dlp no repo).
- **Cabe no appetite (2):** schema+server → aquisição/pipeline → CLI/relatório → blindagem → testes/gates ≈3,5 dias eng + tempo de máquina, dentro dos ~3–4 dias herdados; tracer cedo no schema/upsert antes do pipeline.
- **Rabbit holes nomeados (3):** descoberta/crawler, plataformas fora da v1, segundo pipeline/serving, transcode/master, merge multi-fonte, dedupe cross-platform, limpeza de órfãos.
- **Depth check (4):** reusa `upsertSpeechBundle`, `deepInfraTranscribeSegments`, `classifySpeech`, `recordingTranscription`, `privateMediaResponse`, `withPayloadTransaction`, `ensureCachedDownload`, `assertLocalDatabase`; extrai o resolver de binário para um dono neutro em vez de criar um segundo; nada de módulo top-level novo em `src/utilities/`.
- **Outcome preservado (5):** a engenharia não reescreveu o produto — a fala web entra no catálogo com as mesmas facetas, privada, idempotente e com relatório honesto; o que ficou de fora (tela, cortes, descoberta) é exatamente o que a intenção cortou.
Perde 0,5 porque duas pontas ficam condicionadas a confirmação humana/execução fora do código: o HARD-STOP da migration (deliberado) e a cobertura e2e do PR high-risk (conjunto curado não inclui `campaignSpeechAcervo`; a blindagem fica pinada por unit/int) — o plano declara isso em vez de fingir que está coberto.

View file

@ -89,6 +89,7 @@
"images:fill": "node scripts/fill-image.mjs",
"camara:pilot": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/pilot-camara-speeches.mjs",
"camara:import": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-camara-speeches.mjs",
"falas-web:import": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-web-speeches.mjs",
"typecheck": "tsc --noEmit",
"typecheck:tsc6": "tsc6 --noEmit",
"prepare": "husky",

View file

@ -78,12 +78,11 @@ import {
speechSourceKey,
} from './lib/camaraSpeeches.mjs'
import {
databaseHostname,
assertWriteConfirm,
databaseTarget,
dieWithLabel,
ensureCachedDownload,
isTruthyEnv,
loadCliEnv,
requiresWriteConfirm,
} from './lib/cli.mjs'
loadCliEnv()
@ -1149,14 +1148,6 @@ const runRepairLinks = async (payload, options) => {
}
}
const databaseTarget = () => {
const url = process.env.DATABASE_URL
const host = databaseHostname(url)
if (host === null) return '(DATABASE_URL ausente ou inválida)'
// `databaseHostname` only returns non-null when `new URL` already parsed.
return `${host}${new URL(url).pathname}`
}
const modeLabel = (options) => {
if (options.coverage) return 'coverage (read-only)'
if (options.verifyLinks !== null) return `verify-links n=${options.verifyLinks} (read-only)`
@ -1168,21 +1159,6 @@ const modeLabel = (options) => {
return `legislature ${options.legislature}`
}
/**
* C155 write guard: the homeserver env file sets `NODE_ENV=production` and the
* runbook rewrites the DB host to the local proxy, so the host check alone
* cannot see production — the explicit flag is what makes the write deliberate.
*/
const assertWriteAllowed = () => {
if (!requiresWriteConfirm()) return
if (isTruthyEnv(process.env[WRITE_CONFIRM_FLAG])) return
die(
`alvo de escrita não-local/produção detectado (${databaseTarget()}).\n` +
`Confirme a intenção com:\n ${WRITE_CONFIRM_FLAG}=1 pnpm camara:import …\n` +
`Runbook: docs/ops/teqo-1313-deploy.md §C155/§C160.`,
)
}
async function main() {
const options = parseArgs(process.argv.slice(2))
if (options.help) {
@ -1197,7 +1173,12 @@ async function main() {
} 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.
assertWriteAllowed()
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).',

View file

@ -0,0 +1,240 @@
/**
* C215 — ingests a batch of web-speech findings (the discovery output of C218)
* into the acervo: validate the batch → acquire the media (yt-dlp for
* YouTube/Instagram, direct HTTP for radio/audio) → transcribe with timestamps
* → classify → mirror the artifact privately → upsert idempotently. Re-running
* never duplicates and never overwrites a manual facet curation; every run
* prints and writes an honest report (found/new/updated/skipped/failures,
* time and ASR cost).
*
* The report lands under `data/falas-web/reports/` (gitignored); the mirrored
* media goes to the private collection (temp files are removed after each
* finding). The local-DB guard refuses a non-local DATABASE_URL, and any write
* run targeting production (or a remote/override DB) additionally requires
* `FALAS_WEB_IMPORT_CONFIRM=1`.
*
* Usage:
* pnpm falas-web:import --findings data/falas-web/lote.json
* pnpm falas-web:import --findings lote.json --dry-run
* pnpm falas-web:import --findings lote.json --limit 3
* pnpm falas-web:import --findings lote.json --reprocess
* FALAS_WEB_IMPORT_CONFIRM=1 pnpm falas-web:import --findings lote.json
*/
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 { assertWriteConfirm, databaseTarget, dieWithLabel, loadCliEnv } from './lib/cli.mjs'
import { resolveFfmpeg } from './lib/mediaBinaries.mjs'
import {
formatWebSpeechReport,
planWebSpeechBatch,
summarizePlannedActions,
summarizeWebSpeechResults,
webSpeechPlannedAction,
webSpeechReportStamp,
} from './lib/webSpeeches.mjs'
loadCliEnv()
const die = dieWithLabel('falas-web:import')
const config = (await import('../src/payload.config.ts')).default
const { parseWebSpeechBatch } = await import('../src/lib/webSpeech.ts')
const { findSpeechImportState } = await import('../src/utilities/speech/speechImport.ts')
const { ingestWebSpeech } = await import('../src/utilities/speech/webSpeechIngest.ts')
const WRITE_CONFIRM_FLAG = 'FALAS_WEB_IMPORT_CONFIRM'
const DEFAULT_OUT_DIR = 'data/falas-web'
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const parseArgs = (argv) => {
const options = {
findings: null,
out: DEFAULT_OUT_DIR,
limit: null,
dryRun: false,
reprocess: 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 === '--findings') options.findings = value()
else if (arg === '--out') options.out = value()
else if (arg === '--limit') options.limit = Number(value())
else if (arg === '--dry-run') options.dryRun = true
else if (arg === '--reprocess') options.reprocess = true
else die(`argumento desconhecido: ${arg}`)
}
if (options.help) return options
if (!options.findings) die('informe --findings <arquivo.json> (veja --help).')
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 falas-web:import --findings <arquivo.json> [opções]
O lote é o JSON da descoberta (C218): { generatedAt?, findings: [...] }.
Cada achado: platform (youtube|instagram|radio|audio), url, publishedAt e —
para radio|audio — mediaUrl (arquivo direto). externalId/title/channel/
durationSeconds/thumbnailUrl são opcionais e o yt-dlp completa lacunas.
Opções:
--findings <arquivo> lote de achados (obrigatório)
--out <dir> diretório dos relatórios (default data/falas-web)
--limit <n> processa no máximo n achados
--dry-run planeja o lote (lê o catálogo) sem baixar nem escrever
--reprocess refaz download/transcrição mesmo com item completo
--help esta ajuda
Sucesso parcial sai com código 0 (as falhas ficam no relatório); código 1
quando nenhum achado entrou no catálogo.
Escrita em alvo não-local ou com NODE_ENV=production exige
${WRITE_CONFIRM_FLAG}=1. O binário do yt-dlp vem de YTDLP_PATH ou do PATH;
instale com \`pipx install yt-dlp\` (o ffmpeg empacotado é usado se não houver
no PATH).
`
const writeReport = async (options, report) => {
const reportPath = join(
options.out,
'reports',
`falas-web-${webSpeechReportStamp(report.runAt)}.json`,
)
await mkdir(dirname(reportPath), { recursive: true })
await writeFile(reportPath, JSON.stringify(report, null, 2))
return reportPath
}
/** The packaged ffmpeg fallback keeps the operator flow working without a system binary. */
const ensureFfmpeg = async () => {
if (process.env.FFMPEG_PATH?.trim()) return
const resolved = await resolveFfmpeg({
audio: true,
filters: [],
requirementsLabel: 'libmp3lame para extrair o áudio',
})
process.env.FFMPEG_PATH = resolved.bin
console.log(`[falas-web:import] ffmpeg: ${resolved.bin} (${resolved.source})`)
}
async function main() {
const options = parseArgs(process.argv.slice(2))
if (options.help) {
console.log(HELP)
process.exit(0)
}
if (options.dryRun) {
if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.')
} else {
assertWriteConfirm({
label: 'falas-web:import',
flag: WRITE_CONFIRM_FLAG,
command: 'pnpm falas-web:import',
})
assertLocalDatabase(
'falas-web:import',
`A ingestão escreve no acervo; produção exige ${WRITE_CONFIRM_FLAG}=1.`,
)
}
console.log(
`[falas-web:import] alvo: ${databaseTarget()} | modo: ${options.dryRun ? 'dry-run (read-only)' : options.reprocess ? 'import (--reprocess)' : 'import'}`,
)
const envelope = parseWebSpeechBatch(JSON.parse(await readFile(options.findings, 'utf8')))
if (!envelope.ok) die(envelope.error)
const plan = planWebSpeechBatch(envelope.batch.findings)
const entries = options.limit ? plan.entries.slice(0, options.limit) : plan.entries
if (envelope.batch.findings.length > 0 && entries.length === 0) {
die('nenhum achado válido no lote — corrija o JSON (inválidos/duplicados no relatório).')
}
const runAt = new Date().toISOString()
const payload = await getPayload({ config })
const startedAt = Date.now()
if (options.dryRun) {
const planned = []
for (const entry of entries) {
const state = await findSpeechImportState(payload, entry.sourceKey)
planned.push({
sourceKey: entry.sourceKey,
platform: entry.finding.platform,
action: webSpeechPlannedAction(state, options.reprocess),
})
}
const report = {
runAt,
mode: 'dry-run',
batchGeneratedAt: envelope.batch.generatedAt,
findings: envelope.batch.findings.length,
planned: entries.length,
invalid: plan.invalid,
duplicates: plan.duplicates,
plannedActions: summarizePlannedActions(planned),
plannedEntries: planned,
durationMs: Date.now() - startedAt,
}
const reportPath = await writeReport(options, report)
for (const line of formatWebSpeechReport({ ...report, failures: [] })) console.log(line)
console.log(`\n[falas-web:import] relatório JSON: ${reportPath}`)
process.exit(0)
}
await ensureFfmpeg()
const results = []
for (const entry of entries) {
console.log(`[falas-web:import] ${entry.sourceKey}`)
const result = await ingestWebSpeech(payload, entry.finding, { reprocess: options.reprocess })
results.push(result)
console.log(
` → ${result.status}${result.stage ? ` (${result.stage})` : ''}${result.error ? `: ${result.error}` : ''}`,
)
}
const summary = summarizeWebSpeechResults(results)
const report = {
runAt,
mode: 'import',
batchGeneratedAt: envelope.batch.generatedAt,
findings: envelope.batch.findings.length,
planned: entries.length,
invalid: plan.invalid,
duplicates: plan.duplicates,
reprocess: options.reprocess,
...summary,
durationMs: Date.now() - startedAt,
}
const reportPath = await writeReport(options, report)
for (const line of formatWebSpeechReport(report)) console.log(line)
console.log(`\n[falas-web:import] relatório JSON: ${reportPath}`)
const succeeded = summary.created + summary.updated + summary.skipped
if (summary.failed > 0 && succeeded === 0) {
die('nenhum achado entrou no catálogo — falhas listadas no relatório.')
}
process.exit(0)
}
main().catch((error) => {
console.error(error)
die(error?.message || String(error))
})

View file

@ -216,7 +216,12 @@ export async function resolveOfficialPdfUrl(legacyUrl, { timeoutMs = 180_000, at
throw lastError
}
export const DEEPINFRA_COST_PER_MINUTE_USD = 0.00045
/**
* Provider price (~US$ per audio minute). The app-side owner is
* `src/utilities/ai/deepInfraTranscribe.ts` (C215) — re-exported here so the
* Câmara scripts and the web ingestion always report the same number.
*/
export { DEEPINFRA_TRANSCRIBE_COST_PER_MINUTE_USD as DEEPINFRA_COST_PER_MINUTE_USD } from '../../src/utilities/ai/deepInfraTranscribe.ts'
/**
* Sends the excerpt MP4 (video/mp4 is accepted; no ffmpeg) to Deep Infra's

View file

@ -194,6 +194,33 @@ export const requiresWriteConfirm = ({
allowRemoteDb = isRemoteDbOverrideSet(),
} = {}) => nodeEnv === 'production' || allowRemoteDb || !isLocalDatabaseUrl(databaseUrl)
/** Human label of the write target for the guard messages (`host/name`). */
export const databaseTarget = () => {
const url = process.env.DATABASE_URL
const host = databaseHostname(url)
if (host === null) return '(DATABASE_URL ausente ou inválida)'
return `${host}${new URL(url).pathname}`
}
/**
* C155/C215 write guard shared by the import CLIs: a non-local/production
* target (or the `ALLOW_REMOTE_DB` escape) refuses the run unless the command's
* own intent flag is set. The homeserver env sets `NODE_ENV=production` and the
* proxy rewrites the DB host, so the explicit flag is what makes the write
* deliberate.
*
* @param {{ label: string, flag: string, command: string, hint?: string }} options
*/
export const assertWriteConfirm = ({ label, flag, command, hint = '' }) => {
if (!requiresWriteConfirm()) return
if (isTruthyEnv(process.env[flag])) return
dieWithLabel(label)(
`alvo de escrita não-local/produção detectado (${databaseTarget()}).\n` +
`Confirme a intenção com:\n ${flag}=1 ${command} …` +
(hint ? `\n${hint}` : ''),
)
}
/**
* Port for `next dev` (OPS40). Next's CLI resolves its port via commander's
* `.env('PORT')` BEFORE `@next/env` loads `.env.local`, so a `PORT` written by

View file

@ -454,6 +454,11 @@ export const E2E_AFFECTED_MANIFEST = [
'src/utilities/privateMedia',
'src/utilities/media',
'src/lib/speech',
// C215 — the web-speech contract, its collection and the private mirror
// collection drive the same acervo vertical.
'src/lib/webSpeech',
'src/collections/Speech.ts',
'src/collections/InternetSpeechMedia.ts',
'src/lib/reel',
'src/lib/recording',
'src/lib/contentPiece',

View file

@ -0,0 +1,149 @@
/**
* Generic external-binary plumbing of the CLI tools: a process runner that
* never throws, a fail-closed capability probe and the ffmpeg resolution
* (`FFMPEG_PATH` → `ffmpeg` on PATH → the registry-packaged
* `@ffmpeg-installer/ffmpeg` binary). Extracted from the reel composer (C196)
* so the web-speech ingestion (C215) reuses the same owner instead of a second
* resolver.
*
* Resolution order (see `docs/plans/reels-tutoriais-impl.md`, Decisão 2): the
* packed fallback keeps the non-technical operator flow working on a
* workstation without a system ffmpeg; the homeserver Docker build installs it
* from npm (never from GitHub releases, which the homeserver cannot reach — see
* the sharp override note in `pnpm-workspace.yaml`).
*/
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
const DEFAULT_MAX_BUFFER_BYTES = 16 * 1024 * 1024
/**
* @typedef {{ ok: boolean, stdout: string, stderr: string, error?: { code?: string } }} RunResult
* @typedef {(bin: string, args: string[]) => Promise<RunResult>} RunFn
*/
/**
* Runs a process and returns the exit outcome instead of throwing.
*
* @type {(bin: string, args: string[]) => Promise<RunResult>}
*/
export const runProcess = async (bin, args) => {
try {
const { stdout, stderr } = await execFileAsync(bin, args, {
maxBuffer: DEFAULT_MAX_BUFFER_BYTES,
})
return { ok: true, stdout, stderr }
} catch (error) {
return { ok: false, stdout: error.stdout ?? '', stderr: error.stderr ?? '', error }
}
}
const defaultRun = runProcess
const versionLine = (stdout) =>
stdout.split('\n').find((line) => line.startsWith('ffmpeg version')) ?? ''
/**
* Capability probe: the binary must run and ship every required encoder and
* filter. Missing capabilities are reported, never assumed.
*
* @param {string} bin
* @param {{ run?: RunFn, audio?: boolean, encoders?: string[], filters?: string[] }} [options]
* @returns {Promise<{ ok: true, bin: string, version: string } | { ok: false, bin: string, reason: string }>}
*/
export const probeFfmpeg = async (
bin,
{ run = defaultRun, audio = false, encoders = [], filters = [] } = {},
) => {
const version = await run(bin, ['-hide_banner', '-version'])
if (!version.ok) {
return { ok: false, bin, reason: `não executou (${version.error?.code ?? 'erro'})` }
}
const requiredEncoders = [...encoders, ...(audio ? ['libmp3lame'] : [])]
if (requiredEncoders.length > 0) {
const listed = await run(bin, ['-hide_banner', '-encoders'])
const missing = requiredEncoders.filter(
(encoder) => !listed.ok || !new RegExp(`\\b${encoder}\\b`).test(listed.stdout),
)
if (missing.length > 0) return { ok: false, bin, reason: `sem encoder ${missing.join(', ')}` }
}
if (filters.length > 0) {
const listed = await run(bin, ['-hide_banner', '-filters'])
if (!listed.ok) return { ok: false, bin, reason: 'não listou filtros' }
const missing = filters.filter((filter) => !new RegExp(`\\b${filter}\\b`).test(listed.stdout))
if (missing.length > 0)
return { ok: false, bin, reason: `sem filtro(s): ${missing.join(', ')}` }
}
return { ok: true, bin, version: versionLine(version.stdout) }
}
const FFMPEG_INSTALL_HINT =
'Defina FFMPEG_PATH, instale o ffmpeg (ex.: `sudo apt install ffmpeg`) ou rode `pnpm install` para o binário empacotado.'
/**
* Resolves the first candidate that passes the probe. Explicit `FFMPEG_PATH` is strict.
*
* @param {{ env?: Record<string, string | undefined>, run?: RunFn, installer?: { path?: string } | null, audio?: boolean, encoders?: string[], filters?: string[], requirementsLabel?: string, installHint?: string }} [options]
* @returns {Promise<{ ok: true, bin: string, version: string, source: string }>}
*/
export const resolveFfmpeg = async ({
env = process.env,
run = defaultRun,
installer,
audio = false,
encoders = [],
filters = [],
requirementsLabel = '',
installHint = FFMPEG_INSTALL_HINT,
} = {}) => {
let packaged = installer
if (packaged === undefined) {
try {
packaged = (await import('@ffmpeg-installer/ffmpeg')).default
} catch {
// The packaged binary throws on import when its platform package is
// missing; the probe below decides, never the import.
packaged = null
}
}
const candidates = []
const explicit = env.FFMPEG_PATH?.trim()
if (explicit) candidates.push({ bin: explicit, source: 'FFMPEG_PATH', strict: true })
candidates.push({ bin: 'ffmpeg', source: 'PATH', strict: false })
if (packaged?.path) candidates.push({ bin: packaged.path, source: 'empacotado', strict: false })
const failures = []
for (const candidate of candidates) {
const probe = await probeFfmpeg(candidate.bin, { run, audio, encoders, filters })
if (probe.ok) {
return { ok: true, bin: probe.bin, version: probe.version, source: candidate.source }
}
failures.push(`${candidate.source}: ${probe.reason}`)
if (candidate.strict) break
}
const suffix = requirementsLabel ? ` (${requirementsLabel})` : ''
throw new Error(`Nenhum ffmpeg utilizável${suffix}. ${failures.join('; ')}. ${installHint}`)
}
/**
* Runs a process and fails with the stderr tail — never swallows the exit
* code. Shared by the CLI tools (ffmpeg and edge-tts).
*
* @param {string} bin
* @param {string[]} args
* @param {{ run?: RunFn, label?: string }} [options]
*/
export const runTool = async (bin, args, { run = defaultRun, label = 'processo' } = {}) => {
const result = await run(bin, args)
if (!result.ok) {
const tail = (result.stderr || result.error?.message || '').trim().slice(-400)
throw new Error(`${label} falhou${tail ? `: ${tail}` : ''}`)
}
return result
}

View file

@ -1,29 +1,24 @@
/**
* C196 — ffmpeg plumbing of the reel composer: binary resolution with a
* fail-closed capability probe, the per-scene clip command (zoompan + burned
* caption overlays) and the concat that stitches the scenes into `reel.mp4`.
*
* Resolution order (see `docs/plans/reels-tutoriais-impl.md`, Decisão 2):
* `FFMPEG_PATH` → `ffmpeg` on PATH → the registry-packaged
* `@ffmpeg-installer/ffmpeg` binary. The packed fallback keeps the
* non-technical operator flow working on a workstation without a system
* ffmpeg; the homeserver Docker build installs it from npm (never from GitHub
* releases, which the homeserver cannot reach — see the sharp override note in
* `pnpm-workspace.yaml`).
* C196 — ffmpeg plumbing of the reel composer: the per-scene clip command
* (zoompan + burned caption overlays) and the concat that stitches the scenes
* into `reel.mp4`. The generic binary resolution/probe/runner (C215 moved them
* to `mediaBinaries.mjs`) is re-exported here so the reel tools and their unit
* pins keep the same import surface; the reel requirements (libx264 + the
* composer filters, `atempo` when audio) are injected into the wrappers.
*/
import { execFile } from 'node:child_process'
import { writeFile } from 'node:fs/promises'
import { promisify } from 'node:util'
import {
probeFfmpeg as probeFfmpegBin,
resolveFfmpeg as resolveFfmpegBin,
runProcess,
runTool,
} from './mediaBinaries.mjs'
import { NARRATION_PCM } from './reelScript.mjs'
import { seconds } from './reelTimeline.mjs'
const execFileAsync = promisify(execFile)
const MAX_BUFFER_BYTES = 16 * 1024 * 1024
const REQUIRED_FILTERS = ['fade', 'overlay', 'scale', 'zoompan']
const AUDIO_REQUIRED_ENCODER = 'libmp3lame'
const AUDIO_REQUIRED_FILTERS = ['atempo']
export const REEL_PRESET = 'medium'
export const REEL_CRF = 18
@ -38,25 +33,7 @@ export const PREVIEW_CRF = 14
* @typedef {(bin: string, args: string[]) => Promise<{ ok: boolean, stdout: string, stderr: string, error?: { code?: string } }>} RunFn
*/
/**
* Generic process runner shared by the reel tools (ffmpeg and edge-tts): never
* throws, returns the exit outcome.
*
* @type {RunFn}
*/
export const runProcess = async (bin, args) => {
try {
const { stdout, stderr } = await execFileAsync(bin, args, { maxBuffer: MAX_BUFFER_BYTES })
return { ok: true, stdout, stderr }
} catch (error) {
return { ok: false, stdout: error.stdout ?? '', stderr: error.stderr ?? '', error }
}
}
const defaultRun = runProcess
const versionLine = (stdout) =>
stdout.split('\n').find((line) => line.startsWith('ffmpeg version')) ?? ''
export { runProcess, runTool }
/**
* Capability probe: the binary must encode H.264 (libx264) and ship the
@ -66,90 +43,28 @@ const versionLine = (stdout) =>
* @param {{ run?: RunFn, audio?: boolean }} [options]
* @returns {Promise<{ ok: true, bin: string, version: string } | { ok: false, bin: string, reason: string }>}
*/
export const probeFfmpeg = async (bin, { run = defaultRun, audio = false } = {}) => {
const version = await run(bin, ['-hide_banner', '-version'])
if (!version.ok)
return { ok: false, bin, reason: `não executou (${version.error?.code ?? 'erro'})` }
const encoders = await run(bin, ['-hide_banner', '-encoders'])
if (!encoders.ok || !/\blibx264\b/.test(encoders.stdout)) {
return { ok: false, bin, reason: 'sem encoder libx264' }
}
if (audio && !new RegExp(`\\b${AUDIO_REQUIRED_ENCODER}\\b`).test(encoders.stdout)) {
return { ok: false, bin, reason: `sem encoder ${AUDIO_REQUIRED_ENCODER}` }
}
const filters = await run(bin, ['-hide_banner', '-filters'])
if (!filters.ok) return { ok: false, bin, reason: 'não listou filtros' }
const requiredFilters = audio
? [...REQUIRED_FILTERS, ...AUDIO_REQUIRED_FILTERS]
: REQUIRED_FILTERS
const missing = requiredFilters.filter(
(filter) => !new RegExp(`\\b${filter}\\b`).test(filters.stdout),
)
if (missing.length > 0) return { ok: false, bin, reason: `sem filtro(s): ${missing.join(', ')}` }
return { ok: true, bin, version: versionLine(version.stdout) }
}
const installHint =
'Defina FFMPEG_PATH, instale o ffmpeg (ex.: `sudo apt install ffmpeg`) ou rode `pnpm install` para o binário empacotado.'
export const probeFfmpeg = (bin, { audio = false, ...options } = {}) =>
probeFfmpegBin(bin, {
...options,
audio,
encoders: ['libx264'],
filters: [...REQUIRED_FILTERS, ...(audio ? AUDIO_REQUIRED_FILTERS : [])],
})
/**
* Resolves the first candidate that passes the probe. Explicit `FFMPEG_PATH` is strict.
* Resolves the first candidate that passes the reel probe. Explicit
* `FFMPEG_PATH` is strict.
*
* @param {{ env?: Record<string, string | undefined>, run?: RunFn, installer?: { path?: string } | null, audio?: boolean }} [options]
* @returns {Promise<{ ok: true, bin: string, version: string, source: string }>}
*/
export const resolveFfmpeg = async ({
env = process.env,
run = defaultRun,
installer,
audio = false,
} = {}) => {
let packaged = installer
if (packaged === undefined) {
try {
packaged = (await import('@ffmpeg-installer/ffmpeg')).default
} catch {
// The packaged binary throws on import when its platform package is
// missing; the probe below decides, never the import.
packaged = null
}
}
const candidates = []
const explicit = env.FFMPEG_PATH?.trim()
if (explicit) candidates.push({ bin: explicit, source: 'FFMPEG_PATH', strict: true })
candidates.push({ bin: 'ffmpeg', source: 'PATH', strict: false })
if (packaged?.path) candidates.push({ bin: packaged.path, source: 'empacotado', strict: false })
const failures = []
for (const candidate of candidates) {
const probe = await probeFfmpeg(candidate.bin, { run, audio })
if (probe.ok) {
return { ok: true, bin: probe.bin, version: probe.version, source: candidate.source }
}
failures.push(`${candidate.source}: ${probe.reason}`)
if (candidate.strict) break
}
throw new Error(
`Nenhum ffmpeg utilizável (libx264 + filtros do reel${audio ? ' + áudio' : ''}). ${failures.join('; ')}. ${installHint}`,
)
}
/**
* Runs a process and fails with the stderr tail — never swallows the exit
* code. Shared by the reel tools (ffmpeg and edge-tts); `runFfmpeg` is the
* ffmpeg-labelled alias kept for the historical call sites.
*
* @param {string} bin
* @param {string[]} args
* @param {{ run?: RunFn, label?: string }} [options]
*/
export const runTool = async (bin, args, { run = defaultRun, label = 'processo' } = {}) => {
const result = await run(bin, args)
if (!result.ok) {
const tail = (result.stderr || result.error?.message || '').trim().slice(-400)
throw new Error(`${label} falhou${tail ? `: ${tail}` : ''}`)
}
return result
}
export const resolveFfmpeg = (options = {}) =>
resolveFfmpegBin({
requirementsLabel: `libx264 + filtros do reel${options.audio ? ' + áudio' : ''}`,
...options,
encoders: ['libx264'],
filters: [...REQUIRED_FILTERS, ...(options.audio ? AUDIO_REQUIRED_FILTERS : [])],
})
/** @type {typeof runTool} */
export const runFfmpeg = (bin, args, options = {}) =>
@ -284,7 +199,7 @@ export const encodeFrames = async ({
preset = PREVIEW_PRESET,
crf = PREVIEW_CRF,
writeFileImpl = writeFile,
run = defaultRun,
run = runProcess,
}) => {
await writeFileImpl(listPath, buildFrameListContent({ frames, fps }))
await runFfmpeg(

View file

@ -82,6 +82,7 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/lib/imageResize.mjs',
'scripts/lib/issues-panel.mjs',
'scripts/lib/markdown-ansi.mjs',
'scripts/lib/mediaBinaries.mjs',
'scripts/lib/plansOnlyClosesGuard.mjs',
'scripts/lib/playwright-e2e-args.mjs',
'scripts/lib/portalTransparenciaEmendas.mjs',
@ -99,6 +100,7 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/lib/staging-test-account.mjs',
'scripts/lib/test-affected-core.mjs',
'scripts/lib/testing-audit-metrics-core.mjs',
'scripts/lib/webSpeeches.mjs',
'scripts/lib/worktree-env.mjs',
'scripts/lib/worktree.mjs',
'scripts/lib/wpArticles.mjs',

131
scripts/lib/webSpeeches.mjs Normal file
View file

@ -0,0 +1,131 @@
/**
* C215 — pure planning/reporting of the web-speech ingestion CLI: validate the
* batch (one invalid finding fails only itself), dedupe by natural key, decide
* the planned action per entry and aggregate the run report. No I/O: the unit
* specs read this module and `import-web-speeches.mjs` is the impure shell.
*/
import { formatSpeechSpan } from '../../src/lib/speechClock.ts'
import {
isCompleteWebSpeechState,
parseWebSpeechFinding,
webSpeechSourceKey,
} from '../../src/lib/webSpeech.ts'
/** @typedef {import('../../src/lib/webSpeech.ts').WebSpeechFinding} WebSpeechFinding */
/** @typedef {import('../../src/utilities/speech/webSpeechIngest.ts').WebSpeechIngestResult} WebSpeechIngestResult */
export { isCompleteWebSpeechState }
/**
* @param {unknown[]} findings
* @returns {{ entries: Array<{ index: number, sourceKey: string, finding: WebSpeechFinding }>, invalid: Array<{ index: number, error: string }>, duplicates: Array<{ index: number, sourceKey: string }> }}
*/
export const planWebSpeechBatch = (findings) => {
const entries = []
const invalid = []
const duplicates = []
const seen = new Set()
findings.forEach((raw, index) => {
const parsed = parseWebSpeechFinding(raw)
if (!parsed.ok) {
invalid.push({ index, error: parsed.error })
return
}
const sourceKey = webSpeechSourceKey(parsed.finding)
if (seen.has(sourceKey)) {
duplicates.push({ index, sourceKey })
return
}
seen.add(sourceKey)
entries.push({ index, sourceKey, finding: parsed.finding })
})
return { entries, invalid, duplicates }
}
/** What a re-run would do to an entry, from its stored state. */
export const webSpeechPlannedAction = (state, reprocess = false) => {
if (state === null) return 'novo'
if (!isCompleteWebSpeechState(state)) return 'atualizado'
return reprocess ? 'reprocessado' : 'ignorado'
}
/** @param {WebSpeechIngestResult[]} results */
export const summarizeWebSpeechResults = (results) => {
const count = (status) => results.filter((result) => result.status === status).length
const sum = (pick) => results.reduce((total, result) => total + (Number(pick(result)) || 0), 0)
return {
created: count('created'),
updated: count('updated'),
skipped: count('skipped'),
failed: count('failed'),
asrSeconds: sum((result) => result.asrSeconds),
asrCostUsd: sum((result) => result.asrCostUsd),
llmCostUsd: sum((result) => result.llmCostUsd),
failures: results
.filter((result) => result.status === 'failed')
.map((result) => ({
sourceKey: result.sourceKey,
stage: result.stage ?? null,
error: result.error ?? null,
})),
}
}
/** @param {Array<{ action: string }>} planned */
export const summarizePlannedActions = (planned) => ({
novo: planned.filter((entry) => entry.action === 'novo').length,
atualizado: planned.filter((entry) => entry.action === 'atualizado').length,
ignorado: planned.filter((entry) => entry.action === 'ignorado').length,
reprocessado: planned.filter((entry) => entry.action === 'reprocessado').length,
})
const secondsLabel = (seconds) => formatSpeechSpan(Math.round(seconds))
/**
* Human lines for stdout; the JSON file keeps the full report. Failures are
* never hidden — each one names its stage and reason.
*
* @param {ReturnType<typeof summarizeWebSpeechResults> & { runAt: string, mode: string, findings: number, planned: number, invalid: Array<{index: number, error: string}>, duplicates: Array<{index: number, sourceKey: string}>, durationMs: number, plannedActions?: Record<string, number>|null }} report
* @returns {string[]}
*/
export const formatWebSpeechReport = (report) => {
const label = '[falas-web:import]'
const lines = []
lines.push(
`${label} achados: ${report.findings} · considerados: ${report.planned} · inválidos: ${report.invalid.length} · duplicados: ${report.duplicates.length}`,
)
if (report.plannedActions) {
const actions = report.plannedActions
lines.push(
`${label} plano (dry-run): novos: ${actions.novo} · atualizados: ${actions.atualizado} · ignorados: ${actions.ignorado} · reprocessados: ${actions.reprocessado}`,
)
} else {
const summary = report
lines.push(
`${label} novos: ${summary.created} · atualizados: ${summary.updated} · ignorados: ${summary.skipped} · falhas: ${summary.failed}`,
)
lines.push(
`${label} tempo: ${(report.durationMs / 1000).toFixed(1)}s · ASR: ${secondsLabel(summary.asrSeconds)} (~US$ ${summary.asrCostUsd.toFixed(4)}) · LLM: US$ ${summary.llmCostUsd.toFixed(4)}`,
)
}
for (const failure of report.invalid) {
lines.push(`${label} inválido #${failure.index}: ${failure.error}`)
}
if (report.failures?.length) {
lines.push(`${label} falhas:`)
for (const failure of report.failures) {
lines.push(
` - ${failure.sourceKey} (${failure.stage ?? 'desconhecido'}): ${failure.error ?? 'erro'}`,
)
}
}
return lines
}
export const webSpeechReportStamp = (runAt) => runAt.replace(/[:.]/g, '-')

View file

@ -0,0 +1,44 @@
import type { CollectionConfig } from 'payload'
import { INTERNET_SPEECH_MEDIA_SLUG } from '@/lib/webSpeech'
import { canReadSpeech, payloadAdminOnly } from '@/utilities/campaignAccess'
/**
* C215 — private upload collection of a web speech's mirrored artifact (the
* media and the optional thumbnail). Like `recordingMedia`, deliberately NOT
* `media`: that collection is anonymous-read by contract (public pages), while
* a third-party speech file may only be read by the communication roles. The
* browser reaches the file through the authenticated acervo route (C216),
* never the public `/api/media/file` proxy.
*/
export const InternetSpeechMedia: CollectionConfig = {
slug: INTERNET_SPEECH_MEDIA_SLUG,
labels: {
singular: 'Mídia de fala da internet',
plural: 'Mídias de falas da internet',
},
admin: {
group: 'Comunicação',
description:
'Arquivos privados das falas encontradas na internet (mídia espelhada e capa). Só abrem com login da campanha.',
},
access: {
create: payloadAdminOnly,
read: canReadSpeech,
update: payloadAdminOnly,
delete: payloadAdminOnly,
},
fields: [
{
name: 'alt',
type: 'text',
label: 'Texto alternativo',
required: true,
admin: {
description: 'Descrição do arquivo para acessibilidade.',
},
},
],
upload: true,
}

View file

@ -6,15 +6,20 @@ import type {
} from 'payload'
import { SPEECH_CLASSIFICATION_SOURCES, SPEECH_SCOPES, SPEECH_TOPICS } from '@/lib/speechFacets'
import { INTERNET_SPEECH_MEDIA_SLUG, WEB_SPEECH_PLATFORMS } from '@/lib/webSpeech'
import { canReadSpeech, canUpdateSpeech, payloadAdminOnly } from '@/utilities/campaignAccess'
/**
* Speech catalog (C153) — one record per Jorge Solla speech in the Câmara,
* with the official transcript (taquigrafia), the ASR segments with timestamps
* (speechSegment), curated facets and extracted mentions.
* Speech catalog (C153) — one record per Jorge Solla speech: the Câmara
* speeches with the official transcript (taquigrafia), ASR segments with
* timestamps (speechSegment), curated facets and extracted mentions, plus the
* web speeches (C215) mirrored from the internet with the same facets and
* segments.
*
* Data provenance: Câmara open data + VOD, CC BY 4.0 — keep the credit.
* Identity is the API speech (`sourceKey`), never the regenerable VOD URL.
* Câmara data provenance: open data + VOD, CC BY 4.0 — keep the credit. Web
* rows carry their own origin (platform + URL) and a private mirrored file.
* Identity is the source (`sourceKey`): the Câmara API speech or the web
* namespace (`web:<platform>:<externalId|url>`), never a regenerable URL.
*/
const FACET_FIELDS = [
@ -56,8 +61,22 @@ const preserveManualFacets: CollectionBeforeChangeHook = ({
return data
}
/** Segments are owned by the speech; Payload relationships do not cascade. */
const deleteSpeechSegments: CollectionBeforeDeleteHook = async ({ id, req }) => {
/** Speech assets are owned by the speech; Payload relationships do not cascade. */
const deleteSpeechAssets: CollectionBeforeDeleteHook = async ({ id, req }) => {
const speech = await req.payload.findByID({
collection: 'speech',
id,
depth: 0,
select: { mirroredMedia: true, thumbnail: true },
req,
// Intentional bypass: the cascade is owned by this hook; media access is the
// acervo gate and this runs inside the authorized speech delete request.
overrideAccess: true,
})
const mediaIds = [speech?.mirroredMedia, speech?.thumbnail]
.map((media) => (typeof media === 'object' && media !== null ? media.id : media))
.filter((mediaId): mediaId is number => typeof mediaId === 'number')
await req.payload.delete({
collection: 'speechSegment',
where: { speech: { equals: id } },
@ -66,6 +85,15 @@ const deleteSpeechSegments: CollectionBeforeDeleteHook = async ({ id, req }) =>
// admin-only and this runs inside the authorized speech delete request.
overrideAccess: true,
})
if (mediaIds.length > 0) {
await req.payload.delete({
collection: INTERNET_SPEECH_MEDIA_SLUG,
where: { id: { in: mediaIds } },
req,
// Intentional bypass: same cascade, now over the private mirrored files.
overrideAccess: true,
})
}
}
export const Speech: CollectionConfig = {
@ -77,9 +105,9 @@ export const Speech: CollectionConfig = {
admin: {
group: 'Comunicação',
useAsTitle: 'speechAt',
defaultColumns: ['speechAt', 'type', 'phase', 'topics', 'classifiedBy'],
defaultColumns: ['speechAt', 'origin', 'platform', 'type', 'topics', 'classifiedBy'],
description:
'Acervo de falas do deputado na Câmara. Dados e vídeos da Câmara dos Deputados (CC BY 4.0).',
'Acervo de falas do deputado: discursos na Câmara (CC BY 4.0) e falas publicadas na internet (mídia espelhada privada).',
},
access: {
create: payloadAdminOnly,
@ -90,7 +118,7 @@ export const Speech: CollectionConfig = {
hooks: {
beforeValidate: [deriveSpeechYear],
beforeChange: [preserveManualFacets],
beforeDelete: [deleteSpeechSegments],
beforeDelete: [deleteSpeechAssets],
},
fields: [
{
@ -102,7 +130,24 @@ export const Speech: CollectionConfig = {
index: true,
admin: {
readOnly: true,
description: 'Identidade da fala na API da Câmara (data/hora + tipo + fase).',
description: 'Identidade da fala: API da Câmara ou web:<plataforma>:<id|url>.',
},
},
{
// C215 — discriminator of the catalog source. The Câmara surfaces filter
// `camara`; the web source is served by C216 with its own list contract.
name: 'origin',
type: 'select',
label: 'Origem',
required: true,
defaultValue: 'camara',
index: true,
options: [
{ label: 'Câmara', value: 'camara' },
{ label: 'Internet', value: 'web' },
],
admin: {
description: 'Discurso da Câmara ou fala publicada na internet.',
},
},
{
@ -267,6 +312,71 @@ export const Speech: CollectionConfig = {
label: 'VOD (baixar)',
admin: { readOnly: true, description: 'Último link conhecido; regerável pelo VOD.' },
},
// C215 — web speech origin and mirrored assets (null on Câmara rows).
{
name: 'platform',
type: 'select',
label: 'Plataforma',
index: true,
options: WEB_SPEECH_PLATFORMS.map(({ value, label }) => ({ value, label })),
admin: {
description: 'Plataforma de origem da fala da internet.',
},
},
{
name: 'externalId',
type: 'text',
label: 'Id externo',
admin: {
readOnly: true,
description: 'Identificador da publicação na plataforma (quando houver).',
},
},
{
name: 'sourceUrl',
type: 'text',
label: 'URL de origem',
admin: {
readOnly: true,
description: 'Link canônico da publicação original.',
},
},
{
name: 'title',
type: 'text',
label: 'Título',
admin: {
description: 'Título da publicação na plataforma.',
},
},
{
name: 'channel',
type: 'text',
label: 'Canal/autor',
admin: {
description: 'Quem publicou (canal, rádio, perfil) — texto de origem, não é contato.',
},
},
{
name: 'mirroredMedia',
type: 'upload',
relationTo: INTERNET_SPEECH_MEDIA_SLUG,
label: 'Mídia espelhada',
admin: {
readOnly: true,
description: 'Arquivo privado preservado para player, download e cortes.',
},
},
{
name: 'thumbnail',
type: 'upload',
relationTo: INTERNET_SPEECH_MEDIA_SLUG,
label: 'Capa',
admin: {
readOnly: true,
description: 'Capa da origem quando capturada; sem ela a lista mostra placeholder.',
},
},
{
name: 'topics',
type: 'select',

280
src/lib/webSpeech.ts Normal file
View file

@ -0,0 +1,280 @@
import { z } from 'zod'
/**
* C215 — pure contract of the web-speech ingestion: the platform taxonomy, the
* finding-batch schema the discovery (C218) hands over, the natural key
* (`web:<platform>:<externalId|url>`) and the canonical URL normalization that
* keeps re-runs idempotent. No I/O: the CLI, the pipeline and the tests share
* this module.
*/
export const WEB_SPEECH_PLATFORMS = [
{ value: 'youtube', label: 'YouTube' },
{ value: 'instagram', label: 'Instagram' },
{ value: 'radio', label: 'Rádio' },
{ value: 'audio', label: 'Áudio' },
] as const
export type WebSpeechPlatform = (typeof WEB_SPEECH_PLATFORMS)[number]['value']
const WEB_SPEECH_PLATFORM_VALUES = WEB_SPEECH_PLATFORMS.map((platform) => platform.value) as [
WebSpeechPlatform,
...WebSpeechPlatform[],
]
export const webSpeechPlatformLabel = (value: WebSpeechPlatform): string =>
WEB_SPEECH_PLATFORMS.find((platform) => platform.value === value)?.label ?? value
/** Owner of the private upload collection slug (collection, config and S3). */
export const INTERNET_SPEECH_MEDIA_SLUG = 'internetSpeechMedia' as const
/**
* Platforms whose acquisition goes through yt-dlp. The others (`radio`,
* `audio`) are direct files: the finding must carry `mediaUrl` or the item
* fails honestly in the report — the script never guesses a page's media.
*/
const YTDLP_PLATFORMS: readonly WebSpeechPlatform[] = ['youtube', 'instagram']
export const requiresDirectMediaUrl = (platform: WebSpeechPlatform): boolean =>
!YTDLP_PLATFORMS.includes(platform)
const TRACKING_PARAMS = new Set(['fbclid', 'gclid', 'igshid', 'igsh', 'si', 'feature'])
const isTrackingParam = (name: string): boolean =>
TRACKING_PARAMS.has(name) || name.toLowerCase().startsWith('utm_')
const stripTrackingParams = (url: URL): void => {
for (const name of [...url.searchParams.keys()]) {
if (isTrackingParam(name)) url.searchParams.delete(name)
}
}
const withoutTrailingSlash = (pathname: string): string =>
pathname.length > 1 && pathname.endsWith('/') ? pathname.replace(/\/+$/, '') : pathname
const YOUTUBE_HOSTS = new Set(['youtube.com', 'm.youtube.com', 'music.youtube.com'])
const YOUTU_BE_HOSTS = new Set(['youtu.be'])
const normalizedHost = (url: URL): string => url.hostname.toLowerCase().replace(/^www\./, '')
const firstPathSegment = (pathname: string): string | null =>
pathname.split('/').find((segment) => segment !== '') ?? null
/** Video id from any YouTube URL shape (`watch`, `shorts`, `live`, `embed`, `youtu.be`). */
const youtubeVideoId = (url: URL): string | null => {
const host = normalizedHost(url)
if (YOUTU_BE_HOSTS.has(host)) return firstPathSegment(url.pathname)
if (!YOUTUBE_HOSTS.has(host)) return null
if (url.pathname === '/watch') return url.searchParams.get('v')
const match = /^\/(?:shorts|live|embed)\/([^/?#]+)/.exec(url.pathname)
return match?.[1] ?? null
}
/** Shortcode + kind (`reel`/`p`/`tv`) from any Instagram post URL. */
const instagramPost = (url: URL): { kind: string; code: string } | null => {
if (normalizedHost(url) !== 'instagram.com') return null
const match = /^\/(p|reel|reels|tv)\/([^/?#]+)/.exec(url.pathname)
if (!match) return null
return { kind: match[1] === 'reels' ? 'reel' : match[1], code: match[2] }
}
const parseUrl = (raw: string): URL | null => {
try {
return new URL(raw.trim())
} catch {
return null
}
}
/**
* Stable URL for identity and provenance: YouTube/Instagram collapse to the
* canonical post URL and every platform drops tracking params, the fragment
* and a trailing slash. An unparseable URL returns trimmed as-is (the finding
* schema validates the shape before this runs).
*/
export const canonicalWebSpeechUrl = (platform: WebSpeechPlatform, rawUrl: string): string => {
const url = parseUrl(rawUrl)
if (!url) return rawUrl.trim()
if (platform === 'youtube') {
const videoId = youtubeVideoId(url)
if (videoId) return `https://www.youtube.com/watch?v=${videoId}`
}
if (platform === 'instagram') {
const post = instagramPost(url)
if (post) return `https://www.instagram.com/${post.kind}/${post.code}/`
}
url.hash = ''
stripTrackingParams(url)
url.hostname = url.hostname.toLowerCase()
url.pathname = withoutTrailingSlash(url.pathname)
return url.toString()
}
/**
* Natural key of one web speech. `externalId` wins when the discovery already
* knows the platform id; otherwise the canonical URL is the identity — the
* same URL always produces the same key, so re-running never duplicates.
*/
export const webSpeechSourceKey = ({
platform,
url,
externalId,
}: {
platform: WebSpeechPlatform
url: string
externalId?: string | null
}): string => {
const id = externalId?.trim()
return id ? `web:${platform}:${id}` : `web:${platform}:${canonicalWebSpeechUrl(platform, url)}`
}
/** A web speech row is complete when it has both the transcript and the mirrored file. */
export const isCompleteWebSpeechState = (
state: { segmentCount: number; mirroredMedia: number | null } | null,
): boolean => state !== null && state.segmentCount > 0 && state.mirroredMedia !== null
const PUBLISHED_AT_PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?$/
const TIMEZONE_SUFFIX = /(?:Z|[+-]\d{2}:?\d{2})$/
const isValidCalendarDay = (year: string, month: string, day: string): boolean => {
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
return (
date.getUTCFullYear() === Number(year) &&
date.getUTCMonth() === Number(month) - 1 &&
date.getUTCDate() === Number(day)
)
}
/**
* `speechAt` (Brasília wall-clock text, same contract as the Câmara rows) from
* the discovery's `publishedAt` — day-only becomes midnight and an explicit
* timezone is converted to America/Bahia. Unrecognized values return null so
* the finding fails validation instead of inventing a date.
*/
export const webSpeechAtFromPublishedAt = (publishedAt: string): string | null => {
const trimmed = publishedAt.trim()
if (TIMEZONE_SUFFIX.test(trimmed)) {
const instant = new Date(trimmed)
if (Number.isNaN(instant.getTime())) return null
// sv-SE renders the ISO-like `YYYY-MM-DD HH:mm` for the requested zone.
return new Intl.DateTimeFormat('sv-SE', {
timeZone: 'America/Bahia',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
.format(instant)
.replace(' ', 'T')
}
const match = PUBLISHED_AT_PATTERN.exec(trimmed)
if (!match) return null
const [, year, month, day, hour = '00', minute = '00'] = match
if (!isValidCalendarDay(year, month, day)) return null
if (Number(hour) > 23 || Number(minute) > 59) return null
return `${year}-${month}-${day}T${hour}:${minute}`
}
const MAX_TITLE_LENGTH = 300
const MAX_CHANNEL_LENGTH = 200
const webSpeechFindingSchema = z
.object({
platform: z.enum(WEB_SPEECH_PLATFORM_VALUES, { error: 'plataforma desconhecida' }),
url: z.string({ error: 'url obrigatória' }).trim().url('url inválida'),
mediaUrl: z.string().trim().url('mediaUrl inválida').optional(),
externalId: z.string().trim().min(1, 'externalId vazio').optional(),
title: z
.string()
.trim()
.min(1, 'título vazio')
.max(MAX_TITLE_LENGTH, `título até ${MAX_TITLE_LENGTH} caracteres`)
.optional(),
channel: z
.string()
.trim()
.min(1, 'canal vazio')
.max(MAX_CHANNEL_LENGTH, `canal até ${MAX_CHANNEL_LENGTH} caracteres`)
.optional(),
publishedAt: z
.string({ error: 'publishedAt obrigatório' })
.trim()
.min(4, 'publishedAt inválido'),
durationSeconds: z
.number({ error: 'durationSeconds inválido' })
.finite()
.nonnegative('durationSeconds inválido')
.optional(),
thumbnailUrl: z.string().trim().url('thumbnailUrl inválida').optional(),
})
.superRefine((finding, context) => {
if (requiresDirectMediaUrl(finding.platform) && !finding.mediaUrl) {
context.addIssue({
code: 'custom',
path: ['mediaUrl'],
message: `Achado de ${webSpeechPlatformLabel(finding.platform)} exige mediaUrl (arquivo direto).`,
})
}
if (webSpeechAtFromPublishedAt(finding.publishedAt) === null) {
context.addIssue({
code: 'custom',
path: ['publishedAt'],
message: 'publishedAt deve ser YYYY-MM-DD ou data/hora ISO.',
})
}
})
export type WebSpeechFinding = z.infer<typeof webSpeechFindingSchema>
export type WebSpeechFindingParseResult =
| { ok: true; finding: WebSpeechFinding }
| { ok: false; error: string }
export const parseWebSpeechFinding = (value: unknown): WebSpeechFindingParseResult => {
const parsed = webSpeechFindingSchema.safeParse(value)
if (parsed.success) return { ok: true, finding: parsed.data }
const issue = parsed.error.issues[0]
const path = issue?.path.join('.') ?? ''
return {
ok: false,
error: path ? `${path}: ${issue?.message}` : (issue?.message ?? 'achado inválido'),
}
}
type WebSpeechBatch = {
generatedAt: string | null
findings: unknown[]
}
export type WebSpeechBatchParseResult =
| { ok: true; batch: WebSpeechBatch }
| { ok: false; error: string }
/**
* Envelope only: the findings stay raw so one invalid entry becomes a failure
* of that entry in the report instead of killing the whole batch.
*/
export const parseWebSpeechBatch = (value: unknown): WebSpeechBatchParseResult => {
const envelope = z
.object({
generatedAt: z.string().trim().min(1).optional(),
findings: z.array(z.unknown()),
})
.safeParse(value)
if (!envelope.success) {
return { ok: false, error: 'lote inválido: esperado { generatedAt?, findings: [...] }.' }
}
return {
ok: true,
batch: {
generatedAt: envelope.data.generatedAt ?? null,
findings: envelope.data.findings,
},
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,71 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_speech_origin" AS ENUM('camara', 'web');
CREATE TYPE "public"."enum_speech_platform" AS ENUM('youtube', 'instagram', 'radio', 'audio');
CREATE TABLE "internet_speech_media" (
"id" serial PRIMARY KEY NOT NULL,
"alt" varchar NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"url" varchar,
"thumbnail_u_r_l" varchar,
"filename" varchar,
"mime_type" varchar,
"filesize" numeric,
"width" numeric,
"height" numeric,
"focal_x" numeric,
"focal_y" numeric
);
ALTER TABLE "speech" ADD COLUMN "origin" "enum_speech_origin" DEFAULT 'camara' NOT NULL;
ALTER TABLE "speech" ADD COLUMN "platform" "enum_speech_platform";
ALTER TABLE "speech" ADD COLUMN "external_id" varchar;
ALTER TABLE "speech" ADD COLUMN "source_url" varchar;
ALTER TABLE "speech" ADD COLUMN "title" varchar;
ALTER TABLE "speech" ADD COLUMN "channel" varchar;
ALTER TABLE "speech" ADD COLUMN "mirrored_media_id" integer;
ALTER TABLE "speech" ADD COLUMN "thumbnail_id" integer;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "internet_speech_media_id" integer;
CREATE INDEX "internet_speech_media_updated_at_idx" ON "internet_speech_media" USING btree ("updated_at");
CREATE INDEX "internet_speech_media_created_at_idx" ON "internet_speech_media" USING btree ("created_at");
CREATE UNIQUE INDEX "internet_speech_media_filename_idx" ON "internet_speech_media" USING btree ("filename");
ALTER TABLE "speech" ADD CONSTRAINT "speech_mirrored_media_id_internet_speech_media_id_fk" FOREIGN KEY ("mirrored_media_id") REFERENCES "public"."internet_speech_media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "speech" ADD CONSTRAINT "speech_thumbnail_id_internet_speech_media_id_fk" FOREIGN KEY ("thumbnail_id") REFERENCES "public"."internet_speech_media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_internet_speech_media_fk" FOREIGN KEY ("internet_speech_media_id") REFERENCES "public"."internet_speech_media"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "speech_origin_idx" ON "speech" USING btree ("origin");
CREATE INDEX "speech_platform_idx" ON "speech" USING btree ("platform");
CREATE INDEX "speech_mirrored_media_idx" ON "speech" USING btree ("mirrored_media_id");
CREATE INDEX "speech_thumbnail_idx" ON "speech" USING btree ("thumbnail_id");
CREATE INDEX "payload_locked_documents_rels_internet_speech_media_id_idx" ON "payload_locked_documents_rels" USING btree ("internet_speech_media_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "internet_speech_media" DISABLE ROW LEVEL SECURITY;
DROP TABLE "internet_speech_media" CASCADE;
ALTER TABLE "speech" DROP CONSTRAINT "speech_mirrored_media_id_internet_speech_media_id_fk";
ALTER TABLE "speech" DROP CONSTRAINT "speech_thumbnail_id_internet_speech_media_id_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_internet_speech_media_fk";
DROP INDEX "speech_origin_idx";
DROP INDEX "speech_platform_idx";
DROP INDEX "speech_mirrored_media_idx";
DROP INDEX "speech_thumbnail_idx";
DROP INDEX "payload_locked_documents_rels_internet_speech_media_id_idx";
ALTER TABLE "speech" DROP COLUMN "origin";
ALTER TABLE "speech" DROP COLUMN "platform";
ALTER TABLE "speech" DROP COLUMN "external_id";
ALTER TABLE "speech" DROP COLUMN "source_url";
ALTER TABLE "speech" DROP COLUMN "title";
ALTER TABLE "speech" DROP COLUMN "channel";
ALTER TABLE "speech" DROP COLUMN "mirrored_media_id";
ALTER TABLE "speech" DROP COLUMN "thumbnail_id";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "internet_speech_media_id";
DROP TYPE "public"."enum_speech_origin";
DROP TYPE "public"."enum_speech_platform";`)
}

View file

@ -82,6 +82,7 @@ import * as migration_20260923_032714_add_content_piece from './20260923_032714_
import * as migration_20260923_054145_add_share_link_announcement from './20260923_054145_add_share_link_announcement';
import * as migration_20260923_172437_add_content_events from './20260923_172437_add_content_events';
import * as migration_20260923_190414_add_content_event_variant from './20260923_190414_add_content_event_variant';
import * as migration_20260924_042028_add_web_speech_origin from './20260924_042028_add_web_speech_origin';
export const migrations = [
{
@ -502,6 +503,11 @@ export const migrations = [
{
up: migration_20260923_190414_add_content_event_variant.up,
down: migration_20260923_190414_add_content_event_variant.down,
name: '20260923_190414_add_content_event_variant'
name: '20260923_190414_add_content_event_variant',
},
{
up: migration_20260924_042028_add_web_speech_origin.up,
down: migration_20260924_042028_add_web_speech_origin.down,
name: '20260924_042028_add_web_speech_origin'
},
];

View file

@ -89,6 +89,7 @@ export interface Config {
speech: Speech;
speechSegment: SpeechSegment;
speechCut: SpeechCut;
internetSpeechMedia: InternetSpeechMedia;
reel: Reel;
reelMedia: ReelMedia;
contentPiece: ContentPiece;
@ -143,6 +144,7 @@ export interface Config {
speech: SpeechSelect<false> | SpeechSelect<true>;
speechSegment: SpeechSegmentSelect<false> | SpeechSegmentSelect<true>;
speechCut: SpeechCutSelect<false> | SpeechCutSelect<true>;
internetSpeechMedia: InternetSpeechMediaSelect<false> | InternetSpeechMediaSelect<true>;
reel: ReelSelect<false> | ReelSelect<true>;
reelMedia: ReelMediaSelect<false> | ReelMediaSelect<true>;
contentPiece: ContentPieceSelect<false> | ContentPieceSelect<true>;
@ -938,7 +940,7 @@ export interface MunicipalityUpdate {
createdAt: string;
}
/**
* Acervo de falas do deputado na Câmara. Dados e vídeos da Câmara dos Deputados (CC BY 4.0).
* Acervo de falas do deputado: discursos na Câmara (CC BY 4.0) e falas publicadas na internet (mídia espelhada privada).
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "speech".
@ -946,9 +948,13 @@ export interface MunicipalityUpdate {
export interface Speech {
id: number;
/**
* Identidade da fala na API da Câmara (data/hora + tipo + fase).
* Identidade da fala: API da Câmara ou web:<plataforma>:<id|url>.
*/
sourceKey: string;
/**
* Discurso da Câmara ou fala publicada na internet.
*/
origin: 'camara' | 'web';
/**
* Horário local de Brasília, como publicado pela Câmara.
*/
@ -988,6 +994,34 @@ export interface Speech {
* Último link conhecido; regerável pelo VOD.
*/
vodDownloadUrl?: string | null;
/**
* Plataforma de origem da fala da internet.
*/
platform?: ('youtube' | 'instagram' | 'radio' | 'audio') | null;
/**
* Identificador da publicação na plataforma (quando houver).
*/
externalId?: string | null;
/**
* Link canônico da publicação original.
*/
sourceUrl?: string | null;
/**
* Título da publicação na plataforma.
*/
title?: string | null;
/**
* Quem publicou (canal, rádio, perfil) — texto de origem, não é contato.
*/
channel?: string | null;
/**
* Arquivo privado preservado para player, download e cortes.
*/
mirroredMedia?: (number | null) | InternetSpeechMedia;
/**
* Capa da origem quando capturada; sem ela a lista mostra placeholder.
*/
thumbnail?: (number | null) | InternetSpeechMedia;
topics?:
| (
| 'saude'
@ -1022,6 +1056,30 @@ export interface Speech {
updatedAt: string;
createdAt: string;
}
/**
* Arquivos privados das falas encontradas na internet (mídia espelhada e capa). Só abrem com login da campanha.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "internetSpeechMedia".
*/
export interface InternetSpeechMedia {
id: number;
/**
* Descrição do arquivo para acessibilidade.
*/
alt: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "speechSegment".
@ -2052,6 +2110,10 @@ export interface PayloadLockedDocument {
relationTo: 'speechCut';
value: number | SpeechCut;
} | null)
| ({
relationTo: 'internetSpeechMedia';
value: number | InternetSpeechMedia;
} | null)
| ({
relationTo: 'reel';
value: number | Reel;
@ -2608,6 +2670,7 @@ export interface ActivitySelect<T extends boolean = true> {
*/
export interface SpeechSelect<T extends boolean = true> {
sourceKey?: T;
origin?: T;
speechAt?: T;
year?: T;
legislature?: T;
@ -2629,6 +2692,13 @@ export interface SpeechSelect<T extends boolean = true> {
excerptTMs?: T;
vodPlaybackUrl?: T;
vodDownloadUrl?: T;
platform?: T;
externalId?: T;
sourceUrl?: T;
title?: T;
channel?: T;
mirroredMedia?: T;
thumbnail?: T;
topics?: T;
scopes?: T;
classifiedBy?: T;
@ -2673,6 +2743,24 @@ export interface SpeechCutSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "internetSpeechMedia_select".
*/
export interface InternetSpeechMediaSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "reel_select".
@ -3603,6 +3691,7 @@ export interface TaskCreateCollectionExport {
| 'speech'
| 'speechSegment'
| 'speechCut'
| 'internetSpeechMedia'
| 'reel'
| 'reelMedia'
| 'contentPiece'

View file

@ -26,6 +26,7 @@ import { ElectionCandidate } from './collections/ElectionCandidate'
import { ElectionCandidateVote } from './collections/ElectionCandidateVote'
import { ElectionTally } from './collections/ElectionTally'
import { GoogleCalendarSync } from './collections/GoogleCalendarSync'
import { InternetSpeechMedia } from './collections/InternetSpeechMedia'
import { Jingle } from './collections/Jingle'
import { Leadership } from './collections/Leadership'
import { Media } from './collections/Media'
@ -127,6 +128,7 @@ export default buildConfig({
Speech,
SpeechSegment,
SpeechCut,
InternetSpeechMedia,
Reel,
ReelMedia,
ContentPiece,
@ -189,6 +191,8 @@ export default buildConfig({
recordingMedia: true,
// C211 — private content-piece files; same failure mode if omitted.
contentMedia: true,
// C215 — private mirrored web-speech files; same failure mode if omitted.
internetSpeechMedia: true,
},
bucket: mediaStorage.bucket,
config: {

View file

@ -25,6 +25,13 @@ export const DEEPINFRA_TRANSCRIBE_URL = 'https://api.deepinfra.com/v1/openai/aud
const DEEPINFRA_WHISPER_MODEL = 'openai/whisper-large-v3'
const DEEPINFRA_LANGUAGE = 'pt'
/**
* C215 — provider price (~US$ per audio minute, Whisper large-v3 at Deep
* Infra) used by the ingestion report to state the ASR cost honestly. The
* Câmara CLI keeps its own copy; this is the app-side owner.
*/
export const DEEPINFRA_TRANSCRIBE_COST_PER_MINUTE_USD = 0.00045
export type TranscribeAudioResult =
| { ok: true; text: string }
| { ok: false; error: string; status: number }

View file

@ -95,7 +95,14 @@ export const findSpeechExcerpts = (ctx: AIToolContext) =>
const speeches = await ctx.payload.find({
collection: 'speech',
where: { and: terms.map((term) => ({ searchText: { like: term } })) },
// C215 — the assistant keeps suggesting Câmara excerpts; the web
// speeches (C216) are a different source with their own screen.
where: {
and: [
{ origin: { equals: 'camara' } },
...terms.map((term) => ({ searchText: { like: term } })),
],
},
depth: 0,
limit: SPEECH_CANDIDATE_LIMIT,
page: 1,

View file

@ -0,0 +1,63 @@
import 'server-only'
import { createWriteStream } from 'node:fs'
import { Readable, Transform } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import type { ReadableStream as NodeReadableStream } from 'node:stream/web'
/**
* C215 — streams one public URL to a local file: the direct acquisition path
* of radio/audio findings (yt-dlp owns the platform extractors). A non-2xx
* answer fails before anything is written, the body is never buffered in
* memory and both a timeout and a byte ceiling bound a stalled or oversized
* server (a partial file is removed by the caller's temp dir).
*/
/** Ceiling: hours of audio/video are the real case, 4 GiB is the guard. */
const DOWNLOAD_MAX_BYTES = 4 * 1024 * 1024 * 1024
const DOWNLOAD_TIMEOUT_MS = 60 * 60_000
const TOO_LARGE_MESSAGE = 'Download excede o tamanho máximo permitido.'
export const downloadUrlToFile = async ({
url,
destinationPath,
timeoutMs = DOWNLOAD_TIMEOUT_MS,
maxBytes = DOWNLOAD_MAX_BYTES,
fetchImpl = fetch,
}: {
url: string
destinationPath: string
timeoutMs?: number
maxBytes?: number
fetchImpl?: typeof fetch
}): Promise<void> => {
const response = await fetchImpl(url, {
redirect: 'follow',
signal: AbortSignal.timeout(timeoutMs),
})
if (!response.ok) throw new Error(`Download falhou (HTTP ${response.status}).`)
if (!response.body) throw new Error('Download sem corpo.')
const declaredLength = Number(response.headers.get('content-length'))
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
throw new Error(TOO_LARGE_MESSAGE)
}
let received = 0
const ceiling = new Transform({
transform(chunk, _encoding, callback) {
received += chunk.length
if (received > maxBytes) {
callback(new Error(TOO_LARGE_MESSAGE))
return
}
callback(null, chunk)
},
})
await pipeline(
Readable.fromWeb(response.body as unknown as NodeReadableStream<Uint8Array>),
ceiling,
createWriteStream(destinationPath),
)
}

View file

@ -16,7 +16,12 @@ const FFMPEG_MIN_TIMEOUT_MS = 30_000
const FFMPEG_MAX_TIMEOUT_MS = 300_000
const FFMPEG_MAX_BUFFER_BYTES = 8 * 1024 * 1024
const ffmpegBinary = (): string => process.env.FFMPEG_PATH?.trim() || 'ffmpeg'
/**
* The resolved binary (`FFMPEG_PATH` override or `ffmpeg` on PATH). Exported
* so callers that must tell another tool where ffmpeg lives (yt-dlp's
* `--ffmpeg-location`, C215) read the same resolution instead of the env.
*/
export const ffmpegBinary = (): string => process.env.FFMPEG_PATH?.trim() || 'ffmpeg'
/** Last-resort copy when an error carries no message; each job names its own. */
export const messageOf = (error: unknown, fallback: string): string =>

View file

@ -0,0 +1,170 @@
import 'server-only'
import { execFile } from 'node:child_process'
/**
* C215 — the single owner of yt-dlp execution for the web-speech ingestion:
* binary resolution (`YTDLP_PATH` → `yt-dlp` on PATH, fail-closed), the
* metadata probe (`--dump-json`) and the download of one URL to a local file.
* YouTube and Instagram only; direct radio/audio files are fetched by
* `downloadToFile`.
*
* There is no packaged yt-dlp: the operator installs it and the tests inject a
* fake through `YTDLP_PATH`. The format cap keeps the mirrored artifact enough
* for transcription, player and cut — never the master.
*/
export const YTDLP_INSTALL_HINT =
'Instale o yt-dlp (ex.: `pipx install yt-dlp`) ou defina YTDLP_PATH.'
/** H.264/AAC up to 720p when available, then the best single file. */
export const YTDLP_FORMAT = 'bv*[height<=720][vcodec^=avc1]+ba[acodec^=mp4a]/b[height<=720]/b'
const VERSION_TIMEOUT_MS = 30_000
const METADATA_TIMEOUT_MS = 120_000
const DOWNLOAD_TIMEOUT_MS = 60 * 60_000
const MAX_BUFFER_BYTES = 16 * 1024 * 1024
export type YtDlpResult =
| { ok: true; stdout: string; stderr: string }
| {
ok: false
stdout: string
stderr: string
error?: { code?: string | number | null; message?: string }
}
export type YtDlpRunner = (
bin: string,
args: string[],
options?: { timeoutMs?: number },
) => Promise<YtDlpResult>
const defaultRunner: YtDlpRunner = (bin, args, { timeoutMs } = {}) =>
new Promise((resolve) => {
execFile(
bin,
args,
{ timeout: timeoutMs, maxBuffer: MAX_BUFFER_BYTES },
(error, stdout, stderr) => {
if (!error) {
resolve({ ok: true, stdout: stdout ?? '', stderr: stderr ?? '' })
return
}
resolve({ ok: false, stdout: stdout ?? '', stderr: stderr ?? '', error })
},
)
})
const ytDlpBinary = (env: Record<string, string | undefined> = process.env): string =>
env.YTDLP_PATH?.trim() || 'yt-dlp'
export type YtDlpResolution = { ok: true; bin: string } | { ok: false; reason: string }
/** Probe of the resolved binary; a missing/unusable yt-dlp never degrades silently. */
export const resolveYtDlp = async ({
env = process.env,
run = defaultRunner,
}: {
env?: Record<string, string | undefined>
run?: YtDlpRunner
} = {}): Promise<YtDlpResolution> => {
const bin = ytDlpBinary(env)
const probe = await run(bin, ['--version'], { timeoutMs: VERSION_TIMEOUT_MS })
if (!probe.ok) {
const code = probe.error?.code
return {
ok: false,
reason: code === 'ENOENT' ? `yt-dlp não encontrado (${bin})` : `yt-dlp não executou (${bin})`,
}
}
return { ok: true, bin }
}
export type YtDlpMetadata = {
externalId: string | null
title: string | null
channel: string | null
durationSeconds: number | null
thumbnailUrl: string | null
}
const stringOrNull = (value: unknown): string | null =>
typeof value === 'string' && value.trim() !== '' ? value.trim() : null
const positiveNumberOrNull = (value: unknown): number | null => {
const number = Number(value)
return Number.isFinite(number) && number > 0 ? number : null
}
/** One `--dump-json` document; absent fields stay null instead of guessing. */
export const parseYtDlpMetadata = (value: unknown): YtDlpMetadata => {
const data = value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
return {
externalId: stringOrNull(data.id),
title: stringOrNull(data.title),
channel: stringOrNull(data.channel) ?? stringOrNull(data.uploader),
durationSeconds: positiveNumberOrNull(data.duration),
thumbnailUrl: stringOrNull(data.thumbnail),
}
}
const stderrTail = (result: Extract<YtDlpResult, { ok: false }>): string => {
const tail = (result.stderr || result.error?.message || '').trim().slice(-400)
return tail ? `: ${tail}` : ''
}
export const readYtDlpMetadata = async ({
bin,
url,
run = defaultRunner,
}: {
bin: string
url: string
run?: YtDlpRunner
}): Promise<YtDlpMetadata> => {
const result = await run(
bin,
['--dump-json', '--no-playlist', '--no-warnings', '--skip-download', url],
{ timeoutMs: METADATA_TIMEOUT_MS },
)
if (!result.ok) throw new Error(`yt-dlp não leu os metadados${stderrTail(result)}`)
try {
return parseYtDlpMetadata(JSON.parse(result.stdout))
} catch {
throw new Error('yt-dlp devolveu metadados fora do formato.')
}
}
export const downloadWithYtDlp = async ({
bin,
url,
outputTemplate,
ffmpegLocation = null,
run = defaultRunner,
}: {
bin: string
url: string
outputTemplate: string
ffmpegLocation?: string | null
run?: YtDlpRunner
}): Promise<void> => {
const result = await run(
bin,
[
'--no-playlist',
'--no-warnings',
'--no-progress',
'-f',
YTDLP_FORMAT,
'--merge-output-format',
'mp4',
...(ffmpegLocation ? ['--ffmpeg-location', ffmpegLocation] : []),
'-o',
outputTemplate,
url,
],
{ timeoutMs: DOWNLOAD_TIMEOUT_MS },
)
if (!result.ok) throw new Error(`yt-dlp falhou no download${stderrTail(result)}`)
}

View file

@ -45,6 +45,7 @@ const COVERAGE_QUERY = sql`
)) AS "with_segments",
COUNT(*) FILTER (WHERE s."audio_id" IS NULL AND s."youtube_url" IS NOT NULL) AS "fallback_youtube"
FROM "speech" s
WHERE s."origin" = 'camara'
GROUP BY s."legislature"
ORDER BY s."legislature" NULLS LAST
`

View file

@ -5,13 +5,22 @@ import type { Payload, RequiredDataFromCollectionSlug } from 'payload'
import type { MergedSpeechFacets } from '@/lib/speechFacets'
import { normalizeForSearch } from '@/lib/speechSearch'
import type { Speech } from '@/payload-types'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import {
withPayloadTransaction,
type PayloadTransactionRequest,
} from '@/utilities/payloadTransaction'
/**
* Idempotent persistence of one imported speech (C153). Identity is
* `sourceKey`; metadata is refreshed on every run, segments are replaced only
* when the caller supplies them, and a `manual` facet curation is never
* overwritten. Everything runs in one transaction on the Payload session.
*
* C215 — the same owner persists the web speeches: the optional origin fields
* (`origin: 'web'`, platform, canonical URL, title, channel and the mirrored
* media) travel in the bundle. The web pipeline already holds a transaction
* (media upload + upsert) and passes it via `options.req`; the Câmara path
* passes nothing and keeps opening its own.
*/
type SpeechSegmentInput = {
@ -42,6 +51,15 @@ export type SpeechImportBundle = {
excerptTMs: number | null
vodPlaybackUrl: string | null
vodDownloadUrl: string | null
/** C215 — web origin metadata (undefined on the Câmara path). */
origin?: Speech['origin']
platform?: Speech['platform']
externalId?: string | null
sourceUrl?: string | null
title?: string | null
channel?: string | null
mirroredMedia?: number | null
thumbnail?: number | null
/** Undefined preserves the stored facets (e.g. a run that skipped classification). */
facets?: MergedSpeechFacets
/** Undefined preserves the stored segments (e.g. `--skip-transcribe`). */
@ -60,6 +78,12 @@ export type SpeechImportState = {
summary: string | null
officialTranscript: string | null
officialTextUrl: string | null
origin: Speech['origin']
platform: Speech['platform'] | null
externalId: string | null
sourceUrl: string | null
mirroredMedia: number | null
thumbnail: number | null
}
export type SpeechUpsertCounts = {
@ -69,6 +93,10 @@ export type SpeechUpsertCounts = {
manualFacetsPreserved: boolean
}
/** Depth-0 upload relations arrive as the id; normalize for the state contract. */
const mediaIdOf = (media: Speech['mirroredMedia']): number | null =>
typeof media === 'object' && media !== null ? media.id : (media ?? null)
/** Current stored state of one speech, for the importer's skip decisions. */
export const findSpeechImportState = async (
payload: Payload,
@ -89,6 +117,12 @@ export const findSpeechImportState = async (
summary: true,
officialTranscript: true,
officialTextUrl: true,
origin: true,
platform: true,
externalId: true,
sourceUrl: true,
mirroredMedia: true,
thumbnail: true,
},
// Intentional bypass: the import CLI is a trusted actor with no session.
overrideAccess: true,
@ -115,152 +149,189 @@ export const findSpeechImportState = async (
summary: speech.summary ?? null,
officialTranscript: speech.officialTranscript ?? null,
officialTextUrl: speech.officialTextUrl ?? null,
origin: speech.origin,
platform: speech.platform ?? null,
externalId: speech.externalId ?? null,
sourceUrl: speech.sourceUrl ?? null,
mirroredMedia: mediaIdOf(speech.mirroredMedia),
thumbnail: mediaIdOf(speech.thumbnail),
}
}
type SpeechWriteData = RequiredDataFromCollectionSlug<'speech'>
const metadataData = (
bundle: SpeechImportBundle,
): Omit<SpeechWriteData, 'sourceKey' | 'classifiedBy' | 'searchText'> => ({
speechAt: bundle.speechAt,
year: bundle.year,
legislature: bundle.legislature,
type: bundle.type,
phase: bundle.phase,
durationSeconds: bundle.durationSeconds,
summary: bundle.summary,
officialTranscript: bundle.officialTranscript,
officialTextUrl: bundle.officialTextUrl,
keywords: bundle.keywords,
eventId: bundle.eventId,
eventType: bundle.eventType,
eventStartAt: bundle.eventStartAt,
eventEndAt: bundle.eventEndAt,
youtubeUrl: bundle.youtubeUrl,
presidingOfficer: bundle.presidingOfficer,
audioId: bundle.audioId,
excerptTMs: bundle.excerptTMs,
vodPlaybackUrl: bundle.vodPlaybackUrl,
vodDownloadUrl: bundle.vodDownloadUrl,
})
type SpeechMetadataData = Omit<SpeechWriteData, 'sourceKey' | 'classifiedBy' | 'searchText'>
export const upsertSpeechBundle = async (
const metadataData = (bundle: SpeechImportBundle): SpeechMetadataData => {
const data: SpeechMetadataData = {
origin: bundle.origin ?? 'camara',
speechAt: bundle.speechAt,
year: bundle.year,
legislature: bundle.legislature,
type: bundle.type,
phase: bundle.phase,
durationSeconds: bundle.durationSeconds,
summary: bundle.summary,
officialTranscript: bundle.officialTranscript,
officialTextUrl: bundle.officialTextUrl,
keywords: bundle.keywords,
eventId: bundle.eventId,
eventType: bundle.eventType,
eventStartAt: bundle.eventStartAt,
eventEndAt: bundle.eventEndAt,
youtubeUrl: bundle.youtubeUrl,
presidingOfficer: bundle.presidingOfficer,
audioId: bundle.audioId,
excerptTMs: bundle.excerptTMs,
vodPlaybackUrl: bundle.vodPlaybackUrl,
vodDownloadUrl: bundle.vodDownloadUrl,
}
// Web-origin fields travel only when the caller supplied them: the Câmara
// bundle omits them and Payload leaves the stored values untouched.
if (bundle.platform !== undefined) data.platform = bundle.platform
if (bundle.externalId !== undefined) data.externalId = bundle.externalId
if (bundle.sourceUrl !== undefined) data.sourceUrl = bundle.sourceUrl
if (bundle.title !== undefined) data.title = bundle.title
if (bundle.channel !== undefined) data.channel = bundle.channel
if (bundle.mirroredMedia !== undefined) data.mirroredMedia = bundle.mirroredMedia
if (bundle.thumbnail !== undefined) data.thumbnail = bundle.thumbnail
return data
}
export type SpeechUpsertOptions = {
/** Existing transaction to ride (C215 media upload + upsert); absent opens one. */
req?: PayloadTransactionRequest
}
const upsertSpeechBundleInTransaction = async (
payload: Payload,
bundle: SpeechImportBundle,
): Promise<SpeechUpsertCounts> =>
withPayloadTransaction(payload, async ({ req }) => {
const found = await payload.find({
req: PayloadTransactionRequest,
): Promise<SpeechUpsertCounts> => {
const found = await payload.find({
collection: 'speech',
where: { sourceKey: { equals: bundle.sourceKey } },
limit: 1,
depth: 0,
select: { id: true, classifiedBy: true },
req,
// Intentional bypass: the import CLI is a trusted actor with no session.
overrideAccess: true,
})
const current = found.docs[0]
const manualFacetsPreserved = current?.classifiedBy === 'manual'
// C154 — the speech-level search text mirrors the segments and is owned by
// this bundle: `segments: undefined` (skip/ASR failure) preserves whatever
// is stored, exactly like the segments themselves.
const segmentSearchText =
bundle.segments === undefined
? undefined
: normalizeForSearch(bundle.segments.map((segment) => segment.text).join(' '))
const metadata = metadataData(bundle)
const facets: Partial<SpeechWriteData> = {}
if (bundle.facets && !manualFacetsPreserved) {
const municipalitySlugs = bundle.facets.municipalities.map((entry) => entry.slug)
const municipalities =
municipalitySlugs.length === 0
? []
: (
await payload.find({
collection: 'municipality',
where: { slug: { in: municipalitySlugs } },
limit: municipalitySlugs.length,
depth: 0,
select: { slug: true },
req,
// Intentional bypass: resolving gazetteer slugs inside the import transaction.
overrideAccess: true,
})
).docs
facets.topics = bundle.facets.topics
facets.scopes = bundle.facets.scopes
facets.classifiedBy = bundle.facets.classifiedBy
facets.mentionedMunicipalities = municipalities.map((doc) => doc.id)
facets.mentionedPeople = bundle.facets.people
facets.mentionedPrograms = bundle.facets.programs
facets.mentionedProjects = bundle.facets.projects
}
const data = { ...metadata, ...facets }
let speechId: number
let created = false
if (current) {
await payload.update({
collection: 'speech',
where: { sourceKey: { equals: bundle.sourceKey } },
limit: 1,
id: current.id,
data: segmentSearchText === undefined ? data : { ...data, searchText: segmentSearchText },
depth: 0,
select: { id: true, classifiedBy: true },
req,
// Intentional bypass: the import CLI is a trusted actor with no session.
overrideAccess: true,
})
const current = found.docs[0]
const manualFacetsPreserved = current?.classifiedBy === 'manual'
speechId = current.id
} else {
const speech = await payload.create({
collection: 'speech',
data: {
sourceKey: bundle.sourceKey,
classifiedBy: bundle.facets?.classifiedBy ?? 'gazetteer',
searchText: segmentSearchText ?? '',
...data,
},
depth: 0,
req,
// Intentional bypass: the import CLI is a trusted actor with no session.
overrideAccess: true,
})
speechId = speech.id
created = true
}
// C154 — the speech-level search text mirrors the segments and is owned by
// this bundle: `segments: undefined` (skip/ASR failure) preserves whatever
// is stored, exactly like the segments themselves.
const segmentSearchText =
bundle.segments === undefined
? undefined
: normalizeForSearch(bundle.segments.map((segment) => segment.text).join(' '))
let segmentsInserted = 0
let segmentsDeleted = 0
if (bundle.segments !== undefined) {
const removed = await payload.delete({
collection: 'speechSegment',
where: { speech: { equals: speechId } },
req,
// Intentional bypass: replacing segments owned by the speech being imported.
overrideAccess: true,
})
segmentsDeleted = removed.docs.length
const metadata = metadataData(bundle)
const facets: Partial<SpeechWriteData> = {}
if (bundle.facets && !manualFacetsPreserved) {
const municipalitySlugs = bundle.facets.municipalities.map((entry) => entry.slug)
const municipalities =
municipalitySlugs.length === 0
? []
: (
await payload.find({
collection: 'municipality',
where: { slug: { in: municipalitySlugs } },
limit: municipalitySlugs.length,
depth: 0,
select: { slug: true },
req,
// Intentional bypass: resolving gazetteer slugs inside the import transaction.
overrideAccess: true,
})
).docs
facets.topics = bundle.facets.topics
facets.scopes = bundle.facets.scopes
facets.classifiedBy = bundle.facets.classifiedBy
facets.mentionedMunicipalities = municipalities.map((doc) => doc.id)
facets.mentionedPeople = bundle.facets.people
facets.mentionedPrograms = bundle.facets.programs
facets.mentionedProjects = bundle.facets.projects
}
const data = { ...metadata, ...facets }
let speechId: number
let created = false
if (current) {
await payload.update({
collection: 'speech',
id: current.id,
data: segmentSearchText === undefined ? data : { ...data, searchText: segmentSearchText },
depth: 0,
req,
// Intentional bypass: the import CLI is a trusted actor with no session.
overrideAccess: true,
})
speechId = current.id
} else {
const speech = await payload.create({
collection: 'speech',
data: {
sourceKey: bundle.sourceKey,
classifiedBy: bundle.facets?.classifiedBy ?? 'gazetteer',
searchText: segmentSearchText ?? '',
...data,
},
depth: 0,
req,
// Intentional bypass: the import CLI is a trusted actor with no session.
overrideAccess: true,
})
speechId = speech.id
created = true
}
let segmentsInserted = 0
let segmentsDeleted = 0
if (bundle.segments !== undefined) {
const removed = await payload.delete({
for (const [index, segment] of bundle.segments.entries()) {
await payload.create({
collection: 'speechSegment',
where: { speech: { equals: speechId } },
data: {
speech: speechId,
order: index + 1,
startSeconds: segment.startSeconds,
endSeconds: segment.endSeconds,
text: segment.text,
searchText: normalizeForSearch(segment.text),
},
req,
// Intentional bypass: replacing segments owned by the speech being imported.
// Intentional bypass: the import CLI is a trusted actor with no session.
overrideAccess: true,
})
segmentsDeleted = removed.docs.length
for (const [index, segment] of bundle.segments.entries()) {
await payload.create({
collection: 'speechSegment',
data: {
speech: speechId,
order: index + 1,
startSeconds: segment.startSeconds,
endSeconds: segment.endSeconds,
text: segment.text,
searchText: normalizeForSearch(segment.text),
},
req,
// Intentional bypass: the import CLI is a trusted actor with no session.
overrideAccess: true,
})
segmentsInserted += 1
}
segmentsInserted += 1
}
}
return { created, segmentsInserted, segmentsDeleted, manualFacetsPreserved }
})
return { created, segmentsInserted, segmentsDeleted, manualFacetsPreserved }
}
export const upsertSpeechBundle = async (
payload: Payload,
bundle: SpeechImportBundle,
options: SpeechUpsertOptions = {},
): Promise<SpeechUpsertCounts> => {
if (options.req) return upsertSpeechBundleInTransaction(payload, bundle, options.req)
return withPayloadTransaction(payload, ({ req }) =>
upsertSpeechBundleInTransaction(payload, bundle, req),
)
}

View file

@ -32,7 +32,9 @@ const durationBucketWhere = (bucket: SpeechDurationBucket): Where => {
}
const buildSpeechFacetWhere = (state: SpeechListState): Where[] => {
const filters: Where[] = []
// C215 — this contract is the Câmara list: the web speeches share the table
// but are served by their own source (C216), never mixed here.
const filters: Where[] = [{ origin: { equals: 'camara' } }]
if (state.years?.length) filters.push({ year: { in: state.years } })
if (state.topics?.length) filters.push({ topics: { in: state.topics } })

View file

@ -91,6 +91,9 @@ const loadSpeechFilterOptions = async (
depth: 0,
limit: 0,
pagination: false,
// C215 — options are the Câmara catalog's; the web source (C216) builds
// its own options from its rows.
where: { origin: { equals: 'camara' } },
select: { year: true, phase: true, mentionedMunicipalities: true },
user,
overrideAccess: false,

View file

@ -0,0 +1,514 @@
import 'server-only'
import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Payload } from 'payload'
import {
buildRecordingAudioFfmpegArgs,
mergeChunkTranscriptions,
RECORDING_AUDIO_CHUNK_SECONDS,
type RecordingChunkTranscription,
type RecordingTranscriptSegment,
} from '@/lib/recordingTranscription'
import type { SpeechFacetInput } from '@/lib/speechGazetteer'
import {
canonicalWebSpeechUrl,
INTERNET_SPEECH_MEDIA_SLUG,
isCompleteWebSpeechState,
requiresDirectMediaUrl,
webSpeechAtFromPublishedAt,
webSpeechPlatformLabel,
webSpeechSourceKey,
type WebSpeechFinding,
} from '@/lib/webSpeech'
import {
DEEPINFRA_TRANSCRIBE_COST_PER_MINUTE_USD,
deepInfraTranscribeSegments,
type TranscribeSegmentsResult,
} from '@/utilities/ai/deepInfraTranscribe'
import { downloadUrlToFile } from '@/utilities/media/downloadToFile'
import { ffmpegBinary, messageOf, runFfmpeg } from '@/utilities/media/ffmpeg'
import {
downloadWithYtDlp,
readYtDlpMetadata,
resolveYtDlp,
YTDLP_INSTALL_HINT,
type YtDlpMetadata,
} from '@/utilities/media/ytdlp'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import {
classifySpeech,
type SpeechClassificationResult,
} from '@/utilities/speech/speechClassifier'
import {
findSpeechImportState,
upsertSpeechBundle,
type SpeechImportBundle,
} from '@/utilities/speech/speechImport'
/**
* C215 — one web speech end to end: acquire the media (yt-dlp or direct HTTP),
* transcribe with timestamps (the C199 chunk owner), classify (the C153 owner),
* mirror the artifact in the private collection and upsert idempotently (the
* Câmara import owner). Every dependency is injectable so the int specs run
* offline; the CLI is the only caller.
*/
/** Audio extraction has no duration to scale the timeout from; 30 min is ample. */
const FFMPEG_TIMEOUT_MS = 30 * 60_000
const ACQUISITION_FAILURE = 'Não foi possível baixar a mídia da fala.'
const TRANSCRIPTION_FAILURE = 'Não foi possível transcrever a fala.'
const MEDIA_FAILURE = 'Não foi possível guardar a mídia da fala.'
const CLASSIFICATION_FAILURE = 'Não foi possível classificar a fala.'
type WebSpeechIngestStatus = 'created' | 'updated' | 'skipped' | 'failed'
type WebSpeechIngestStage =
| 'validation'
| 'acquisition'
| 'transcription'
| 'classification'
| 'media'
| 'upsert'
export type WebSpeechIngestResult = {
sourceKey: string
status: WebSpeechIngestStatus
stage: WebSpeechIngestStage | null
error: string | null
asrSeconds: number
asrCostUsd: number
llmCostUsd: number | null
}
type WebSpeechAcquisition = {
/** Produced file inside the temp dir, extension included. */
fileName: string
metadata: YtDlpMetadata | null
}
export type WebSpeechAcquirer = (
finding: WebSpeechFinding,
tempDir: string,
) => Promise<WebSpeechAcquisition>
export type WebSpeechTranscriber = (
file: Blob,
options?: { filename?: string },
) => Promise<TranscribeSegmentsResult>
export type WebSpeechClassifier = (input: SpeechFacetInput) => Promise<SpeechClassificationResult>
export type WebSpeechIngestDeps = {
acquire?: WebSpeechAcquirer
transcribe?: WebSpeechTranscriber
classify?: WebSpeechClassifier
runFfmpeg?: typeof runFfmpeg
downloadThumbnail?: (url: string, destinationPath: string) => Promise<void>
}
class WebSpeechIngestError extends Error {
readonly stage: WebSpeechIngestStage
constructor(stage: WebSpeechIngestStage, message: string) {
super(message)
this.stage = stage
}
}
const extensionOf = (url: string, fallback: string): string => {
try {
const name = new URL(url).pathname.split('/').pop() ?? ''
const index = name.lastIndexOf('.')
const extension = index >= 0 ? name.slice(index).toLowerCase() : ''
return /^\.[a-z0-9]{1,5}$/.test(extension) ? extension : fallback
} catch {
return fallback
}
}
/** yt-dlp for YouTube/Instagram; direct mediaUrl for radio/audio. */
const defaultAcquire: WebSpeechAcquirer = async (finding, tempDir) => {
if (requiresDirectMediaUrl(finding.platform)) {
if (!finding.mediaUrl) throw new Error('Achado sem mediaUrl.')
const fileName = `source${extensionOf(finding.mediaUrl, '.mp3')}`
await downloadUrlToFile({ url: finding.mediaUrl, destinationPath: join(tempDir, fileName) })
return { fileName, metadata: null }
}
const resolved = await resolveYtDlp()
if (!resolved.ok) throw new Error(`${resolved.reason}. ${YTDLP_INSTALL_HINT}`)
const metadata = await readYtDlpMetadata({ bin: resolved.bin, url: finding.url })
await downloadWithYtDlp({
bin: resolved.bin,
url: finding.url,
outputTemplate: join(tempDir, 'source.%(ext)s'),
ffmpegLocation: ffmpegBinary(),
})
const fileName = (await readdir(tempDir)).find((name) => name.startsWith('source.'))
if (!fileName) throw new Error('yt-dlp não produziu arquivo.')
return { fileName, metadata }
}
type BundleMetadata = {
title?: string | null
channel?: string | null
externalId?: string | null
durationSeconds?: number | null
}
/**
* The web bundle: Câmara-only fields stay null (no legislature, no VOD, no
* official transcript) and the web metadata travels only when known —
* `undefined` preserves the stored value on a metadata refresh.
*/
const baseBundle = (
finding: WebSpeechFinding,
sourceKey: string,
speechAt: string,
metadata: BundleMetadata,
): SpeechImportBundle => ({
sourceKey,
origin: 'web',
platform: finding.platform,
externalId: metadata.externalId,
sourceUrl: canonicalWebSpeechUrl(finding.platform, finding.url),
title: metadata.title,
channel: metadata.channel,
speechAt,
year: Number(speechAt.slice(0, 4)),
legislature: null,
type: null,
phase: null,
durationSeconds: metadata.durationSeconds ?? null,
summary: null,
officialTranscript: null,
officialTextUrl: null,
keywords: [],
eventId: null,
eventType: null,
eventStartAt: null,
eventEndAt: null,
youtubeUrl: null,
presidingOfficer: null,
audioId: null,
excerptTMs: null,
vodPlaybackUrl: null,
vodDownloadUrl: null,
})
const emptyResult = (
sourceKey: string,
status: WebSpeechIngestStatus,
stage: WebSpeechIngestStage | null,
error: string | null,
): WebSpeechIngestResult => ({
sourceKey,
status,
stage,
error,
asrSeconds: 0,
asrCostUsd: 0,
llmCostUsd: null,
})
type ExtractedTranscript = {
segments: RecordingTranscriptSegment[]
durationSeconds: number | null
asrSeconds: number
}
/** Extract → chunk → transcribe → merge (the C199 owners, relative offsets). */
const extractTranscript = async ({
ffmpeg,
transcribe,
inputPath,
tempDir,
declaredDuration,
}: {
ffmpeg: typeof runFfmpeg
transcribe: WebSpeechTranscriber
inputPath: string
tempDir: string
declaredDuration: number | null
}): Promise<ExtractedTranscript> => {
const outputPattern = join(tempDir, 'chunk-%03d.mp3')
await ffmpeg(
buildRecordingAudioFfmpegArgs({ inputPath, outputPattern }),
declaredDuration ?? 0,
TRANSCRIPTION_FAILURE,
FFMPEG_TIMEOUT_MS,
)
const chunkFiles = (await readdir(tempDir)).filter((name) => /^chunk-\d+\.mp3$/.test(name)).sort()
if (chunkFiles.length === 0) throw new Error(TRANSCRIPTION_FAILURE)
const chunks: RecordingChunkTranscription[] = []
let asrSeconds = 0
let measuredDuration = 0
for (const [index, chunkFile] of chunkFiles.entries()) {
const bytes = await readFile(join(tempDir, chunkFile))
const result = await transcribe(new Blob([bytes], { type: 'audio/mpeg' }), {
filename: chunkFile,
})
if (!result.ok) throw new Error(result.error)
chunks.push({
offsetSeconds: index * RECORDING_AUDIO_CHUNK_SECONDS,
segments: result.segments,
})
asrSeconds += result.durationSeconds ?? RECORDING_AUDIO_CHUNK_SECONDS
if (result.durationSeconds !== null) measuredDuration += result.durationSeconds
}
const segments = mergeChunkTranscriptions(chunks)
if (segments.length === 0) throw new Error(TRANSCRIPTION_FAILURE)
// Only measured data (or the declared duration / the last segment end) —
// never the chunk count as a duration.
const lastSegmentEnd = segments.at(-1)?.endSeconds
const durationSeconds =
measuredDuration > 0
? Math.round(measuredDuration)
: (declaredDuration ?? (lastSegmentEnd !== undefined ? Math.round(lastSegmentEnd) : null))
return { segments, durationSeconds, asrSeconds }
}
/**
* The artifact and the speech in ONE transaction (`req` threaded through the
* upload and the upsert). Throws `WebSpeechIngestError('media')`; the caller
* owns the best-effort cleanup of a thumbnail created before the failure.
*/
const persistIngestedSpeech = async ({
payload,
finding,
sourceKey,
speechAt,
metadata,
facets,
segments,
durationSeconds,
inputPath,
thumbnail,
}: {
payload: Payload
finding: WebSpeechFinding
sourceKey: string
speechAt: string
metadata: { title: string | null; channel: string | null; externalId: string | null }
facets: SpeechClassificationResult['facets']
segments: RecordingTranscriptSegment[]
durationSeconds: number | null
inputPath: string
thumbnail: number | null
}): Promise<void> => {
const alt = `${webSpeechPlatformLabel(finding.platform)} — ${metadata.title ?? speechAt}`
try {
await withPayloadTransaction(payload, async ({ req }) => {
const media = await payload.create({
collection: INTERNET_SPEECH_MEDIA_SLUG,
data: { alt },
filePath: inputPath,
req,
// Intentional bypass: the ingestion CLI is a trusted actor with no session.
overrideAccess: true,
})
await upsertSpeechBundle(
payload,
{
...baseBundle(finding, sourceKey, speechAt, { ...metadata, durationSeconds }),
facets,
segments: segments.map((segment) => ({
startSeconds: segment.startSeconds,
endSeconds: segment.endSeconds,
text: segment.text,
})),
mirroredMedia: media.id,
thumbnail,
},
{ req },
)
})
} catch (error) {
throw new WebSpeechIngestError('media', messageOf(error, MEDIA_FAILURE))
}
}
export const ingestWebSpeech = async (
payload: Payload,
finding: WebSpeechFinding,
{ reprocess = false, deps = {} }: { reprocess?: boolean; deps?: WebSpeechIngestDeps } = {},
): Promise<WebSpeechIngestResult> => {
const sourceKey = webSpeechSourceKey(finding)
const speechAt = webSpeechAtFromPublishedAt(finding.publishedAt)
if (!speechAt)
return emptyResult(sourceKey, 'failed', 'validation', 'Data de publicação inválida.')
const acquire = deps.acquire ?? defaultAcquire
const transcribe = deps.transcribe ?? deepInfraTranscribeSegments
const classify = deps.classify ?? classifySpeech
const ffmpeg = deps.runFfmpeg ?? runFfmpeg
const downloadThumbnail =
deps.downloadThumbnail ??
((url, destinationPath) => downloadUrlToFile({ url, destinationPath }))
let asrSeconds = 0
let tempDir: string | null = null
try {
const state = await findSpeechImportState(payload, sourceKey)
if (isCompleteWebSpeechState(state) && !reprocess) {
// Complete row: refresh only the metadata the finding brought (undefined
// preserves the stored values); never re-download or re-transcribe.
await upsertSpeechBundle(
payload,
baseBundle(finding, sourceKey, speechAt, {
title: finding.title,
channel: finding.channel,
externalId: finding.externalId,
durationSeconds: finding.durationSeconds ?? state?.durationSeconds ?? undefined,
}),
)
return emptyResult(sourceKey, 'skipped', null, null)
}
tempDir = await mkdtemp(join(tmpdir(), 'web-speech-'))
let acquisition: WebSpeechAcquisition
try {
acquisition = await acquire(finding, tempDir)
} catch (error) {
throw new WebSpeechIngestError('acquisition', messageOf(error, ACQUISITION_FAILURE))
}
const inputPath = join(tempDir, acquisition.fileName)
const metadata = acquisition.metadata
const title = finding.title ?? metadata?.title ?? null
const channel = finding.channel ?? metadata?.channel ?? null
const externalId = finding.externalId ?? metadata?.externalId ?? null
const declaredDuration = finding.durationSeconds ?? metadata?.durationSeconds ?? null
const thumbnailUrl = finding.thumbnailUrl ?? metadata?.thumbnailUrl ?? null
let extracted: ExtractedTranscript
try {
extracted = await extractTranscript({
ffmpeg,
transcribe,
inputPath,
tempDir,
declaredDuration,
})
} catch (error) {
throw new WebSpeechIngestError('transcription', messageOf(error, TRANSCRIPTION_FAILURE))
}
asrSeconds = extracted.asrSeconds
let classification: SpeechClassificationResult
try {
classification = await classify({
transcript: extracted.segments.map((segment) => segment.text).join(' '),
summary: title,
keywords: [],
})
} catch (error) {
throw new WebSpeechIngestError('classification', messageOf(error, CLASSIFICATION_FAILURE))
}
// Thumbnail is best effort and lives OUTSIDE the main transaction: a broken
// image (or a failed upload) must never lose the speech.
let thumbnailId: number | null = null
if (thumbnailUrl) {
try {
const destination = join(tempDir, `thumbnail${extensionOf(thumbnailUrl, '.jpg')}`)
await downloadThumbnail(thumbnailUrl, destination)
const thumbnail = await payload.create({
collection: INTERNET_SPEECH_MEDIA_SLUG,
data: { alt: `Capa — ${title ?? speechAt}` },
filePath: destination,
// Intentional bypass: the ingestion CLI is a trusted actor with no session.
overrideAccess: true,
})
thumbnailId = thumbnail.id
} catch {
thumbnailId = null
}
}
const previousThumbnail = state?.thumbnail ?? null
const previousMediaIds = [
...(typeof state?.mirroredMedia === 'number' ? [state.mirroredMedia] : []),
// Only a replaced thumbnail is deleted; keeping it is the fallback when
// the new download failed.
...(thumbnailId !== null && typeof previousThumbnail === 'number' ? [previousThumbnail] : []),
]
try {
await persistIngestedSpeech({
payload,
finding,
sourceKey,
speechAt,
metadata: { title, channel, externalId },
facets: classification.facets,
segments: extracted.segments,
durationSeconds: extracted.durationSeconds,
inputPath,
thumbnail: thumbnailId ?? previousThumbnail,
})
} catch (error) {
if (thumbnailId !== null) {
await payload
.delete({
collection: INTERNET_SPEECH_MEDIA_SLUG,
id: thumbnailId,
// Intentional bypass: the pipeline cleans up the file it just created.
overrideAccess: true,
})
.catch(() => undefined)
}
throw error
}
// Replaced artifacts are removed best effort: an orphan object in the
// private bucket is harmless, a leaked row is not.
if (previousMediaIds.length > 0) {
await payload
.delete({
collection: INTERNET_SPEECH_MEDIA_SLUG,
where: { id: { in: previousMediaIds } },
// Intentional bypass: the pipeline replaces the files it owns.
overrideAccess: true,
})
.catch(() => undefined)
}
return {
sourceKey,
status: state ? 'updated' : 'created',
stage: null,
error: null,
asrSeconds,
asrCostUsd: (asrSeconds * DEEPINFRA_TRANSCRIBE_COST_PER_MINUTE_USD) / 60,
llmCostUsd: classification.llm.estimatedCostUsd,
}
} catch (error) {
const asrCostUsd = (asrSeconds * DEEPINFRA_TRANSCRIBE_COST_PER_MINUTE_USD) / 60
if (error instanceof WebSpeechIngestError) {
return {
...emptyResult(sourceKey, 'failed', error.stage, error.message),
asrSeconds,
asrCostUsd,
}
}
return {
...emptyResult(sourceKey, 'failed', null, messageOf(error, 'Falha na ingestão da fala.')),
asrSeconds,
asrCostUsd,
}
} finally {
if (tempDir) await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
}
}

View file

@ -51,6 +51,7 @@ const createSpeech = async (
collection: 'speech',
data: {
sourceKey: campaign.fixtures.value('speech'),
origin: 'camara',
speechAt: input.speechAt ?? '2026-03-10T18:00',
type: 'BREVES COMUNICAÇÕES',
phase: 'Breves Comunicações',

View file

@ -35,6 +35,7 @@ const createSpeech = async (
collection: 'speech',
data: {
sourceKey: campaign.fixtures.value('speech'),
origin: 'camara',
speechAt: '2026-08-11T18:48',
type: 'BREVES COMUNICAÇÕES',
phase: 'Breves Comunicações',

View file

@ -65,6 +65,7 @@ describe('institution snapshot (C187)', () => {
collection: 'speech',
data: {
sourceKey: `test-inst-${randomUUID()}`,
origin: 'camara',
speechAt: '2024-05-10T17:28',
type: 'DISCURSO',
phase: 'Discurso',

View file

@ -27,6 +27,7 @@ const createSpeech = async (
collection: 'speech',
data: {
sourceKey: `test-${randomUUID()}`,
origin: 'camara',
speechAt: '2023-02-07T17:28',
type: 'BREVES COMUNICAÇÕES',
phase: 'Breves Comunicações',
@ -199,6 +200,7 @@ describe('speech catalog (C153)', () => {
collection: 'speech',
data: {
sourceKey: `test-${randomUUID()}`,
origin: 'camara',
speechAt: '2023-02-08T10:00',
classifiedBy: 'gazetteer',
},
@ -223,6 +225,7 @@ describe('speech catalog (C153)', () => {
collection: 'speech',
data: {
sourceKey: `test-${randomUUID()}`,
origin: 'camara',
speechAt: '2023-02-08T10:00',
classifiedBy: 'gazetteer',
},

View file

@ -20,11 +20,12 @@ type SpeechCreateData = RequiredDataFromCollectionSlug<'speech'>
const createSpeech = (
transactionID: number | string,
data: SpeechCreateData,
data: Omit<SpeechCreateData, 'origin'>,
): Promise<{ id: number }> =>
payload.create({
collection: 'speech',
data,
// C215 — this spec measures the Câmara coverage.
data: { origin: 'camara', ...data },
depth: 0,
req: { transactionID },
// Intentional bypass: the spec seeds its own rows.

View file

@ -59,6 +59,7 @@ describe('theme snapshot (C190)', () => {
collection: 'speech',
data: {
sourceKey: `test-theme-${randomUUID()}`,
origin: 'camara',
speechAt: '2024-05-10T17:28',
type: 'DISCURSO',
phase: 'Discurso',

View file

@ -0,0 +1,205 @@
// @vitest-environment node
import { randomUUID } from 'node:crypto'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { matchMunicipalityMentions } from '@/lib/speechGazetteer'
import { INTERNET_SPEECH_MEDIA_SLUG } from '@/lib/webSpeech'
import type { InternetSpeechMedia } from '@/payload-types'
import config from '@/payload.config'
import {
findSpeechImportState,
upsertSpeechBundle,
type SpeechImportBundle,
} from '@/utilities/speech/speechImport'
// C215 — the web-speech fields of the shared upsert: round-trip, idempotency
// (metadata refreshes, segments/facets preserved when omitted), manual
// curation wins and the speech delete cascades over the mirrored media.
let payload: Payload
const createdSourceKeys = new Set<string>()
const MEDIA_BYTES = Buffer.from('web-speech-media')
const createMedia = async (name: string): Promise<InternetSpeechMedia> =>
payload.create({
collection: INTERNET_SPEECH_MEDIA_SLUG,
data: { alt: `Arquivo ${name}` },
file: { data: MEDIA_BYTES, mimetype: 'video/mp4', name, size: MEDIA_BYTES.length },
overrideAccess: true,
})
const webBundle = (overrides: Partial<SpeechImportBundle> = {}): SpeechImportBundle => ({
sourceKey: `web:test-${randomUUID()}`,
origin: 'web',
platform: 'youtube',
externalId: 'abc123',
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
title: 'Entrevista na rádio',
channel: 'Canal do Solla',
speechAt: '2026-09-20T00:00',
year: 2026,
legislature: null,
type: null,
phase: null,
durationSeconds: 180,
summary: null,
officialTranscript: null,
officialTextUrl: null,
keywords: [],
eventId: null,
eventType: null,
eventStartAt: null,
eventEndAt: null,
youtubeUrl: null,
presidingOfficer: null,
audioId: null,
excerptTMs: null,
vodPlaybackUrl: null,
vodDownloadUrl: null,
facets: {
topics: ['saude'],
scopes: ['bahia'],
municipalities: matchMunicipalityMentions('Feira de Santana'),
people: ['Lula'],
programs: [],
projects: [],
classifiedBy: 'gazetteer',
},
segments: [{ startSeconds: 0, endSeconds: 2.5, text: 'A saúde pública baiana' }],
...overrides,
})
const findBySourceKey = async (sourceKey: string) => {
const found = await payload.find({
collection: 'speech',
where: { sourceKey: { equals: sourceKey } },
depth: 1,
overrideAccess: true,
})
return found.docs[0] ?? null
}
describe('upsertSpeechBundle web origin (C215)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
afterAll(async () => {
for (const sourceKey of createdSourceKeys) {
const speech = await findBySourceKey(sourceKey)
if (!speech) continue
await payload.delete({ collection: 'speech', id: speech.id, depth: 0, overrideAccess: true })
}
})
it('persists the origin fields and the mirrored media', async () => {
const media = await createMedia('fala.mp4')
const bundle = webBundle({ mirroredMedia: media.id })
createdSourceKeys.add(bundle.sourceKey)
const counts = await upsertSpeechBundle(payload, bundle)
expect(counts.created).toBe(true)
const speech = await findBySourceKey(bundle.sourceKey)
expect(speech).toMatchObject({
origin: 'web',
platform: 'youtube',
externalId: 'abc123',
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
title: 'Entrevista na rádio',
channel: 'Canal do Solla',
speechAt: '2026-09-20T00:00',
year: 2026,
durationSeconds: 180,
classifiedBy: 'gazetteer',
topics: ['saude'],
})
expect(speech?.mirroredMedia).toMatchObject({ id: media.id })
expect(speech?.mentionedMunicipalities?.[0]).toMatchObject({ slug: 'feira-de-santana' })
const state = await findSpeechImportState(payload, bundle.sourceKey)
expect(state).toMatchObject({
origin: 'web',
platform: 'youtube',
externalId: 'abc123',
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
mirroredMedia: media.id,
thumbnail: null,
segmentCount: 1,
})
})
it('refreshes metadata on re-run and preserves omitted segments/facets', async () => {
const bundle = webBundle()
createdSourceKeys.add(bundle.sourceKey)
await upsertSpeechBundle(payload, bundle)
const counts = await upsertSpeechBundle(
payload,
webBundle({
sourceKey: bundle.sourceKey,
title: 'Título atualizado',
segments: undefined,
facets: undefined,
}),
)
expect(counts.created).toBe(false)
expect(counts.segmentsInserted).toBe(0)
const speech = await findBySourceKey(bundle.sourceKey)
expect(speech?.title).toBe('Título atualizado')
expect(speech?.searchText).toBe('a saude publica baiana')
expect(speech?.topics).toEqual(['saude'])
const segments = await payload.count({
collection: 'speechSegment',
where: { speech: { equals: speech?.id } },
overrideAccess: true,
})
expect(segments.totalDocs).toBe(1)
})
it('never overwrites a manual facet curation with the web metadata', async () => {
const bundle = webBundle()
createdSourceKeys.add(bundle.sourceKey)
await upsertSpeechBundle(payload, bundle)
const speech = await findBySourceKey(bundle.sourceKey)
await payload.update({
collection: 'speech',
id: speech?.id,
data: { classifiedBy: 'manual', topics: ['cultura'], title: 'Curado à mão' },
depth: 0,
overrideAccess: true,
})
await upsertSpeechBundle(payload, webBundle({ sourceKey: bundle.sourceKey }))
const again = await findBySourceKey(bundle.sourceKey)
expect(again?.topics).toEqual(['cultura'])
expect(again?.classifiedBy).toBe('manual')
// Manual curation is facets only: origin metadata still refreshes.
expect(again?.title).toBe('Entrevista na rádio')
})
it('deletes the mirrored media when the speech is deleted', async () => {
const media = await createMedia('cascata.mp4')
const bundle = webBundle({ mirroredMedia: media.id })
createdSourceKeys.add(bundle.sourceKey)
await upsertSpeechBundle(payload, bundle)
const speech = await findBySourceKey(bundle.sourceKey)
await payload.delete({ collection: 'speech', id: speech?.id, depth: 0, overrideAccess: true })
const mediaAfter = await payload
.findByID({
collection: INTERNET_SPEECH_MEDIA_SLUG,
id: media.id,
overrideAccess: true,
})
.catch(() => null)
expect(mediaAfter).toBeNull()
})
})

View file

@ -0,0 +1,355 @@
// @vitest-environment node
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { matchMunicipalityMentions } from '@/lib/speechGazetteer'
import { INTERNET_SPEECH_MEDIA_SLUG, type WebSpeechFinding } from '@/lib/webSpeech'
import type { Speech } from '@/payload-types'
import config from '@/payload.config'
import {
ingestWebSpeech,
type WebSpeechAcquirer,
type WebSpeechClassifier,
type WebSpeechIngestDeps,
type WebSpeechTranscriber,
} from '@/utilities/speech/webSpeechIngest'
// C215 — the ingestion pipeline with injected seams: acquire → transcribe →
// classify → mirror → upsert. The fakes keep it offline; the assertions pin
// idempotency (second run only refreshes metadata), --reprocess, the mirrored
// private file and the honest failure stages.
let payload: Payload
const acquireCalls: string[] = []
// Real magic bytes so Payload's own detection derives the mimeType (the fake
// pipeline writes files, but the upload must behave like production).
const MP4_BYTES = Buffer.from('00000018667479706d703432000000006d70343269736f6d', 'hex')
const MP3_BYTES = Buffer.from('fffb900000000000000000000000000000000000', 'hex')
// Real 2x2 JPEG: Payload runs sharp over image uploads (focal point), so the
// fake thumbnail must decode like a real one.
const JPEG_BYTES = Buffer.from(
'/9j/2wBDAA0JCgsKCA0LCgsODg0PEyAVExISEyccHhcgLikxMC4pLSwzOko+MzZGNywtQFdBRkxOUlNSMj5aYVpQYEpRUk//2wBDAQ4ODhMREyYVFSZPNS01T09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT0//wAARCAACAAIDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAX/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAABAb/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCGACsH/9k=',
'base64',
)
// Every key these specs may touch, in both the external-id and the URL form:
// a failed attempt must never leak a row into the retry.
const TEST_SOURCE_KEYS = [
'web:youtube:abc123',
'web:youtube:https://www.youtube.com/watch?v=abc123',
'web:youtube:reproc',
'web:youtube:https://www.youtube.com/watch?v=reproc',
'web:youtube:thumb',
'web:youtube:https://www.youtube.com/watch?v=thumb',
'web:youtube:thumb-keep',
'web:youtube:https://www.youtube.com/watch?v=thumb-keep',
'web:youtube:broken',
'web:youtube:https://www.youtube.com/watch?v=broken',
'web:youtube:semvoz',
'web:youtube:https://www.youtube.com/watch?v=semvoz',
'web:radio:https://radio.example/entrevista',
]
const cleanup = async (): Promise<void> => {
await payload.delete({
collection: 'speech',
where: { sourceKey: { in: TEST_SOURCE_KEYS } },
overrideAccess: true,
})
}
const finding = (overrides: Partial<WebSpeechFinding> = {}): WebSpeechFinding => ({
platform: 'youtube',
url: 'https://www.youtube.com/watch?v=abc123',
publishedAt: '2026-09-20',
...overrides,
})
const acquire: WebSpeechAcquirer = async (current, tempDir) => {
acquireCalls.push(current.url)
const fileName = 'source.mp4'
await writeFile(join(tempDir, fileName), MP4_BYTES)
return {
fileName,
metadata: {
externalId: 'abc123',
title: 'Título do yt-dlp',
channel: 'Canal do yt-dlp',
durationSeconds: 12,
thumbnailUrl: null,
},
}
}
const transcribe: WebSpeechTranscriber = async () => ({
ok: true,
segments: [{ start: 0, end: 2.5, text: 'A saúde pública em Feira de Santana' }],
durationSeconds: 2.5,
})
const classify: WebSpeechClassifier = async () => ({
facets: {
topics: ['saude'],
scopes: ['bahia'],
municipalities: matchMunicipalityMentions('Feira de Santana'),
people: [],
programs: [],
projects: [],
classifiedBy: 'gazetteer',
},
llm: { used: false, totalTokens: null, estimatedCostUsd: null, error: null },
})
const fakeFfmpeg: WebSpeechIngestDeps['runFfmpeg'] = async (args) => {
const outputPattern = args.at(-1) ?? ''
if (!outputPattern.includes('%03d')) throw new Error('fake ffmpeg sem outputPattern')
await writeFile(outputPattern.replace('%03d', '000'), MP3_BYTES)
}
const deps = (overrides: WebSpeechIngestDeps = {}): WebSpeechIngestDeps => ({
acquire,
transcribe,
classify,
runFfmpeg: fakeFfmpeg,
...overrides,
})
const findBySourceKey = async (sourceKey: string): Promise<Speech | null> => {
const found = await payload.find({
collection: 'speech',
where: { sourceKey: { equals: sourceKey } },
depth: 1,
overrideAccess: true,
})
return found.docs[0] ?? null
}
const countSegments = async (speechId: number): Promise<number> =>
(
await payload.count({
collection: 'speechSegment',
where: { speech: { equals: speechId } },
overrideAccess: true,
})
).totalDocs
describe('ingestWebSpeech (C215)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
await cleanup()
})
afterAll(async () => {
await cleanup()
})
it('ingests one finding end to end and re-running only refreshes metadata', async () => {
acquireCalls.length = 0
const current = finding({ externalId: 'abc123', title: 'Título do lote' })
const sourceKey = 'web:youtube:abc123'
const first = await ingestWebSpeech(payload, current, { deps: deps() })
expect(first).toMatchObject({
sourceKey,
status: 'created',
stage: null,
error: null,
})
expect(first.asrSeconds).toBe(2.5)
expect(first.asrCostUsd).toBeGreaterThan(0)
const speech = await findBySourceKey(sourceKey)
expect(speech).toMatchObject({
origin: 'web',
platform: 'youtube',
externalId: 'abc123',
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
title: 'Título do lote',
channel: 'Canal do yt-dlp',
speechAt: '2026-09-20T00:00',
year: 2026,
durationSeconds: 3,
classifiedBy: 'gazetteer',
topics: ['saude'],
})
expect(speech?.searchText).toBe('a saude publica em feira de santana')
expect(speech?.mentionedMunicipalities?.[0]).toMatchObject({ slug: 'feira-de-santana' })
const media = typeof speech?.mirroredMedia === 'object' ? speech.mirroredMedia : null
expect(media?.mimeType).toBe('video/mp4')
expect(await countSegments(speech?.id ?? 0)).toBe(1)
expect(acquireCalls).toHaveLength(1)
const second = await ingestWebSpeech(payload, current, { deps: deps() })
expect(second.status).toBe('skipped')
expect(acquireCalls).toHaveLength(1)
const again = await findBySourceKey(sourceKey)
expect(again?.id).toBe(speech?.id)
expect(again?.title).toBe('Título do lote')
// The metadata-only refresh must never wipe the stored duration/media.
expect(again?.durationSeconds).toBe(3)
expect(await countSegments(again?.id ?? 0)).toBe(1)
})
it('reprocesses on demand and removes the replaced artifact', async () => {
acquireCalls.length = 0
const current = finding({
externalId: 'reproc',
url: 'https://www.youtube.com/watch?v=reproc',
})
const sourceKey = 'web:youtube:reproc'
await ingestWebSpeech(payload, current, { deps: deps() })
const before = await findBySourceKey(sourceKey)
const previousMediaId =
typeof before?.mirroredMedia === 'object' ? before.mirroredMedia?.id : null
expect(previousMediaId).not.toBeNull()
const result = await ingestWebSpeech(payload, current, { deps: deps(), reprocess: true })
expect(result.status).toBe('updated')
expect(acquireCalls).toHaveLength(2)
const after = await findBySourceKey(sourceKey)
const nextMediaId = typeof after?.mirroredMedia === 'object' ? after.mirroredMedia?.id : null
expect(nextMediaId).not.toBe(previousMediaId)
const previous = await payload
.findByID({
collection: INTERNET_SPEECH_MEDIA_SLUG,
id: previousMediaId ?? 0,
overrideAccess: true,
})
.catch(() => null)
expect(previous).toBeNull()
})
it('mirrors the origin thumbnail when the finding brings one', async () => {
const current = finding({
externalId: 'thumb',
url: 'https://www.youtube.com/watch?v=thumb',
thumbnailUrl: 'https://i.ytimg.com/vi/thumb/hq.jpg',
})
const sourceKey = 'web:youtube:thumb'
const result = await ingestWebSpeech(payload, current, {
deps: deps({
downloadThumbnail: async (_url, destinationPath) => {
await writeFile(destinationPath, JPEG_BYTES)
},
}),
})
expect(result.status).toBe('created')
const speech = await findBySourceKey(sourceKey)
const thumbnail = typeof speech?.thumbnail === 'object' ? speech.thumbnail : null
expect(thumbnail?.mimeType).toBe('image/jpeg')
})
it('keeps the stored thumbnail when a reprocess cannot fetch a new one', async () => {
const current = finding({
externalId: 'thumb-keep',
url: 'https://www.youtube.com/watch?v=thumb-keep',
thumbnailUrl: 'https://i.ytimg.com/vi/thumb-keep/hq.jpg',
})
const sourceKey = 'web:youtube:thumb-keep'
await ingestWebSpeech(payload, current, {
deps: deps({
downloadThumbnail: async (_url, destinationPath) => {
await writeFile(destinationPath, JPEG_BYTES)
},
}),
})
const before = await findBySourceKey(sourceKey)
const beforeThumbnail = typeof before?.thumbnail === 'object' ? before.thumbnail?.id : null
expect(beforeThumbnail).not.toBeNull()
const result = await ingestWebSpeech(payload, current, {
deps: deps({
downloadThumbnail: async () => {
throw new Error('capa indisponível')
},
}),
reprocess: true,
})
expect(result.status).toBe('updated')
const after = await findBySourceKey(sourceKey)
const afterThumbnail = typeof after?.thumbnail === 'object' ? after.thumbnail?.id : null
expect(afterThumbnail).toBe(beforeThumbnail)
})
it('fails honestly when the acquisition fails, without a ghost row', async () => {
const current = finding({
externalId: 'broken',
url: 'https://www.youtube.com/watch?v=broken',
})
const sourceKey = 'web:youtube:broken'
const result = await ingestWebSpeech(payload, current, {
deps: deps({
acquire: async () => {
throw new Error('yt-dlp falhou no download')
},
}),
})
expect(result).toMatchObject({ status: 'failed', stage: 'acquisition' })
expect(result.error).toContain('yt-dlp falhou no download')
expect(await findBySourceKey(sourceKey)).toBeNull()
})
it('fails honestly when the transcription fails', async () => {
const current = finding({
externalId: 'semvoz',
url: 'https://www.youtube.com/watch?v=semvoz',
})
const sourceKey = 'web:youtube:semvoz'
const result = await ingestWebSpeech(payload, current, {
deps: deps({
transcribe: async () => ({ ok: false, error: 'Transcrição vazia.', status: 502 }),
}),
})
expect(result).toMatchObject({ status: 'failed', stage: 'transcription' })
expect(result.error).toContain('Transcrição vazia')
expect(await findBySourceKey(sourceKey)).toBeNull()
})
it('accepts a radio finding with a direct mediaUrl', async () => {
const current = finding({
platform: 'radio',
url: 'https://radio.example/entrevista',
mediaUrl: 'https://radio.example/entrevista.mp3',
publishedAt: '2026-09-21',
})
const sourceKey = 'web:radio:https://radio.example/entrevista'
const downloaded: string[] = []
const result = await ingestWebSpeech(payload, current, {
deps: deps({
acquire: async (currentFinding, tempDir) => {
downloaded.push(currentFinding.mediaUrl ?? '')
const fileName = 'source.mp3'
await writeFile(join(tempDir, fileName), MP3_BYTES)
return { fileName, metadata: null }
},
}),
})
expect(result.status).toBe('created')
expect(downloaded).toEqual(['https://radio.example/entrevista.mp3'])
const speech = await findBySourceKey(sourceKey)
expect(speech).toMatchObject({
origin: 'web',
platform: 'radio',
sourceUrl: 'https://radio.example/entrevista',
channel: null,
})
const media = typeof speech?.mirroredMedia === 'object' ? speech.mirroredMedia : null
expect(media?.mimeType).toBe('audio/mpeg')
})
})

View file

@ -0,0 +1,104 @@
// @vitest-environment node
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { downloadUrlToFile } from '@/utilities/media/downloadToFile'
// C215 — the direct-acquisition streamer: non-2xx fails before writing, the
// byte ceiling guards the disk and the happy path writes the exact bytes.
let tempDir: string
const response = ({
body,
status = 200,
headers = {},
}: {
body: ReadableStream<Uint8Array> | null
status?: number
headers?: Record<string, string>
}) =>
({
ok: status >= 200 && status < 300,
status,
body,
headers: new Headers(headers),
}) as Response
const streamOf = (chunks: string[]): ReadableStream<Uint8Array> =>
new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk))
controller.close()
},
})
const fetchOf = (value: Response) => (async () => value) as unknown as typeof fetch
describe('downloadUrlToFile', () => {
beforeAll(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'download-to-file-'))
})
afterAll(async () => {
await rm(tempDir, { recursive: true, force: true })
})
it('writes the streamed body to the destination', async () => {
const destinationPath = join(tempDir, 'ok.mp3')
await downloadUrlToFile({
url: 'https://radio.example/a.mp3',
destinationPath,
fetchImpl: fetchOf(response({ body: streamOf(['fala', ' da ', 'radio']) })),
})
expect(await readFile(destinationPath, 'utf8')).toBe('fala da radio')
})
it('fails on a non-2xx answer without writing the file', async () => {
const destinationPath = join(tempDir, 'missing.mp3')
await expect(
downloadUrlToFile({
url: 'https://radio.example/missing.mp3',
destinationPath,
fetchImpl: fetchOf(response({ body: null, status: 404 })),
}),
).rejects.toThrow(/HTTP 404/)
})
it('fails when the response has no body', async () => {
await expect(
downloadUrlToFile({
url: 'https://radio.example/empty.mp3',
destinationPath: join(tempDir, 'empty.mp3'),
fetchImpl: fetchOf(response({ body: null })),
}),
).rejects.toThrow(/sem corpo/)
})
it('refuses a declared size above the ceiling', async () => {
await expect(
downloadUrlToFile({
url: 'https://radio.example/big.mp3',
destinationPath: join(tempDir, 'big.mp3'),
fetchImpl: fetchOf(
response({ body: streamOf(['x']), headers: { 'content-length': '99' } }),
),
maxBytes: 10,
}),
).rejects.toThrow(/tamanho máximo/)
})
it('aborts mid-stream when the body crosses the ceiling', async () => {
await expect(
downloadUrlToFile({
url: 'https://radio.example/stream.mp3',
destinationPath: join(tempDir, 'stream.mp3'),
fetchImpl: fetchOf(response({ body: streamOf(['12345', '67890', 'x']) })),
maxBytes: 10,
}),
).rejects.toThrow(/tamanho máximo/)
})
})

View file

@ -48,7 +48,12 @@ const execute =
(findSpeechExcerpts(ctxFor(actor, payload)) as unknown as ExecutableTool).execute(args)
const SPEECH_WHERE = {
and: [{ searchText: { like: 'hospital' } }, { searchText: { like: 'suburbio' } }],
and: [
// C215 — the assistant keeps suggesting Câmara excerpts only.
{ origin: { equals: 'camara' } },
{ searchText: { like: 'hospital' } },
{ searchText: { like: 'suburbio' } },
],
}
const reranked = (index: number, reason = 'Serve à intenção') => ({ index, reason })

View file

@ -8,9 +8,12 @@ import {
} from '@/utilities/speech/speechListFilters'
import { parseSpeechListParams } from '@/utilities/speech/speechListUrl'
// C215 — the Câmara list contract always carries the source discriminator.
const camara = { origin: { equals: 'camara' } }
describe('buildSpeechListWhere', () => {
it('is empty without filters', () => {
expect(buildSpeechListWhere(parseSpeechListParams({}))).toEqual({})
it('keeps only the Câmara discriminator without filters', () => {
expect(buildSpeechListWhere(parseSpeechListParams({}))).toEqual({ and: [camara] })
})
it('assembles the facet filters', () => {
@ -26,6 +29,7 @@ describe('buildSpeechListWhere', () => {
expect(where).toEqual({
and: [
camara,
{ year: { in: [2026] } },
{ topics: { in: ['saude'] } },
{ scopes: { in: ['bahia'] } },
@ -39,6 +43,7 @@ describe('buildSpeechListWhere', () => {
const where = buildSpeechListWhere(parseSpeechListParams({ q: 'Farmácia Popular' }))
expect(where).toEqual({
and: [
camara,
{
or: [
{ searchText: { like: 'farmacia popular' } },
@ -51,24 +56,26 @@ describe('buildSpeechListWhere', () => {
it('maps each duration bucket to its range and ORs them', () => {
expect(buildSpeechListWhere(parseSpeechListParams({ duration: 'curta' }))).toEqual({
and: [{ durationSeconds: { less_than: 120 } }],
and: [camara, { durationSeconds: { less_than: 120 } }],
})
expect(buildSpeechListWhere(parseSpeechListParams({ duration: ['media'] }))).toEqual({
and: [
camara,
{
durationSeconds: { greater_than_equal: 120, less_than: 300 },
},
],
})
expect(buildSpeechListWhere(parseSpeechListParams({ duration: ['longa'] }))).toEqual({
and: [{ durationSeconds: { greater_than_equal: 300 } }],
and: [camara, { durationSeconds: { greater_than_equal: 300 } }],
})
expect(buildSpeechListWhere(parseSpeechListParams({ duration: ['sem_duracao'] }))).toEqual({
and: [{ durationSeconds: { exists: false } }],
and: [camara, { durationSeconds: { exists: false } }],
})
expect(buildSpeechListWhere(parseSpeechListParams({ duration: ['curta', 'longa'] }))).toEqual({
and: [
camara,
{
or: [
{ durationSeconds: { less_than: 120 } },
@ -89,6 +96,7 @@ describe('buildSpeechListWhereIncludingCutOrigins (C174)', () => {
expect(where).toEqual({
and: [
camara,
{
or: [
{ searchText: { like: 'reforma' } },
@ -107,6 +115,7 @@ describe('buildSpeechListWhereIncludingCutOrigins (C174)', () => {
)
expect(withFacet).toEqual({
and: [
camara,
{ topics: { in: ['saude'] } },
{
or: [
@ -132,6 +141,7 @@ describe('buildSpeechListWhere theme expansion (C192)', () => {
expect(where).toEqual({
and: [
camara,
{
or: [
{ searchText: { like: 'defesa do sus' } },
@ -148,7 +158,7 @@ describe('buildSpeechListWhere theme expansion (C192)', () => {
it('dedupes a term that repeats the query', () => {
expect(buildSpeechListWhere(parseSpeechListParams({ q: 'SUS' }), ['SUS'])).toEqual({
and: [{ or: [{ searchText: { like: 'sus' } }, { keywords: { contains: 'SUS' } }] }],
and: [camara, { or: [{ searchText: { like: 'sus' } }, { keywords: { contains: 'SUS' } }] }],
})
})
@ -161,6 +171,7 @@ describe('buildSpeechListWhere theme expansion (C192)', () => {
),
).toEqual({
and: [
camara,
{ topics: { in: ['saude'] } },
{
or: [

View file

@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest'
import {
canonicalWebSpeechUrl,
parseWebSpeechBatch,
parseWebSpeechFinding,
webSpeechAtFromPublishedAt,
webSpeechPlatformLabel,
webSpeechSourceKey,
} from '@/lib/webSpeech'
// C215 — the pure web-speech contract: identity (sourceKey), canonical URL and
// the finding-batch validation that fails one entry without killing the batch.
describe('canonicalWebSpeechUrl', () => {
it('collapses every YouTube shape to the watch URL', () => {
expect(canonicalWebSpeechUrl('youtube', 'https://youtu.be/dQw4w9WgXcQ?si=track')).toBe(
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
)
expect(
canonicalWebSpeechUrl('youtube', 'https://www.youtube.com/shorts/dQw4w9WgXcQ?feature=share'),
).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
expect(
canonicalWebSpeechUrl('youtube', 'https://m.youtube.com/watch?v=dQw4w9WgXcQ&t=30s'),
).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
expect(canonicalWebSpeechUrl('youtube', 'https://www.youtube.com/live/dQw4w9WgXcQ')).toBe(
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
)
})
it('collapses Instagram post shapes and normalizes reels', () => {
expect(
canonicalWebSpeechUrl('instagram', 'https://www.instagram.com/reels/ABC123/?igsh=xyz'),
).toBe('https://www.instagram.com/reel/ABC123/')
expect(canonicalWebSpeechUrl('instagram', 'https://instagram.com/p/ABC123')).toBe(
'https://www.instagram.com/p/ABC123/',
)
})
it('drops tracking params, the fragment and the trailing slash for direct audio', () => {
expect(
canonicalWebSpeechUrl(
'radio',
'https://RadioBahia.com.br/audio/entrevista.mp3/?utm_source=share&id=7#t=1',
),
).toBe('https://radiobahia.com.br/audio/entrevista.mp3?id=7')
})
it('keeps an unparseable URL trimmed instead of inventing one', () => {
expect(canonicalWebSpeechUrl('audio', ' nao-e-url ')).toBe('nao-e-url')
})
})
describe('webSpeechSourceKey', () => {
it('prefers the external id and falls back to the canonical URL', () => {
expect(
webSpeechSourceKey({
platform: 'youtube',
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
externalId: 'dQw4w9WgXcQ',
}),
).toBe('web:youtube:dQw4w9WgXcQ')
expect(
webSpeechSourceKey({ platform: 'audio', url: 'https://radio.example/a.mp3?utm_source=x' }),
).toBe('web:audio:https://radio.example/a.mp3')
})
it('is stable across YouTube URL shapes (same identity, no duplicate row)', () => {
const short = webSpeechSourceKey({ platform: 'youtube', url: 'https://youtu.be/dQw4w9WgXcQ' })
const long = webSpeechSourceKey({
platform: 'youtube',
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90',
})
expect(short).toBe(long)
expect(short).toBe('web:youtube:https://www.youtube.com/watch?v=dQw4w9WgXcQ')
})
})
describe('webSpeechAtFromPublishedAt', () => {
it('normalizes date-only and converts an explicit timezone to Brasília', () => {
expect(webSpeechAtFromPublishedAt('2026-09-20')).toBe('2026-09-20T00:00')
// 14:35Z is 11:35 in America/Bahia (UTC-3, no DST).
expect(webSpeechAtFromPublishedAt('2026-09-20T14:35:00Z')).toBe('2026-09-20T11:35')
expect(webSpeechAtFromPublishedAt('2026-09-20T14:35:00-03:00')).toBe('2026-09-20T14:35')
expect(webSpeechAtFromPublishedAt('2026-09-20 14:35')).toBe('2026-09-20T14:35')
})
it('returns null for an unrecognized or impossible date', () => {
expect(webSpeechAtFromPublishedAt('20/09/2026')).toBeNull()
expect(webSpeechAtFromPublishedAt('2026-09-20lixo')).toBeNull()
expect(webSpeechAtFromPublishedAt('2026-99-99')).toBeNull()
expect(webSpeechAtFromPublishedAt('2026-09-20T25:00')).toBeNull()
})
})
describe('parseWebSpeechFinding', () => {
const validYoutube = {
platform: 'youtube',
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
publishedAt: '2026-09-20',
}
it('accepts a YouTube finding without mediaUrl', () => {
const parsed = parseWebSpeechFinding(validYoutube)
expect(parsed.ok).toBe(true)
})
it('requires mediaUrl for radio/audio and publishedAt everywhere', () => {
const radio = parseWebSpeechFinding({ ...validYoutube, platform: 'radio' })
expect(radio.ok).toBe(false)
if (!radio.ok) expect(radio.error).toContain('mediaUrl')
const noDate = parseWebSpeechFinding({ ...validYoutube, publishedAt: undefined })
expect(noDate.ok).toBe(false)
const badDate = parseWebSpeechFinding({ ...validYoutube, publishedAt: 'ontem' })
expect(badDate.ok).toBe(false)
})
it('rejects an unknown platform and a non-URL', () => {
expect(parseWebSpeechFinding({ ...validYoutube, platform: 'tiktok' }).ok).toBe(false)
expect(parseWebSpeechFinding({ ...validYoutube, url: 'nada' }).ok).toBe(false)
})
})
describe('parseWebSpeechBatch', () => {
it('validates only the envelope and keeps the findings raw', () => {
const parsed = parseWebSpeechBatch({ generatedAt: '2026-09-24T10:00:00Z', findings: [{}] })
expect(parsed.ok).toBe(true)
if (parsed.ok) {
expect(parsed.batch.generatedAt).toBe('2026-09-24T10:00:00Z')
expect(parsed.batch.findings).toEqual([{}])
}
})
it('fails closed on a malformed envelope', () => {
expect(parseWebSpeechBatch({}).ok).toBe(false)
expect(parseWebSpeechBatch({ findings: 'nope' }).ok).toBe(false)
})
})
describe('webSpeechPlatformLabel', () => {
it('owns the pt-BR labels the acervo shows', () => {
expect(webSpeechPlatformLabel('youtube')).toBe('YouTube')
expect(webSpeechPlatformLabel('radio')).toBe('Rádio')
})
})

View file

@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest'
import {
formatWebSpeechReport,
isCompleteWebSpeechState,
planWebSpeechBatch,
summarizePlannedActions,
summarizeWebSpeechResults,
webSpeechPlannedAction,
} from '../../scripts/lib/webSpeeches.mjs'
type WebSpeechIngestResult =
import('../../src/utilities/speech/webSpeechIngest.ts').WebSpeechIngestResult
// C215 — the CLI planning/reporting rules: one invalid finding fails only
// itself, duplicates are dropped by natural key and the report never hides a
// failure.
const youtube = (overrides = {}) => ({
platform: 'youtube',
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
publishedAt: '2026-09-20',
...overrides,
})
describe('planWebSpeechBatch', () => {
it('keeps the valid entries and reports invalid/duplicates separately', () => {
const plan = planWebSpeechBatch([
youtube(),
youtube({ url: 'https://youtu.be/dQw4w9WgXcQ' }),
youtube({ platform: 'radio' }),
youtube({ externalId: 'outro', url: 'https://www.youtube.com/watch?v=zzz' }),
{ platform: 'youtube', url: 'nao-e-url', publishedAt: '2026-09-20' },
])
expect(plan.entries.map((entry) => entry.sourceKey)).toEqual([
'web:youtube:https://www.youtube.com/watch?v=dQw4w9WgXcQ',
'web:youtube:outro',
])
expect(plan.duplicates).toEqual([
{ index: 1, sourceKey: 'web:youtube:https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
])
expect(plan.invalid.map((entry) => entry.index)).toEqual([2, 4])
})
})
describe('webSpeechPlannedAction', () => {
it('reads the stored state: new, incomplete, complete', () => {
expect(webSpeechPlannedAction(null)).toBe('novo')
expect(webSpeechPlannedAction({ segmentCount: 0, mirroredMedia: null })).toBe('atualizado')
expect(webSpeechPlannedAction({ segmentCount: 3, mirroredMedia: null })).toBe('atualizado')
expect(webSpeechPlannedAction({ segmentCount: 3, mirroredMedia: 9 })).toBe('ignorado')
expect(webSpeechPlannedAction({ segmentCount: 3, mirroredMedia: 9 }, true)).toBe('reprocessado')
})
it('isCompleteWebSpeechState requires transcript and mirrored file', () => {
expect(isCompleteWebSpeechState(null)).toBe(false)
expect(isCompleteWebSpeechState({ segmentCount: 0, mirroredMedia: 1 })).toBe(false)
expect(isCompleteWebSpeechState({ segmentCount: 1, mirroredMedia: null })).toBe(false)
expect(isCompleteWebSpeechState({ segmentCount: 1, mirroredMedia: 1 })).toBe(true)
})
})
describe('summarizeWebSpeechResults', () => {
it('counts statuses, sums costs and lists every failure', () => {
const result = (overrides: Partial<WebSpeechIngestResult>): WebSpeechIngestResult => ({
sourceKey: 'web:youtube:x',
status: 'skipped',
stage: null,
error: null,
asrSeconds: 0,
asrCostUsd: 0,
llmCostUsd: null,
...overrides,
})
const summary = summarizeWebSpeechResults([
result({ status: 'created', asrSeconds: 60, asrCostUsd: 0.00045, llmCostUsd: 0.0001 }),
result({ status: 'updated', asrSeconds: 120, asrCostUsd: 0.0009 }),
result({}),
result({
status: 'failed',
sourceKey: 'web:audio:https://radio.example/a.mp3',
stage: 'acquisition',
error: 'HTTP 404',
}),
])
expect(summary).toMatchObject({ created: 1, updated: 1, skipped: 1, failed: 1 })
expect(summary.asrSeconds).toBe(180)
expect(summary.asrCostUsd).toBeCloseTo(0.00135, 6)
expect(summary.llmCostUsd).toBeCloseTo(0.0001, 6)
expect(summary.failures).toEqual([
{
sourceKey: 'web:audio:https://radio.example/a.mp3',
stage: 'acquisition',
error: 'HTTP 404',
},
])
})
})
describe('formatWebSpeechReport', () => {
const base = {
runAt: '2026-09-24T10:00:00.000Z',
mode: 'import',
findings: 4,
planned: 3,
invalid: [{ index: 3, error: 'url: inválida' }],
duplicates: [{ index: 1, sourceKey: 'web:youtube:x' }],
durationMs: 12_500,
created: 1,
updated: 1,
skipped: 1,
failed: 1,
asrSeconds: 90,
asrCostUsd: 0.000675,
llmCostUsd: 0.0001,
failures: [
{
sourceKey: 'web:audio:https://radio.example/a.mp3',
stage: 'acquisition' as const,
error: 'HTTP 404',
},
],
}
it('prints counts, costs, the invalid entries and every failure', () => {
const lines = formatWebSpeechReport(base).join('\n')
expect(lines).toContain('novos: 1 · atualizados: 1 · ignorados: 1 · falhas: 1')
expect(lines).toContain('ASR: 1min30s (~US$ 0.0007)')
expect(lines).toContain('inválido #3')
expect(lines).toContain('web:audio:https://radio.example/a.mp3 (acquisition): HTTP 404')
})
it('prints the dry-run plan instead of the execution counts', () => {
const lines = formatWebSpeechReport({
...base,
mode: 'dry-run',
plannedActions: summarizePlannedActions([
{ action: 'novo' },
{ action: 'ignorado' },
{ action: 'reprocessado' },
]),
}).join('\n')
expect(lines).toContain('plano (dry-run): novos: 1 · atualizados: 0 · ignorados: 1')
})
})

View file

@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest'
import {
downloadWithYtDlp,
parseYtDlpMetadata,
readYtDlpMetadata,
resolveYtDlp,
YTDLP_FORMAT,
type YtDlpResult,
} from '@/utilities/media/ytdlp'
// C215 — the yt-dlp owner: fail-closed resolution, metadata parsing and the
// deterministic download command (format cap, merge, ffmpeg location).
const okRun =
(stdout = '') =>
async (): Promise<YtDlpResult> => ({ ok: true, stdout, stderr: '' })
describe('resolveYtDlp', () => {
it('accepts the explicit YTDLP_PATH and the PATH fallback', async () => {
await expect(
resolveYtDlp({ env: { YTDLP_PATH: '/custom/yt-dlp' }, run: okRun('2026.09.20') }),
).resolves.toEqual({ ok: true, bin: '/custom/yt-dlp' })
await expect(resolveYtDlp({ env: {}, run: okRun('2026.09.20') })).resolves.toEqual({
ok: true,
bin: 'yt-dlp',
})
})
it('fails closed with the reason when the binary is missing', async () => {
const missing = async (): Promise<YtDlpResult> => ({
ok: false,
stdout: '',
stderr: '',
error: { code: 'ENOENT' },
})
const resolved = await resolveYtDlp({ env: {}, run: missing })
expect(resolved.ok).toBe(false)
if (!resolved.ok) expect(resolved.reason).toContain('não encontrado')
})
})
describe('parseYtDlpMetadata', () => {
it('maps the dump-json document and falls back to uploader', () => {
expect(
parseYtDlpMetadata({
id: 'dQw4w9WgXcQ',
title: ' Entrevista ',
uploader: 'Canal do Solla',
duration: 123.4,
thumbnail: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hq.jpg',
}),
).toEqual({
externalId: 'dQw4w9WgXcQ',
title: 'Entrevista',
channel: 'Canal do Solla',
durationSeconds: 123.4,
thumbnailUrl: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hq.jpg',
})
})
it('keeps absent/invalid fields null instead of guessing', () => {
expect(parseYtDlpMetadata({ duration: -5, title: '' })).toEqual({
externalId: null,
title: null,
channel: null,
durationSeconds: null,
thumbnailUrl: null,
})
})
})
describe('readYtDlpMetadata', () => {
it('parses the JSON stdout', async () => {
const run = okRun(JSON.stringify({ id: 'x', title: 'Fala', duration: 60 }))
await expect(
readYtDlpMetadata({ bin: 'yt-dlp', url: 'https://x', run }),
).resolves.toMatchObject({ externalId: 'x', title: 'Fala', durationSeconds: 60 })
})
it('throws with the stderr tail on failure and on malformed JSON', async () => {
const failing = async (): Promise<YtDlpResult> => ({
ok: false,
stdout: '',
stderr: 'ERROR: Video unavailable',
})
await expect(
readYtDlpMetadata({ bin: 'yt-dlp', url: 'https://x', run: failing }),
).rejects.toThrow(/Video unavailable/)
await expect(
readYtDlpMetadata({ bin: 'yt-dlp', url: 'https://x', run: okRun('not json') }),
).rejects.toThrow(/fora do formato/)
})
})
describe('downloadWithYtDlp', () => {
it('caps quality, merges to mp4 and forwards the ffmpeg location', async () => {
const calls: string[][] = []
const run = async (_bin: string, args: string[]): Promise<YtDlpResult> => {
calls.push(args)
return { ok: true, stdout: '', stderr: '' }
}
await downloadWithYtDlp({
bin: '/bin/yt-dlp',
url: 'https://www.youtube.com/watch?v=x',
outputTemplate: '/tmp/source.%(ext)s',
ffmpegLocation: '/packed/ffmpeg',
run,
})
expect(calls).toHaveLength(1)
const args = calls[0]
expect(args).toContain('--no-playlist')
expect(args.slice(args.indexOf('-f'), args.indexOf('-f') + 2)).toEqual(['-f', YTDLP_FORMAT])
expect(
args.slice(args.indexOf('--merge-output-format'), args.indexOf('--merge-output-format') + 2),
).toEqual(['--merge-output-format', 'mp4'])
expect(
args.slice(args.indexOf('--ffmpeg-location'), args.indexOf('--ffmpeg-location') + 2),
).toEqual(['--ffmpeg-location', '/packed/ffmpeg'])
expect(args.slice(args.indexOf('-o'), args.indexOf('-o') + 2)).toEqual([
'-o',
'/tmp/source.%(ext)s',
])
expect(args.at(-1)).toBe('https://www.youtube.com/watch?v=x')
})
it('throws with the stderr tail when yt-dlp fails', async () => {
const failing = async (): Promise<YtDlpResult> => ({
ok: false,
stdout: '',
stderr: 'ERROR: unable to download',
})
await expect(
downloadWithYtDlp({
bin: 'yt-dlp',
url: 'https://x',
outputTemplate: '/tmp/source.%(ext)s',
run: failing,
}),
).rejects.toThrow(/unable to download/)
})
})