34 KiB
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/leadernegados). - Nenhum segundo pipeline de ASR/classificação/upsert e nenhum segundo cadastro de pessoa (menções continuam texto; sem
Contact, semConsentnovo — não há PII nova). sourceKeyda 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=1para alvo não-local/override;--dry-runsem 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
preserveManualFacetsexistente é o pino).
- Mídia espelhada é privada e preservada: nunca URL pública, nunca
- 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
Speechnão tem origem/plataforma/URL/canal/título/thumb/mídia espelhada e que quatro superfícies leemspeechsem discriminador (speechPageData,speechListFilters,speechCoverage,findSpeechExcerpts) — sem blindagem a fala web vazaria para "Falas da Câmara" com crédito errado (D10). speechPosterJob/speechCutJobsão by-id (VOD Câmara) e degradam sozinhos — não entram nesta fatia.
- 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
Abordagem recomendada
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 aspeech) a virar polimorfismo, acoplando o C217 sem ganho. - C (reusar
recordingMedia/ servir no C215) porquerecordingMediaé 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á consomemprivateMediaResponse.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(selectcamaradefault |web, required, index — backfill dos existentes pelo default da migration);platform(selectyoutube|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;deriveSpeechYearcontinua),durationSeconds,searchText, facets,classifiedBy,speechSegment.legislature/type/phase/VOD ficam nulos na origem web (já são nullable). sourceKeynamespaced:web:<platform>:<externalId>quando houver id externo; senãoweb:<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
beforeDeletedoSpeech(hoje apagaspeechSegment) para apagarmirroredMedia/thumbnailquando 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 doFFMPEG_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/audiosemmediaUrl→ falha honesta no relatório (estágiovalidation/download), o lote continua. - ffmpeg: extrair
runProcess/probeFfmpeg/resolveFfmpegdescripts/lib/reelFfmpeg.mjsparascripts/lib/mediaBinaries.mjs;reelFfmpeg.mjsre-exporta (API dos reels intacta e unit tests dos reels verdes). O script novo resolve o binário e setaFFMPEG_PATHresolvido quando o env não veio explícito, para quesrc/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).
upsertSpeechBundleganha os campos web opcionais no bundle (origin/platform/externalId/sourceUrl/title/channel/mirroredMedia/thumbnail) efindSpeechImportStatepassa 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; ometadataDatada Câmara não muda.- Escrita multi-collection: a ingestão cria o upload
internetSpeechMediae roda o upsert na mesma transação (withPayloadTransaction), passando oreqao upsert; semreq, 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 combuildRecordingAudioFfmpegArgse mescla commergeChunkTranscriptions/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) comsrc/lib/webSpeech,src/collections/Speech.tsesrc/collections/InternetSpeechMedia.tspara não cair no smoke fallback. (src/utilities/speech,src/utilities/mediaesrc/lib/speech*já estão mapeados;src/lib/webSpeeche as collections não casam prefixo nenhum hoje.)
Componentes / mudanças
Speech(src/collections/Speech.ts, editar): campos de origem/mídia (D1);beforeDeleteestendido para apagarmirroredMedia/thumbnail(D2);admin.description/defaultColumnspassam a citar as duas origens. Nenhum campo da Câmara muda.InternetSpeechMedia(src/collections/InternetSpeechMedia.ts, novo): upload privado;altobrigatório (preenchido pelo pipeline); access D2;admin.group: 'Comunicação'com descrição de privacidade (forma deRecordingMedia.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;upsertSpeechBundleaceitareqde transação aberta (Câmara semreqsegue abrindo a própria).scripts/lib/mediaBinaries.mjs(novo): extração derunProcess/probeFfmpeg/resolveFfmpeg;scripts/lib/reelFfmpeg.mjsre-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ãoensureCachedDownload, relatório D8.package.json(editar): scriptfalas-web:import(padrão docamara:import)..gitignore(editar):data/falas-web/.payload.config.ts(editar): registrarInternetSpeechMediana lista de collections einternetSpeechMedia: trueno plugin S3.scripts/lib/e2e-affected-manifest.mjs(editar): prefixos do D13.- Migration:
pnpm migrate:create add_web_speech_origin— enumorigin/platform, colunas emspeech(originNOT NULL DEFAULT'camara', demais nullable), tabelainternet_speech_media+ FKs + índices (origin,platform) e as rels de locked documents. Revisar o SQL gerado; não criar índice trigram novo (ospeech_search_text_trgm_idxda 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 usamoverrideAccess: truecom comentário justificado (ator confiável sem sessão, padrão do import da Câmara). SemConsentnovo (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
- Tracer / schema + server (~1 dia).
src/lib/webSpeech.ts;InternetSpeechMedia; campos/beforeDeletenoSpeech; registro empayload.config.ts;pnpm generate:types;upsertSpeechBundle/findSpeechImportStateestendidos.- Unit: schema do lote (envelope/finding,
mediaUrlobrigatória em radio|audio),webSpeechSourceKey, URL canônica, labels. - Int: round-trip dos campos web (create/update), idempotência do upsert,
manualpreservado, 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).
- 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_PATHfake, transcriber/classificador injetados,FFMPEG_PATHfake) — happy path com espelho + segmentos + searchText; falha de download/ASR → falha no relatório sem row fantasma;--reprocessre-baixa/re-transcreve; segunda execução não re-baixa nem re-transcreve.
- 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-runsem rede e sem escrita (lê estado do banco); guardas (FALAS_WEB_IMPORT_CONFIRM/assertLocalDatabase); JSON em<out>/reports/<timestamp>.json.
- Blindagem Câmara (~0,25 dia).
origin: camaraembuildSpeechFacetWhere(cobrebuildSpeechListWhereebuildSpeechListWhereIncludingCutOrigins),loadSpeechFilterOptions,speechCoverage.ts(SQL) efindSpeechExcerpts.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.
- 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.
- Manifest e2e;
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
mediapública; reusarrecordingMedia; 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;pushde 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
mediaUrlquando a descoberta tiver o arquivo direto. - Custo/tempo de ASR em horas de vídeo.
--limite--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: trueesquecido). Registro no plugin no mesmo PR + checklist de aceite;resolveS3StorageEnvfalha 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).
canonicalWebSpeechUrlunit-pinada + dedupe no lote +sourceKeynamespaced (índice unique já existente). - Migração em prod (coluna NOT NULL).
originnasce 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;
metadataDatada Câmara intocado; os int existentes (speechImport.int.spec.ts) são o pino. - Curadoria manual sobrescrita em
--reprocess. HookpreserveManualFacetsé o pino; int cobre o re-run sobre registromanual. - 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+useronde 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); semConsent/PII novos;sourceKeyda Câmara intacto; sem rota/URL pública nova; migração nova sem editar antigas; copy pt-BR / identificadores em inglês;pnpm gate:fastepnpm 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_origincom HARD-STOP humano (mesmo em--auto), local-only, revisada antes de aplicar. internetSpeechMedia: trueno 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 paraAmerica/Bahiaantes de virarspeechAt; data impossível falha o achado em vez de virar mentira no card.- Pós-simplify: o
defaultRundoreelFfmpeg(referência órfã após a extração) foi reconectado;downloadUrlToFileganhou teto de bytes (4 GiB);databaseTarget/assertWriteConfirmviraram helpers doscripts/lib/cli.mjsusados pelos dois CLIs; o predicado de completude foi parasrc/lib/webSpeech.ts; a constante de custo do ASR passou a ter dono único (o CLI da Câmara re-exporta a do app). ytDlpBinarynão usa o resolver demediaBinaries.mjs. Não existe binário empacotado de yt-dlp; o donosrc/utilities/media/ytdlp.tsresolveYTDLP_PATH→ PATH com probe--versionfail-closed. O resolver extraído dereelFfmpeg.mjscontinua sendo usado só para o ffmpeg (o script resolve com fallback empacotado e setaFFMPEG_PATH).upsertSpeechBundleganhouoptions.reqeoriginno bundle (default'camara'no create) em vez de um writer novo — o caminho Câmara não mudou.- Testes existentes pinados:
speechListFilters.unit.spec.tsespeechExcerptsTool.unit.spec.tspassam a esperarorigin: camara; os creates despeechem int/e2e ganharamorigin: 'camara'(o campo é required no tipo gerado). scripts/lib/mediaBinaries.mjsescripts/lib/webSpeeches.mjsentraram noSCRIPTS_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.
Simplify — triage dos revisores (2026-09-24)
Aplicados na sessão (não reabrir): referência órfã do defaultRun no reelFfmpeg (bug real de produção), teto de bytes no downloadUrlToFile, helpers databaseTarget/assertWriteConfirm no scripts/lib/cli.mjs usados pelos dois CLIs, predicado de completude no src/lib/webSpeech.ts, dono único da constante de custo de ASR (o CLI da Câmara re-exporta a do app), tipos do recordingTranscription, ffmpegBinary exportado do dono, mensagens pt-BR no schema do achado, isComplete/skip preservando a duração armazenada, capa antiga preservada quando a nova falha, regex de data ancorada + calendário + fuso, testes de downloadToFile e asserções endurecidas.
| ID | Resumo | Origem | Score | Tipo | Destino |
|---|---|---|---|---|---|
| S1 | downloadUrlToFile como dono único do streamer (os outros 3 paths não têm teto de bytes) |
simplify/reuse | 3 | expensive_lock | defer — gatilho: quando o C217 precisar baixar mídia externa ou surgir um 4º path |
| S2 | mediaIdOf duplicado (hook do Speech vs speechImport) |
simplify/reuse | 1 | cheap_polish | descartar (2 linhas, idioma local) |
| S3 | ingestWebSpeech ainda com ~180 linhas |
simplify/quality | 2 | cheap_polish | descartar (extração feita; fluxo linear pinado por int) |
| S4 | Nome do arquivo espelhado genérico (source.mp4) |
simplify/quality | 2 | cheap_polish | defer — gatilho: C216 define o nome de download (dispositionFilename) |
| S5 | Cap de bytes ausente nos 3 paths pré-existentes de download | simplify/reuse | 3 | expensive_lock | descartar deste lote (pré-existente em main, fora do escopo; S1 é o gatilho) |
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 (
sourceKeyunique da Câmara, relação fixaSpeechCut.speech,preserveManualFacets,reelFfmpegjá testado,resolveS3StorageEnvfail-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 emsrc/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.