diff --git a/docs/plans/catalogo-falas-solla-impl.md b/docs/plans/catalogo-falas-solla-impl.md
new file mode 100644
index 00000000..b9e9a013
--- /dev/null
+++ b/docs/plans/catalogo-falas-solla-impl.md
@@ -0,0 +1,190 @@
+# Impl: Catálogo de falas do Solla: import idempotente e busca textual
+
+Status: em execução
+Atualizado em: 2026-09-12
+Issue: #955
+Intenção: docs/plans/catalogo-falas-solla.md
+Appetite restante: herdado (~2–3 dias; a Fase 4 é wall-clock de máquina, sem trabalho humano)
+
+## Leitura da intenção
+
+- **Outcome:** o acervo da 57ª legislatura existe no banco local com transcrição oficial, segmentos ASR com minutagem, facetas (tema/alcance) com proveniência e menções; o import é reexecutável sem duplicar e imprime relatório por execução; a busca por palavra sobre os segmentos acha com e sem acento. C155 leva a produção.
+- **O que NÃO negociar:**
+ - Facetas com proveniência `gazetteer | llm | manual`; keyword oficial crua preservada (nunca substituída pela faceta).
+ - Leitura restrita a `communicator`, `coordinator` e `candidate` (+ Payload admin). `advisor` e `leader` não leem — nem via `/api`.
+ - Sem UI (C154), sem espelho de mídia/S3, sem normalizar pessoas citadas como `Contact`.
+ - Identidade do registro não é a URL do MP4 (regerável, hash não é contrato); import escreve só no DB local (C155 faz produção).
+- **O que reavaliar (hipóteses da intenção):**
+ - "LLM do Sollinha via `DEEPSEEK_API_KEY`" — essa chave não existe no ambiente do import; o mesmo modelo (`deepseek-ai/DeepSeek-V4-Flash`) está verificado ao vivo na Deep Infra, provedor/chave que a ASR já usa. Reavaliado na D3.
+ - "chave natural por áudio+trecho" (C152) sozinha não cobre discurso sem trecho (54ª/sessões sem âncora). Ela vira **identidade de vídeo** (decide pular VOD/ASR), não chave do registro. Ver D2.
+ - "Direção no codebase: script no padrão `recover-media.mjs`" — o padrão mais próximo é `seed-tse-results.mjs` (importa `src/` via tsx + `seed-loader`, guard de banco local, `assertLocalDatabase`). É o que este plano usa.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ subgraph Fonte Câmara
+ A[API discursos
dadosabertos] --> B[selectSpeechEvents]
+ B --> C[event page cache
data/camara/events]
+ C --> D[matchExcerpt]
+ C --> P[parsePresidingTransitions]
+ end
+ D --> E{VOD PRONTO?}
+ E -- sim --> F[MP4 cache
data/camara]
+ F --> G[Deep Infra Whisper
verbose_json segments]
+ E -- INDISPONIVEL / sem trecho --> R[registra sem segmentos]
+ A --> H[classifySpeech
gazetteer + LLM]
+ G --> I[upsertSpeechBundle
transação por sourceKey]
+ H --> I
+ P --> I
+ R --> I
+ I --> J[(speech + speechSegment)]
+ J --> K[searchText normalizado
+ GIN pg_trgm]
+```
+
+**Opções consideradas:** A | B | C por decisão (D1–D6 abaixo).
+**Recomendação:** as seis decisões, na ordem em que bloqueiam as demais; D1 e D2 travam o schema, D3–D5 travam o import e o access, D6 é dado de saída.
+**Rejeitadas:** registradas dentro de cada decisão (formato obrigatório da decision-quality).
+
+### Decisões de engenharia
+
+#### D1 — Segmentos em collection própria, não array do discurso
+
+- **Opções:** A) collection `speechSegment` (FK `speech`, `order`, `startSeconds`, `endSeconds`, `text`, `searchText`) | B) array de segmentos dentro de `speech` | C) um blob JSON em `speech`.
+- **Recomendação:** A — a busca global (`contains` + GIN trgm) roda sobre uma tabela própria, paginável e `select`-enxuta; o C154 busca segmento e abre o discurso sem carregar 40–100 segmentos por doc. `speechSegment` fica admin-hidden (precedente `SupporterImportBatch.ts:26`) porque a superfície é o C154.
+- **Rejeitadas:** B porque a query textual sobre array relacional do Payload não expõe `contains` por subcampo com o shape que o C154 precisa e infla todo read de discurso; C porque mata filtro por segmento e a indexação trigram.
+
+#### D2 — `sourceKey` da identidade da API + identidade de vídeo armazenada
+
+- **Opções:** A) `sourceKey = dataHoraInicio|tipoDiscurso|fase` (unique) e `eventId+audioId+excerptTMs` armazenados só para decidir re-transcrição | B) unique em `eventId+audioId+excerptTMs` | C) URL do MP4 como identidade.
+- **Recomendação:** A — cobre discurso sem trecho (senão ele nunca teria chave) e sobrevive à regeneração do VOD; o import acha por `sourceKey`, e se `audioId+excerptTMs` baterem com o que já está no banco **e houver segmentos**, pula VOD+ASR e só atualiza metadados. `--skip-transcribe`/ASR falho passam `segments: undefined` (preserva os antigos, não zera).
+- **Rejeitadas:** B porque discurso sem trecho ficaria de fora ou duplicaria a cada execução; C porque o `.mp4` é regerável e a permanência só foi medida em janela curta (C152).
+
+#### D3 — Classificador LLM via Deep Infra raw fetch (mesmo provedor da ASR)
+
+- **Opções:** A) raw fetch OpenAI-compat na Deep Infra, `model: deepseek-ai/DeepSeek-V4-Flash`, `response_format: json_object`, zod + fallback gazetteer | B) `@ai-sdk/deepseek` contra `api.deepseek.com` | C) só gazetteer.
+- **Recomendação:** A — entrega a opção A da intenção (DeepSeek flash + validação + fallback C) sem segundo provedor nem segredo novo: a `DEEPINFRA_API_KEY` já existe no env e é a mesma da transcrição; custo medido US$ 0,00007/discurso (234 ≈ US$ 0,016). Saída validada por zod; HTTP erro/timeout/parse inválido → `classifiedBy='gazetteer'` com topics/municípios do lexicon (scopes vazios se o lexicon não achar termo). `classifiedBy='manual'` nunca é sobrescrito (D2/hook abaixo).
+- **Rejeitadas:** B porque exige `DEEPSEEK_API_KEY` (ausente), acopla um SDK ao CLI para uma chamada única e cria dois caminhos de LLM no repo; C porque perde `scopes` (Bahia/Brasil/Internacional) e pessoas/programas, que são aceite de produto.
+
+#### D4 — Busca: `searchText` normalizado + GIN `pg_trgm`
+
+- **Opções:** A) coluna `searchText` normalizada app-side (NFD sem acento + lowercase) + índice GIN `gin_trgm_ops` hand-written | B) extensão `unaccent` + `ILIKE unaccent(text)` | C) `tsvector` com dicionário português.
+- **Recomendação:** A — `contains` funciona igual em dev/test/prod sem extensão nova além do `pg_trgm` já instalado (precedente `20260719_020000_add_contact_trgm_index.ts`); a normalização é pura, unit-testada em `src/lib/speechSearch.ts`, e o C154 importa a MESMA função para montar a query. Hook `beforeValidate` em `speechSegment` deriva `searchText` de `text` (criação e edição manual).
+- **Rejeitadas:** B porque `unaccent(text)` não é IMMUTABLE para índice funcional sem wrapper, depende de extension adicional e collation vira pegadinha; C porque `tsvector` faz stemming (piora prefixo/substring), exige config de dicionário e a semântica pedida é substring com/sem acento, não ranking lexical.
+
+#### D5 — Papel `communicator` com predicado próprio, fora de `isStaffCampaignRole`
+
+- **Opções:** A) novo valor no enum `campaignUser.role` + `canReadSpeechCatalog(role)` em `src/lib/campaignRoles.ts` + módulo `src/utilities/access/speeches.ts` reexportado por `campaignAccess.ts` | B) reusar `advisor`/staff | C) criar o leitor no `users` (admin Payload).
+- **Recomendação:** A — o assessor de comunicação lê o catálogo sem herdar nenhuma área de staff: `isStaffCampaignRole` (`src/lib/campaignRoles.ts:13`) fica intocado. Leitura: Payload admin **ou** campaignUser `communicator|coordinator|candidate`. Update (correção manual de facetas): `coordinator|candidate|admin`. Create/delete: `payloadAdminOnly`. Import CLI escreve com `overrideAccess: true` (comentário "bypass", padrão seed-tse). Guardas mínimas de shell para o papel não vazar staff: entrada em `campaignRoleLabels` (`campaignUserProfile.ts:9` — o `Record` quebra o typecheck sem ela), branch explícito em `getCampaignNav` (`nav.ts:85`, hoje devolveria `staffNav` a qualquer não-leader; `communicator` → nav vazia até o C154), e nenhuma mudança em `homeActionsForRole` (já devolve `[]` para não-staff, verificado em `campaignHomeActions.ts:124`).
+- **Rejeitadas:** B porque abriria o /campanha inteiro (viola "leitura restrita"); C porque o perfil de comunicação não é editor do CMS e ganharia o `/admin` todo.
+
+#### D6 — "Quem presidia": heurística "Troca da mesa" na página do evento
+
+- **Opções:** A) parse dos headings `g-l-assista__categoria-outros-videos` "Troca da mesa Presidente X por Participante Y" (transição = maior `t` do grupo; presidente em T = Y da transição mais recente ≤ T; antes da 1ª = X da mais antiga; sem transições = null) | B) deixar `presidingOfficer` sempre null | C) tirar do Diário (`urlTexto`) ou de outro endpoint.
+- **Recomendação:** A — é a única fonte observada com o dado (API não tem campo); o parser entra no `scripts/lib/camaraSpeeches.mjs` (dono da leitura da página, reusado pelo import) como `parsePresidingOfficerTransitions`/`resolvePresidingOfficer`, com fixture de HTML e unit tests; layout mudou → parse devolve `[]`/null e o import nunca quebra.
+- **Rejeitadas:** B porque joga fora dado pedido no aceite e já disponível; C porque `urlTexto` é a taquigrafia (não registra a mesa) e não há endpoint com o campo.
+
+### Componentes / mudanças
+
+- **`src/collections/Speech.ts`** (novo): slug `speech`, labels "Discurso"/"Discursos", `admin.group: 'Comunicação'`, `useAsTitle: 'speechAt'`, `admin.description` com o crédito CC BY 4.0 da Câmara. Campos: `sourceKey` (text, unique, index), `speechAt` (date, index), `year` (number, index; derivado por hook), `legislature` (select 54|55|56|57, index), `type`, `phase`, `durationSeconds` (number), `summary` (textarea), `officialTranscript` (textarea), `officialTextUrl`, `keywords` (text hasMany, cru), `eventId` (number, index), `eventType`, `eventStartAt`/`eventEndAt` (date), `youtubeUrl`, `audioId` (number, index), `excerptTMs` (number, index), `vodPlaybackUrl`/`vodDownloadUrl` (último conhecido, não identidade), `presidingOfficer`, `topics` (select hasMany, index, 18 valores de `SPEECH_TOPICS`), `scopes` (select hasMany, index, 3), `classifiedBy` (select `gazetteer|llm|manual`, default `gazetteer`), `mentionedMunicipalities` (relationship `municipality` hasMany), `mentionedPeople`/`mentionedPrograms`/`mentionedProjects` (text hasMany). Hooks: `beforeValidate` deriva `year`; `beforeChange` preserva facetas quando `originalDoc.classifiedBy === 'manual'` e a escrita é sem `req.user` (import/CLI) — edição autenticada no admin continua podendo corrigir e manter `manual`; `beforeDelete` apaga os segmentos do discurso (relacionamento Payload não tem FK). Access: `create/delete: payloadAdminOnly`, `read: canReadSpeech`, `update: canUpdateSpeech`.
+- **`src/collections/SpeechSegment.ts`** (novo): slug `speechSegment`, labels "Segmento de fala", `admin.hidden: () => true` (precedente `SupporterImportBatch.ts:26`). Campos: `speech` (relationship, required, index), `order` (number, required), `startSeconds`/`endSeconds` (number, required), `text` (textarea, required), `searchText` (text, required, `admin.readOnly`, sem `index` — o GIN é hand-written). Hook `beforeValidate`: `searchText = normalizeForSearch(data.text ?? originalDoc?.text)`. Access espelha `speech` (read `canReadSpeech`; mutações `payloadAdminOnly`).
+- **`src/payload.config.ts`**: registrar `Speech` e `SpeechSegment` no array `collections` (imports + entradas; grupo novo "Comunicação" aparece no admin).
+- **`src/lib/speechFacets.ts`** (novo, puro/client-safe): contrato `SPEECH_TOPICS` (18 slugs pt — valores de dado, precedente `post.type`; nomes de código em inglês), `SPEECH_SCOPES`, `SPEECH_CLASSIFICATION_SOURCES`; lexicon gazetteer tema→termos; `matchTopicFacets(text)` com boundary de token (teste: "sus" NÃO casa "suspeito"); `matchMunicipalityMentions(text)` contra `municipalityCatalog` com consumo guloso do nome mais longo (testes: Conde vs Condeúba; São Félix vs São Félix do Coribe) — **menção a "Salvador" mapeia para as 19 zonas do catálogo** (a cidade não tem linha própria; a menção à cidade vale para qualquer zona, e o C154 filtra por município; documentar no campo/admin description). `parseLlmFacetResponse(json)` com zod (filtra para valores conhecidos, dedupe normalizado, caps de pessoas/programas/projetos); `mergeFacetClassification(gazetteer, llm)` (topics = união; scopes = do LLM validado ou do lexicon; `classifiedBy = llm ? 'llm' : 'gazetteer'`).
+- **`src/lib/speechSearch.ts`** (novo, puro/client-safe): `normalizeForSearch(value)` (NFD, remove combining marks, lowercase, colapsa espaços) e `uniqueByNormalizedForm(values)`; importado pelo hook do segmento, pelo gazetteer e pelo C154.
+- **`src/utilities/speech/speechImport.ts`** (novo, `server-only`): `findSpeechImportState(payload, sourceKey)` (leitura para o skip de VOD/ASR) e `upsertSpeechBundle(payload, bundle)` — reusa `withPayloadTransaction` (`src/utilities/payloadTransaction.ts:86`), acha por `sourceKey`, cria/atualiza, pula campos de faceta se existente é `manual`, substitui segmentos **somente quando `bundle.segments !== undefined`**, tudo com `req: { transactionID }` e `overrideAccess: true` com comentário "bypass". Retorna counts (created/updated/segmentsInserted/segmentsDeleted/manualPreserved) para o relatório.
+- **`src/utilities/speech/speechClassifier.ts`** (novo, `server-only`): `classifySpeech(input)` com `DEEPINFRA_API_KEY`; raw fetch OpenAI-compat (`https://api.deepinfra.com/v1/openai/chat/completions`, `deepseek-ai/DeepSeek-V4-Flash`, `response_format: json_object`, `temperature: 0`, timeout), transcript truncado (≈12k chars) + keywords cruas; valida com `parseLlmFacetResponse`; qualquer falha/chave ausente → gazetteer-only. Nunca lança.
+- **`src/utilities/access/speeches.ts`** (novo): `canReadSpeech` (admin ∪ campaignUser `communicator|coordinator|candidate`), `canUpdateSpeech` (admin ∪ coordinator/candidate); reexport no barrel `src/utilities/campaignAccess.ts` (prefixo `src/utilities/access` já mapeado no E2E manifest — sem mudança de manifesto).
+- **`src/lib/campaignRoles.ts`**: `canReadSpeechCatalog(role)` client-safe (o predicado próprio, fora de `isStaffCampaignRole`).
+- **`src/collections/CampaignUser.ts`**: opção `{ label: 'Assessor de Comunicação', value: 'communicator' }` no select de role (linhas 483–488).
+- **`src/utilities/campaignUserProfile.ts`**: `communicator: 'Assessor de Comunicação'` em `campaignRoleLabels` (obrigatório para o typecheck pós-`generate:types`).
+- **`src/components/campaign/shell/nav.ts`**: branch `if (role === 'communicator') return []` em `getCampaignNav` (nav vazia; C154 adiciona "Falas"). `getCampaignSecondaryNav`/`getCampaignBottomNav` já devolvem `[]` via `isStaffCampaignRole`.
+- **`scripts/lib/camaraSpeeches.mjs`** (editar o dono): adicionar `parsePresidingOfficerTransitions(html)` e `resolvePresidingOfficer(transitions, tMs)` + export; reusar `parseEventExcerpts`, `buildVodUrl`, `parseVodStatus`, `selectSpeechEvents`, `matchExcerpt`, `normalizeTranscription`, `LEGISLATURE_RANGES`, `SOLLA_DEPUTY_ID`, `CAMARA_USER_AGENT`.
+- **`scripts/import-camara-speeches.mjs`** (novo): `dieWithLabel('camara:import')`, `loadCliEnv`, `ensureCachedDownload`/`writeRepoFile` de `scripts/lib/cli.mjs`, `assertLocalDatabase`; flags `--legislature` (default 57) `--date` `--limit` `--skip-transcribe` `--reclassify` `--out` `--help`; cache de lista de eventos por dia e de página de evento em `data/camara/events/`, MP4 em `data/camara/` (já gitignored); prioriza `Sessão Deliberativa`/`Breves Comunicações` e capa a varredura de eventos por discurso (C152: ~50s/dia quando não há trecho); retry com backoff e delay educado; importa `speechImport`/`speechClassifier`/config via dynamic import literal (knip resolve); relatório no stdout + JSON em `data/camara/reports/.json`; falha por discurso entra em `failures[]` e o run continua (resumível); exit 1 só em fatal (DB/guard).
+- **`package.json`**: `"camara:import": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-camara-speeches.mjs"` (padrão `db:seed:tse`; `seed-loader` cobre `server-only` e `next/cache`).
+- **Migrations:** `pnpm migrate:create add_speech_catalog` (rebase antes, conforme engineering-standards) → se o gerador não emitir, adicionar à mão `ALTER TYPE "public"."enum_campaign_user_role" ADD VALUE IF NOT EXISTS 'communicator'` (precedente `20260723_200000_remodel_municipalities.ts:105`; PG 12+ aceita dentro da transação e nada usa o valor nela; `down` irreversível documentado). Depois, hand-written `src/migrations/_add_speech_segment_trgm_index.ts` (`CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX IF NOT EXISTS "speech_segment_search_text_trgm_idx" ON "speech_segment" USING gin ("search_text" gin_trgm_ops);`, `down` dropa só o índice) registrado em `src/migrations/index.ts` como o `20260719_020000_add_contact_trgm_index`.
+- **`pnpm generate:types`**: `payload-types.ts` ganha `Speech`/`SpeechSegment` e `'communicator'` no union de role.
+- **Testes:** `tests/unit/speechFacets.unit.spec.ts`, `tests/unit/speechSearch.unit.spec.ts`, extensão de `tests/unit/camaraSpeeches.unit.spec.ts` (presiding + HTML fixture), `tests/int/speechCatalog.int.spec.ts` (access allow/deny + hook `searchText` + `contains`), `tests/int/speechImport.int.spec.ts` (idempotência, replace de segmentos, manual preservado, `segments: undefined` preserva, estado de skip).
+- **Changelog:** `docs/changelog/2026-09-12-c153.md`.
+
+### Dados → forma (se aplicável)
+
+- **Sem UI** (N/A por decisão de produto). A "forma" deste item é o **relatório de execução** (stdout + JSON): `runAt`, `range`, `totals { listed, processed, created, updated, skippedExisting, withVideo, withSegments, withoutExcerpt, failed }`, `asr { calls, audioSeconds, estimatedCostUsd }` (US$ 0,00045/min), `llm { calls, totalTokens, estimatedCostUsd }`, `elapsedMs`, `failures[] { sourceKey, speechAt, stage: 'event'|'excerpt'|'vod'|'asr'|'classify'|'upsert', message }`.
+- **Rejeitadas:** dashboard/admin como superfície (é o C154; aqui só dados), CSV de saída (o JSON do relatório já é legível por máquina), relatório por discurso no banco (ruído; o relatório é artefato de execução, não entidade).
+
+## Fases verificáveis
+
+1. **Tracer / schema+access+migration** (≈0,5 dia): collections registradas, papel `communicator` (option + label + predicado + nav), `access/speeches.ts` + reexport, migrations (catálogo + enum + trgm), `generate:types`. **Prova vertical (tracer cedo):** `tests/int/speechCatalog.int.spec.ts` cria 1 `speech` + 1 `speechSegment` via Local API, lê por `searchText contains 'saude'` com texto acentuado, e prova allow/deny por papel (communicator/coordinator/candidate/admin leem; advisor/leader/anônimo não; mutação só admin/coordinator/candidate). Aplicar migrations no DB de dev e no `_test` (skill `local-database`) antes do int.
+2. **Módulos puros + unit** (≈0,5 dia): `speechFacets.ts`, `speechSearch.ts`, `canReadSpeechCatalog`; extensão do `camaraSpeeches.mjs` (presiding). Unit verde: boundary de tema, Conde/Condeúba, Salvador → 19 zonas, merge gazetteer+llm, zod rejeitando saída fora da taxonomia, normalização de busca, heurística da mesa com HTML congelado.
+3. **Import + upsert + int** (≈1 dia): `speechImport.ts`, `speechClassifier.ts`, `scripts/import-camara-speeches.mjs`, `package.json`. Int verde: upsert duas vezes = 1 speech + N segmentos estáveis; `classifiedBy='manual'` preservado por upsert sem `req.user`; `segments: undefined` não apaga; busca `contains` normalizada acha acento; estado de skip bate por `audioId+excerptTMs`. Smoke real com `--legislature 57 --limit 3` (com e sem `--skip-transcribe`).
+4. **Prova viva 57ª** (≈0,5 dia de operação; ≈2–3h de máquina, sem trabalho humano): `pnpm camara:import --legislature 57` em `teqo_wt153`; depois **re-executar** e conferir `created=0`/sem duplicata; verificar contagens do relatório (234 listados, `%` com segmentos, falhas), busca por "saude", "SUS", "Petrobras" e filtro por município citado (ex.: Feira de Santana) via Local API; guardar o JSON do relatório.
+5. **Gates / changelog / PR** (≈0,25 dia): `pnpm gate:fast`, `pnpm migrate` + `migrate:status` limpos, `pnpm knip`, `pnpm generate:types` sem diff, `pnpm test:e2e:affected` (prefixo `src/utilities/access` → curated `campaignPermissionProfileHttp`/`campaignDemandVisibility`), changelog, `pnpm push`; PR ready (merge pelo fluxo OPS71). C155 fica fora.
+
+**Quota total:** ~2,75 dias, dentro do appetite; o caminho crítico é a Fase 4 (wall-clock), que roda desatendida.
+
+## Rabbit holes / Não escopo (engenharia)
+
+- **Knip e dynamic import:** o script importa `src/` por dynamic import literal (como `seed-tse-results.mjs:44-50`); não usar `import(variable)` nem `require` — quebra o grafo do knip (`exports: error`). Testes unit/int importam os módulos estaticamente e ancoram o resto.
+- **Top-level pin de `src/utilities`:** tudo em `src/utilities/speech/`; nada solto na raiz de `utilities/`.
+- **Editor de vídeo / clipes / espelhamento S3:** fora (C155/fase própria); só links do VOD e cache local gitignored.
+- **Comissões e audiências:** a API de discursos cobre Plenário; fora.
+- **Normalizar pessoas citadas como `Contact`:** fora; menções são texto pesquisável.
+- **54ª legislatura (2011–2015):** sem trecho por decisão; tratar como vazia, não como falha.
+- **UI/busca do assessor (C154):** aqui só o contrato (constantes de faceta, `normalizeForSearch`, shape da query).
+- **Reclassificação automática agendada / backfill 2011–2026 / produção:** C155.
+- **Busca semântica, `unaccent`, `tsvector`, query builder compartilhado:** fora (D4); se o C154 precisar de mais que `searchText contains normalizeForSearch(q)`, revisitar lá.
+- **Collection `person` paralela / Consent novo:** dado parlamentar público (CC BY 4.0), sem PII de cidadão; o guardrail é access, não Consent.
+- **Rate-limit scheduler / fila:** o run é sequencial e resumível; nada de job queue.
+
+## Riscos e mitigação
+
+- **Anti-crawl (400 a UA de curl):** UA de browser do `CAMARA_USER_AGENT`; nunca bater na URL de trecho com `crawl=no`; só página do evento + `video-sob-demanda`; cache por evento em `data/camara/events/`; delay educado e retry com backoff.
+- **VOD `INDISPONIVEL` ou preso em `GERANDO`:** `parseVodStatus` cobre; fallback YouTube (`urlRegistro` + offset `t/1000 − startTime`); sem `urlRegistro` → "sem vídeo" no relatório; nunca falha o import.
+- **54ª/sessão sem âncora:** "sem trecho" documentado (C152); metadados entram, ASR não; cobertura útil 2015+.
+- **LLM sem chave/falha:** fallback gazetteer-only (`classifiedBy='gazetteer'`, scopes vazios); `--reclassify` reprocessa depois sem re-transcrever.
+- **Custo/tempo do run longo:** ~15–16h de áudio ≈ US$ 0,42 de ASR + ~US$ 0,02 de LLM + 2–3h de parede; script resumível (`sourceKey` + cache de MP4/página), `--limit` para smoke, falha por discurso não aborta o run.
+- **Enum ADD VALUE em transação:** PG 12+ permite, mas o valor não pode ser usado na mesma transação; a migration só adiciona (`IF NOT EXISTS`) e nada insere `communicator` nela; `down` irreversível documentado (precedente do remodel).
+- **Layout da página do evento mudar:** parsers devolvem vazio/null, nunca lançam; unit com HTML congelado detecta a quebra; relatório expõe `withoutExcerpt`/`presidingOfficer=null`.
+- **Rate limit/instabilidade da API:** sequencial, `itens=100`, retry limitado com backoff (padrão do pilot), cache agressivo; falha vira `failures[]` e retoma na próxima execução.
+- **Colisão de `sourceKey`** (dois itens com mesmo `dataHoraInicio|tipo|fase`): detectada no run, segundo item vira falha reportada (nunca sobrescreve em silêncio); se ocorrer, revisitar a chave incluindo `urlTexto`.
+- **Faceta manual sobrescrita:** importer pula campos quando existente é `manual` + hook de preservação para escrita sem `req.user`; int test prova.
+
+## Aceite de engenharia
+
+- [ ] Aceite de produto da intenção coberto: 57ª no banco local com transcrição oficial + segmentos + facetas + menções; reexecução sem duplicar; relatório por execução (total, com vídeo, sem trecho, falhas, tempo, custo ASR); busca por palavra com e sem acento; keywords cruas preservadas.
+- [ ] Invariantes AGENTS/engineering-standards: `access` explícito nas duas collections; migration via `pnpm migrate:create` (+ índice hand-written registrado); `src/utilities/speech/`; `server-only` nos módulos acoplados; identificadores em inglês; sem `Contact` paralelo; `data/camara/` gitignored; `overrideAccess: true` com comentário "bypass"; knip sem órfão; crédito CC BY 4.0 no admin/relatório.
+- [ ] Testes de domínio: unit (facets, search, presiding, role predicate) e int (access allow/deny, upsert idempotente, replace de segmentos, manual preservado, `segments: undefined`, hook `searchText` + `contains`).
+- [ ] Gates: `pnpm gate:fast`; `pnpm migrate`/`migrate:status` limpos; `pnpm knip`; `pnpm generate:types` sem diff; `pnpm test:e2e:affected`; `pnpm push`; changelog `docs/changelog/2026-09-12-c153.md`.
+- [ ] Prova viva registrada (relatório JSON da 57ª + re-run idempotente) no PR.
+
+## Self-score decision-quality
+
+1. **Decisões caras têm rejeitadas?** 5/5 — as seis decisões caras (schema, unicidade/idempotência, provedor LLM, mecanismo de busca, RBAC, dado de "quem presidia") estão no formato Opções|Recomendação|Rejeitadas, com o porquê das rejeitadas; as baratas (nomes de campos, ordem de colunas, prompt) ficam como fill-in.
+2. **Cabe no appetite?** 5/5 — ~2,75 dias contra 2–3 herdados; o único trecho longo é wall-clock desatendido (Fase 4), sem UI e sem pipeline de mídia.
+3. **Rabbit holes nomeados?** 5/5 — os quatro de produto da intenção + os de engenharia (knip/dynamic import, top-level pin, unaccent/tsvector, query builder, job queue, collection de pessoa).
+4. **Depth check reusa?** 5/5 — `withPayloadTransaction`, `payloadAdminOnly`/padrão de `access/*` + barrel, `pg_trgm` hand-written, `seed-tse-results.mjs` como molde de CLI, `SupporterImportBatch` como precedente de admin-hidden, `municipalityCatalog` como gazetteer, e o `camaraSpeeches.mjs` **estendido** (não duplicado) para a mesa. Nenhum pass-through novo.
+5. **Intenção permanece satisfeita?** 5/5 — a engenharia não reescreveu o outcome: sem UI, sem espelho, sem `Contact`, leitura restrita com papel novo que não herda staff, e a única reavaliação (D3) entrega a mesma opção A da intenção por uma rota que já tem chave, registrada para o gate.
+
+## Débitos (triage pós-simplify)
+
+Nenhum achado dos dois revisores virou Issue nova (nenhum `expensive_lock` com score ≥4). O que ficou:
+
+**Deferido com gatilho:**
+
+- **Cache/paginação por script** (`scripts/import-camara-speeches.mjs` vs `pilot-camara-speeches.mjs`): a extração parou no HTTP (`scripts/lib/camaraFetch.mjs`); o fluxo de cache de evento/paginação vive só no import. Gatilho: o pilot virar recorrente ou um 3º consumidor do fluxo.
+- **Constantes Deep Infra duplicadas** (`scripts/lib/camaraSpeeches.mjs` vs `src/utilities/ai/deepInfraTranscribe.ts`): o pilot roda em node puro e não importa `src/`. Gatilho: mudança de endpoint/modelo ou 3º consumidor.
+- **`llm.calls` conta tentativas** mesmo sem `DEEPINFRA_API_KEY` (o import degrada para gazetteer por design; o pilot faz preflight e morre). Gatilho: próximo ajuste do relatório de import.
+- **Índice em `speechSegment.order`**: YAGNI (poucos segmentos por discurso; a FK `speech` já filtra). Gatilho: EXPLAIN/latência real no C154.
+- **Chips do Sollinha para `communicator`** (`src/lib/sollinhaOpeningQuestions.ts`): o fallback não-staff devolve chips de líder; inofensivo (access é checado no servidor), mas fora do vertical. Gatilho: C154, dono da vertical de comunicação.
+
+**Descartado:** redundância do `searchText` no create (exigida pelo `required` do tipo gerado); ordenação defensiva dupla do presiding officer; comentários "Intentional bypass" por site (exigidos pelo ratchet); wall-clock do run (resumível/idempotente).
+
+**Já resolvido no simplify (não reabrir):**
+
+- Perda de mídia quando o evento não resolve no run: `excerptChanged` só compara com excerpt presente e `preserveStoredMedia` mantém `audioId`/`excerptTMs`/VOD/duração (bug HIGH do revisor de qualidade).
+- `--reclassify` com LLM fora do ar não rebaixa mais uma classificação `llm` para gazetteer (preserva people/programs/projects).
+- Envelope do provider validado por zod (sem `as` no `speechClassifier`).
+- `parseDurationToSeconds` estrito (rejeita `1::30`, `1:99:99`, `0h99'00"`).
+- Testes unit dos helpers puros (`speechSourceKey`, `parseOfficialKeywords`, `legislatureForDate`, `parseDurationToSeconds`).
+- Léxico gazetteer separado em `src/lib/speechGazetteer.ts` (o contrato `speechFacets.ts` fica leve para o bundle do C154).
+- Aliases de label do `topicByKey` derivados de `SPEECH_TOPICS` (fim da lista manual).
+- Guard de `year` no import (data malformada não vira 0).
diff --git a/package.json b/package.json
index f8b6ebdc..6bb5f8e8 100644
--- a/package.json
+++ b/package.json
@@ -82,6 +82,7 @@
"images:resize": "node scripts/resize-images.mjs",
"images:fill": "node scripts/fill-image.mjs",
"camara:pilot": "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",
"typecheck": "tsc --noEmit",
"typecheck:tsc6": "tsc6 --noEmit",
"prepare": "husky",
diff --git a/scripts/import-camara-speeches.mjs b/scripts/import-camara-speeches.mjs
new file mode 100644
index 00000000..d623d740
--- /dev/null
+++ b/scripts/import-camara-speeches.mjs
@@ -0,0 +1,510 @@
+/**
+ * C153 import — populates the speech catalog from the Câmara sources:
+ * open-data speeches → event on that date → speaker excerpt on the event page
+ * → async VOD → Deep Infra Whisper segments (minutagem) + facet classification
+ * (gazetteer + LLM) → idempotent upsert into `speech`/`speechSegment`.
+ *
+ * Re-running is safe: records are matched by `sourceKey`, metadata is
+ * refreshed, VOD/ASR is skipped when the excerpt identity and segments are
+ * already stored, and a `manual` facet curation is never overwritten.
+ *
+ * Media and reports land under `data/camara/` (gitignored). The local-DB guard
+ * refuses a non-local DATABASE_URL; production is C155.
+ *
+ * Usage:
+ * pnpm camara:import --legislature 57
+ * pnpm camara:import --date 2023-02-07 --limit 3
+ * pnpm camara:import --legislature 57 --skip-transcribe
+ * pnpm camara:import --legislature 57 --reclassify
+ */
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+
+import { getPayload } from 'payload'
+
+import { assertLocalDatabase } from './assert-local-database.mjs'
+import {
+ DEEPINFRA_COST_PER_MINUTE_USD,
+ downloadToBuffer,
+ eventPageUrl,
+ eventsUrl,
+ getJson,
+ getText,
+ resolveVod,
+ speechesUrl,
+ transcribeSpeechAudio,
+} from './lib/camaraFetch.mjs'
+import {
+ LEGISLATURE_RANGES,
+ SOLLA_DEPUTY_ID,
+ legislatureForDate,
+ matchExcerpt,
+ parseDurationToSeconds,
+ parseEventExcerpts,
+ parseOfficialKeywords,
+ parsePresidingOfficerTransitions,
+ resolvePresidingOfficer,
+ selectSpeechEvents,
+ speechDate,
+ speechSourceKey,
+} from './lib/camaraSpeeches.mjs'
+import { dieWithLabel, ensureCachedDownload, loadCliEnv } from './lib/cli.mjs'
+
+loadCliEnv()
+
+const die = dieWithLabel('camara:import')
+
+const config = (await import('../src/payload.config.ts')).default
+const { findSpeechImportState, upsertSpeechBundle } =
+ await import('../src/utilities/speech/speechImport.ts')
+const { classifySpeech } = await import('../src/utilities/speech/speechClassifier.ts')
+
+const DEFAULT_OUT_DIR = 'data/camara'
+const MAX_EVENT_CANDIDATES = 5
+const EVENT_TYPE_PRIORITY = [
+ 'Sessão Deliberativa',
+ 'Breves Comunicações',
+ 'Sessão Não Deliberativa',
+ 'Sessão Solene',
+ 'Outro Evento',
+]
+
+// ---------------------------------------------------------------------------
+// CLI
+// ---------------------------------------------------------------------------
+
+const parseArgs = (argv) => {
+ const options = {
+ legislature: 57,
+ date: null,
+ limit: null,
+ speaker: 'Jorge Solla',
+ out: DEFAULT_OUT_DIR,
+ skipTranscribe: false,
+ reclassify: 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 === '--skip-transcribe') options.skipTranscribe = true
+ else if (arg === '--reclassify') options.reclassify = true
+ else if (arg === '--legislature') options.legislature = Number(value())
+ else if (arg === '--date') options.date = value()
+ else if (arg === '--limit') options.limit = Number(value())
+ else if (arg === '--speaker') options.speaker = value()
+ else if (arg === '--out') options.out = value()
+ else die(`argumento desconhecido: ${arg}`)
+ }
+ if (!options.help && !options.date && !LEGISLATURE_RANGES[options.legislature]) {
+ die('informe --legislature <54|55|56|57> ou --date (veja --help).')
+ }
+ if (!options.help && options.date && !/^\d{4}-\d{2}-\d{2}$/.test(options.date)) {
+ die('--date deve ser YYYY-MM-DD.')
+ }
+ if (options.limit !== null && (!Number.isInteger(options.limit) || options.limit < 1)) {
+ die('--limit deve ser >= 1.')
+ }
+ if (!options.help && options.out.includes('..')) {
+ die('--out não pode escapar do diretório do repo (sem "..").')
+ }
+ return options
+}
+
+const HELP = `
+Uso: pnpm camara:import [--legislature 54|55|56|57] [--date YYYY-MM-DD] [opções]
+
+Opções:
+ --legislature 54|55|56|57 (default 57; ignorado se --date)
+ --date discursos de um único dia
+ --limit quantos discursos processar (default: todos)
+ --speaker nome do orador a casar no evento (default "Jorge Solla")
+ --out diretório de cache/artefatos (default data/camara)
+ --skip-transcribe não chama a Deep Infra (preserva os segmentos existentes)
+ --reclassify refaz a classificação mesmo já tendo facetas da LLM
+ --help esta ajuda
+
+A página do evento é cacheada em /events; apague-a para reler a Câmara.
+`
+
+// ---------------------------------------------------------------------------
+// Caches
+// ---------------------------------------------------------------------------
+
+const eventsByDate = new Map()
+const eventPages = new Map()
+
+async function getEventsForDate(date) {
+ const cached = eventsByDate.get(date)
+ if (cached) return cached
+ const events = (await getJson(eventsUrl(date))).dados ?? []
+ eventsByDate.set(date, events)
+ return events
+}
+
+async function loadEventPage(eventId, options) {
+ const cached = eventPages.get(eventId)
+ if (cached) return cached
+ const path = join(options.out, 'events', `${eventId}.html`)
+ let html
+ try {
+ html = await readFile(path, 'utf8')
+ } catch (error) {
+ if (error?.code !== 'ENOENT') throw error
+ html = await getText(eventPageUrl(eventId), { timeoutMs: 60_000 })
+ await mkdir(dirname(path), { recursive: true })
+ await writeFile(path, html)
+ }
+ eventPages.set(eventId, html)
+ return html
+}
+
+async function fetchSpeeches(deputyId, from, to) {
+ const speeches = []
+ for (let page = 1; ; page += 1) {
+ const body = await getJson(`${speechesUrl(deputyId, from, to)}&pagina=${page}`)
+ const batch = body.dados ?? []
+ speeches.push(...batch)
+ const hasNext = (body.links ?? []).some((link) => link?.rel === 'next')
+ if (!hasNext || batch.length === 0) break
+ }
+ return speeches
+}
+
+// ---------------------------------------------------------------------------
+// Pipeline
+// ---------------------------------------------------------------------------
+
+const eventPriority = (event) => {
+ const index = EVENT_TYPE_PRIORITY.indexOf(event?.descricaoTipo)
+ return index === -1 ? EVENT_TYPE_PRIORITY.length : index
+}
+
+/** Nearest event of the speech date, preferred session types first. */
+async function resolveSpeechContext(speech, options) {
+ const candidates = selectSpeechEvents(
+ await getEventsForDate(speechDate(speech.dataHoraInicio)),
+ speech.dataHoraInicio,
+ )
+ const ordered = [...candidates].sort((left, right) => eventPriority(left) - eventPriority(right))
+ for (const event of ordered.slice(0, MAX_EVENT_CANDIDATES)) {
+ const html = await loadEventPage(event.id, options)
+ const excerpt = matchExcerpt(
+ parseEventExcerpts(html, event.id),
+ speech.dataHoraInicio,
+ options.speaker,
+ )
+ if (excerpt) return { event, html, excerpt }
+ }
+ return { event: ordered[0] ?? null, html: null, excerpt: null }
+}
+
+async function transcribeExcerpt(eventId, excerpt, options) {
+ const vod = await resolveVod(eventId, excerpt)
+ if (vod.status.state !== 'PRONTO' || !vod.status.video?.downloadUrl) {
+ return {
+ vodState: vod.status.state,
+ failure: `VOD não ficou PRONTO (estado=${vod.status.state})`,
+ }
+ }
+
+ const cached = await ensureCachedDownload({
+ label: 'camara:import',
+ key: `${eventId}-${excerpt.audioId}-${excerpt.tMs}`,
+ url: vod.status.video.downloadUrl,
+ ext: 'mp4',
+ cacheDir: options.out,
+ download: downloadToBuffer,
+ })
+ const { transcription, elapsedMs } = await transcribeSpeechAudio(cached.buffer)
+ return {
+ vodState: vod.status.state,
+ segments: transcription.segments.map((segment) => ({
+ startSeconds: segment.start,
+ endSeconds: segment.end,
+ text: segment.text,
+ })),
+ durationSeconds: transcription.duration,
+ playbackUrl: vod.status.video.playbackUrl,
+ downloadUrl: vod.status.video.downloadUrl,
+ audioSeconds: transcription.duration ?? 0,
+ elapsedMs,
+ }
+}
+
+async function processSpeech(payload, speech, options, run) {
+ const sourceKey = speechSourceKey(speech)
+ const report = {
+ sourceKey,
+ speechAt: speech.dataHoraInicio,
+ eventId: null,
+ excerpt: null,
+ vodState: null,
+ presidingOfficer: null,
+ classifiedBy: null,
+ segmentCount: 0,
+ created: false,
+ manualFacetsPreserved: false,
+ failures: [],
+ }
+
+ const existing = await findSpeechImportState(payload, sourceKey)
+ const keywords = parseOfficialKeywords(speech.keywords)
+ const date = speechDate(speech.dataHoraInicio)
+ const year = /^\d{4}-\d{2}-\d{2}$/.test(date) ? Number(date.slice(0, 4)) : null
+
+ let context = { event: null, html: null, excerpt: null }
+ try {
+ context = await resolveSpeechContext(speech, options)
+ } catch (error) {
+ report.failures.push({ stage: 'event', message: error?.message ?? String(error) })
+ }
+ const { event, excerpt } = context
+ if (event) report.eventId = event.id
+ if (excerpt) {
+ report.excerpt = { audioId: excerpt.audioId, tMs: excerpt.tMs, startTime: excerpt.startTime }
+ }
+ const transitions = context.html ? parsePresidingOfficerTransitions(context.html) : []
+ report.presidingOfficer = excerpt ? resolvePresidingOfficer(transitions, excerpt.tMs) : null
+
+ // Identity/media carry-over: a run that failed to resolve the event page must
+ // not wipe the stored excerpt or force a re-transcription next time.
+ const excerptChanged =
+ Boolean(excerpt) &&
+ Boolean(existing) &&
+ (existing.audioId !== excerpt.audioId || existing.excerptTMs !== excerpt.tMs)
+ const preserveStoredMedia = !excerpt || !excerptChanged
+ const needsTranscription =
+ Boolean(excerpt) &&
+ !options.skipTranscribe &&
+ (!existing || excerptChanged || existing.segmentCount === 0)
+
+ let media = {}
+ if (needsTranscription) {
+ try {
+ media = await transcribeExcerpt(event.id, excerpt, options)
+ if (media.failure) report.failures.push({ stage: 'vod', message: media.failure })
+ if (media.segments) {
+ run.asr.calls += 1
+ run.asr.audioSeconds += media.audioSeconds
+ run.asr.elapsedMs += media.elapsedMs ?? 0
+ }
+ } catch (error) {
+ run.asr.failures += 1
+ report.failures.push({ stage: 'asr', message: error?.message ?? String(error) })
+ }
+ }
+
+ const skipClassification =
+ existing?.classifiedBy === 'manual' || (existing?.classifiedBy === 'llm' && !options.reclassify)
+ let facets
+ if (skipClassification) {
+ report.classifiedBy = existing.classifiedBy
+ } else {
+ const classification = await classifySpeech({
+ transcript: speech.transcricao ?? '',
+ summary: speech.sumario ?? null,
+ keywords,
+ })
+ run.llm.calls += 1
+ if (classification.llm.used) {
+ facets = classification.facets
+ report.classifiedBy = classification.facets.classifiedBy
+ run.llm.used += 1
+ run.llm.totalTokens += classification.llm.totalTokens ?? 0
+ run.llm.estimatedCostUsd += classification.llm.estimatedCostUsd ?? 0
+ } else if (existing?.classifiedBy === 'llm') {
+ // Never downgrade a stored LLM classification to the gazetteer fallback
+ // (that would drop the extracted people/programs/projects).
+ report.classifiedBy = existing.classifiedBy
+ report.failures.push({
+ stage: 'classify',
+ message: `LLM indisponível (${classification.llm.error ?? 'erro'}); facetas anteriores preservadas`,
+ })
+ run.llm.failed += 1
+ } else {
+ facets = classification.facets
+ report.classifiedBy = classification.facets.classifiedBy
+ run.llm.failed += 1
+ if (classification.llm.error && run.llm.sampleError === null) {
+ run.llm.sampleError = classification.llm.error
+ }
+ }
+ }
+
+ const durationSeconds =
+ media.durationSeconds ??
+ (preserveStoredMedia ? (existing?.durationSeconds ?? null) : null) ??
+ parseDurationToSeconds(excerpt?.duration)
+ const bundle = {
+ sourceKey,
+ speechAt: String(speech.dataHoraInicio ?? '').trim(),
+ year,
+ legislature: legislatureForDate(speech.dataHoraInicio),
+ type: speech.tipoDiscurso ?? null,
+ phase: speech.faseEvento?.titulo ?? null,
+ durationSeconds,
+ summary: speech.sumario ?? null,
+ officialTranscript: speech.transcricao ?? null,
+ officialTextUrl: speech.urlTexto ?? null,
+ keywords,
+ eventId: event?.id ?? null,
+ eventType: event?.descricaoTipo ?? null,
+ eventStartAt: event?.dataHoraInicio ?? null,
+ eventEndAt: event?.dataHoraFim ?? null,
+ youtubeUrl: event?.urlRegistro ?? null,
+ presidingOfficer: report.presidingOfficer,
+ audioId: excerpt?.audioId ?? (preserveStoredMedia ? (existing?.audioId ?? null) : null),
+ excerptTMs: excerpt?.tMs ?? (preserveStoredMedia ? (existing?.excerptTMs ?? null) : null),
+ vodPlaybackUrl:
+ media.playbackUrl ?? (preserveStoredMedia ? (existing?.vodPlaybackUrl ?? null) : null),
+ vodDownloadUrl:
+ media.downloadUrl ?? (preserveStoredMedia ? (existing?.vodDownloadUrl ?? null) : null),
+ facets,
+ segments: media.segments,
+ }
+
+ try {
+ const counts = await upsertSpeechBundle(payload, bundle)
+ report.created = counts.created
+ report.manualFacetsPreserved = counts.manualFacetsPreserved
+ report.segmentCount = media.segments ? media.segments.length : (existing?.segmentCount ?? 0)
+ run.totals.created += counts.created ? 1 : 0
+ run.totals.updated += counts.created ? 0 : 1
+ } catch (error) {
+ report.failures.push({ stage: 'upsert', message: error?.message ?? String(error) })
+ }
+
+ run.totals.withExcerpt += excerpt ? 1 : 0
+ run.totals.withoutExcerpt += excerpt ? 0 : 1
+ run.totals.withSegments += report.segmentCount > 0 ? 1 : 0
+ run.totals.failed += report.failures.length > 0 ? 1 : 0
+ return report
+}
+
+// ---------------------------------------------------------------------------
+// Report
+// ---------------------------------------------------------------------------
+
+const printReport = (run) => {
+ const { totals, asr, llm, failures } = run
+ console.log('\n=== Relatório do import C153 ===')
+ console.log(
+ `discursos: ${totals.listed} listados, ${totals.processed} processados ` +
+ `(${totals.created} criados, ${totals.updated} atualizados)`,
+ )
+ console.log(
+ `vídeo: ${totals.withExcerpt} com trecho, ${totals.withoutExcerpt} sem trecho; ` +
+ `${totals.withSegments} com segmentos`,
+ )
+ console.log(
+ `ASR: ${asr.calls} chamadas, ${(asr.audioSeconds / 60).toFixed(1)} min de áudio, ` +
+ `~US$ ${((asr.audioSeconds * DEEPINFRA_COST_PER_MINUTE_USD) / 60).toFixed(4)}, ` +
+ `${(asr.elapsedMs / 1000).toFixed(0)}s de processamento, ${asr.failures} falhas`,
+ )
+ console.log(
+ `LLM: ${llm.used}/${llm.calls} usadas, ${llm.totalTokens} tokens, ` +
+ `~US$ ${llm.estimatedCostUsd.toFixed(4)}${llm.failed > 0 ? `, ${llm.failed} falhas` : ''}`,
+ )
+ console.log(`tempo total: ${(run.elapsedMs / 1000).toFixed(1)}s; falhas: ${failures.length}`)
+ for (const failure of failures) {
+ console.log(` ✗ ${failure.speechAt} [${failure.stage}] ${failure.message}`)
+ }
+}
+
+const speechLine = (report) => {
+ const parts = [
+ report.created ? 'criado' : 'atualizado',
+ report.excerpt ? `trecho a=${report.excerpt.audioId} t=${report.excerpt.tMs}` : 'sem trecho',
+ ]
+ if (report.vodState) parts.push(`VOD ${report.vodState}`)
+ if (report.segmentCount > 0) parts.push(`${report.segmentCount} segmentos`)
+ if (report.presidingOfficer) parts.push(`presidiu ${report.presidingOfficer}`)
+ if (report.classifiedBy) parts.push(`facetas ${report.classifiedBy}`)
+ if (report.failures.length > 0) parts.push(`${report.failures.length} falha(s)`)
+ return parts.join(' · ')
+}
+
+// ---------------------------------------------------------------------------
+// Main
+// ---------------------------------------------------------------------------
+
+async function main() {
+ const options = parseArgs(process.argv.slice(2))
+ if (options.help) {
+ console.log(HELP)
+ process.exit(0)
+ }
+
+ assertLocalDatabase('camara:import', 'O import C153 escreve no banco local; produção é C155.')
+ const payload = await getPayload({ config })
+
+ const from = options.date ?? LEGISLATURE_RANGES[options.legislature][0]
+ const to = options.date ?? LEGISLATURE_RANGES[options.legislature][1]
+ const startedAt = Date.now()
+ console.log(
+ `[camara:import] deputado ${SOLLA_DEPUTY_ID} | ${from}..${to} | speaker "${options.speaker}"`,
+ )
+
+ const speeches = await fetchSpeeches(SOLLA_DEPUTY_ID, from, to)
+ const targets = options.limit === null ? speeches : speeches.slice(0, options.limit)
+ console.log(`[camara:import] ${speeches.length} discursos na API; processando ${targets.length}`)
+ if (targets.length === 0) die(`nenhum discurso em ${from}..${to}.`)
+
+ const run = {
+ runAt: new Date().toISOString(),
+ range: { from, to },
+ options: {
+ legislature: options.date ? null : options.legislature,
+ date: options.date,
+ limit: options.limit,
+ speaker: options.speaker,
+ skipTranscribe: options.skipTranscribe,
+ reclassify: options.reclassify,
+ },
+ totals: {
+ listed: speeches.length,
+ processed: 0,
+ created: 0,
+ updated: 0,
+ withExcerpt: 0,
+ withoutExcerpt: 0,
+ withSegments: 0,
+ failed: 0,
+ },
+ asr: { calls: 0, audioSeconds: 0, elapsedMs: 0, failures: 0 },
+ llm: { calls: 0, used: 0, failed: 0, totalTokens: 0, estimatedCostUsd: 0, sampleError: null },
+ elapsedMs: 0,
+ speeches: [],
+ failures: [],
+ }
+
+ for (const [index, speech] of targets.entries()) {
+ const report = await processSpeech(payload, speech, options, run)
+ run.totals.processed += 1
+ run.speeches.push(report)
+ for (const failure of report.failures) {
+ run.failures.push({ sourceKey: report.sourceKey, speechAt: report.speechAt, ...failure })
+ }
+ console.log(`[${index + 1}/${targets.length}] ${report.speechAt} — ${speechLine(report)}`)
+ }
+
+ run.elapsedMs = Date.now() - startedAt
+ printReport(run)
+
+ const reportPath = join(options.out, 'reports', `import-${run.runAt.replace(/[:.]/g, '-')}.json`)
+ await mkdir(dirname(reportPath), { recursive: true })
+ await writeFile(reportPath, JSON.stringify(run, null, 2))
+ console.log(`\n[camara:import] relatório JSON: ${reportPath}`)
+ process.exit(0)
+}
+
+main().catch((error) => {
+ console.error(error)
+ die(error?.message || String(error))
+})
diff --git a/scripts/lib/camaraFetch.mjs b/scripts/lib/camaraFetch.mjs
new file mode 100644
index 00000000..e7028607
--- /dev/null
+++ b/scripts/lib/camaraFetch.mjs
@@ -0,0 +1,119 @@
+/**
+ * HTTP surface shared by the Câmara speech scripts (C152 pilot + C153 import):
+ * the open-data endpoints, the anti-crawl browser UA, bounded retry, the async
+ * VOD poll and the Deep Infra transcription call. Kept apart from
+ * `camaraSpeeches.mjs` (pure) so the fragile rules stay unit-testable without
+ * network.
+ */
+import {
+ buildVodUrl,
+ CAMARA_USER_AGENT,
+ DEEPINFRA_TRANSCRIBE_URL,
+ DEEPINFRA_WHISPER_MODEL,
+ normalizeTranscription,
+ parseVodStatus,
+} from './camaraSpeeches.mjs'
+
+const camaraHeaders = { 'User-Agent': CAMARA_USER_AGENT, Accept: 'application/json' }
+
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
+
+async function fetchOnce(url, { timeoutMs = 30_000, headers = camaraHeaders } = {}) {
+ const response = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) })
+ if (!response.ok) throw new Error(`HTTP ${response.status} em ${url}`)
+ return response
+}
+
+/** The Câmara API drops connections now and then — bounded retry for GETs. */
+export async function getText(url, { attempts = 3, ...options } = {}) {
+ let lastError
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
+ try {
+ return await (await fetchOnce(url, options)).text()
+ } catch (error) {
+ lastError = error
+ if (attempt < attempts) await sleep(2_000)
+ }
+ }
+ throw lastError
+}
+
+export const getJson = async (url, options) => JSON.parse(await getText(url, options))
+
+export const downloadToBuffer = async (url, { attempts = 3, timeoutMs = 120_000 } = {}) => {
+ let lastError
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
+ try {
+ const response = await fetchOnce(url, {
+ timeoutMs,
+ headers: { 'User-Agent': CAMARA_USER_AGENT },
+ })
+ return Buffer.from(await response.arrayBuffer())
+ } catch (error) {
+ lastError = error
+ if (attempt < attempts) await sleep(3_000)
+ }
+ }
+ throw lastError
+}
+
+export const speechesUrl = (deputyId, from, to) =>
+ `https://dadosabertos.camara.leg.br/api/v2/deputados/${deputyId}/discursos` +
+ `?dataInicio=${from}&dataFim=${to}&itens=100&ordenarPor=dataHoraInicio&ordem=ASC`
+
+export const eventsUrl = (date) =>
+ `https://dadosabertos.camara.leg.br/api/v2/eventos?dataInicio=${date}&dataFim=${date}&itens=100`
+
+export const eventPageUrl = (eventId) => `https://www.camara.leg.br/evento-legislativo/${eventId}`
+
+const VOD_POLL_INTERVAL_MS = 5_000
+const VOD_POLL_MAX_ATTEMPTS = 40
+
+/** Polls the async VOD generation until PRONTO (or gives up). */
+export async function resolveVod(eventId, excerpt) {
+ const url = buildVodUrl(eventId, excerpt.audioId, excerpt.tMs)
+ let lastStatus = null
+ for (let attempt = 1; attempt <= VOD_POLL_MAX_ATTEMPTS; attempt += 1) {
+ const status = parseVodStatus(await getJson(url, { timeoutMs: 60_000 }))
+ lastStatus = status
+ if (status.state === 'PRONTO' || status.state === 'INDISPONIVEL') {
+ return { url, status, attempts: attempt }
+ }
+ await sleep(VOD_POLL_INTERVAL_MS)
+ }
+ return { url, status: lastStatus, attempts: VOD_POLL_MAX_ATTEMPTS }
+}
+
+export const DEEPINFRA_COST_PER_MINUTE_USD = 0.00045
+
+/**
+ * Sends the excerpt MP4 (video/mp4 is accepted; no ffmpeg) to Deep Infra's
+ * OpenAI-compatible endpoint and returns the normalized transcription.
+ * Throws on missing key or provider error — the caller owns the failure policy.
+ */
+export async function transcribeSpeechAudio(buffer) {
+ const apiKey = process.env.DEEPINFRA_API_KEY
+ if (!apiKey) {
+ throw new Error('DEEPINFRA_API_KEY ausente — configure no .env.local ou no env do processo.')
+ }
+ const form = new FormData()
+ form.append('file', new Blob([buffer], { type: 'video/mp4' }), 'trecho.mp4')
+ form.append('model', DEEPINFRA_WHISPER_MODEL)
+ form.append('language', 'pt')
+ form.append('response_format', 'verbose_json')
+ form.append('timestamp_granularities[]', 'segment')
+ const startedAt = Date.now()
+ const response = await fetch(DEEPINFRA_TRANSCRIBE_URL, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${apiKey}` },
+ body: form,
+ signal: AbortSignal.timeout(600_000),
+ })
+ if (!response.ok) {
+ throw new Error(`Deep Infra HTTP ${response.status}: ${(await response.text()).slice(0, 300)}`)
+ }
+ return {
+ transcription: normalizeTranscription(await response.json()),
+ elapsedMs: Date.now() - startedAt,
+ }
+}
diff --git a/scripts/lib/camaraSpeeches.mjs b/scripts/lib/camaraSpeeches.mjs
index a9e92862..c9074785 100644
--- a/scripts/lib/camaraSpeeches.mjs
+++ b/scripts/lib/camaraSpeeches.mjs
@@ -1,7 +1,9 @@
/**
- * Pure derivation for the C152 pilot (`scripts/pilot-camara-speeches.mjs`):
- * matching a Câmara speech to its legislative event and to the speaker's video
- * excerpt, plus normalizing the VOD status and the Deep Infra transcription.
+ * Pure derivation for the Câmara speech scripts — the C152 pilot
+ * (`scripts/pilot-camara-speeches.mjs`) and the C153 catalog import
+ * (`scripts/import-camara-speeches.mjs`): matching a Câmara speech to its
+ * legislative event and to the speaker's video excerpt, the presiding-officer
+ * transitions, plus normalizing the VOD status and the Deep Infra transcription.
*
* No I/O here — the CLI threads fetch/download/transcribe through these
* decisions, so the fragile rules (HTML anchors, time-of-day matching, epoch
@@ -73,6 +75,83 @@ export const speechTimeOfDaySeconds = (iso) => clockToSeconds(String(iso ?? '').
/** `YYYY-MM-DD` prefix of a naive datetime. */
export const speechDate = (iso) => String(iso ?? '').slice(0, 10)
+/**
+ * Stable natural key of an open-data speech. The API has no id, so identity is
+ * the naive datetime plus the speech type and event phase — the record is
+ * updated in place across imports (C153 idempotency).
+ *
+ * @param {{ dataHoraInicio?: string, tipoDiscurso?: string, faseEvento?: { titulo?: string } } | null | undefined} speech
+ * @returns {string}
+ */
+export const speechSourceKey = (speech) =>
+ [
+ String(speech?.dataHoraInicio ?? '').trim(),
+ String(speech?.tipoDiscurso ?? '').trim(),
+ String(speech?.faseEvento?.titulo ?? '').trim(),
+ ].join('|')
+
+/**
+ * Official keywords come as a newline-separated string; keep each keyword raw.
+ *
+ * @param {unknown} raw
+ * @returns {string[]}
+ */
+export const parseOfficialKeywords = (raw) =>
+ String(raw ?? '')
+ .split(/[\r\n]+/)
+ .map((keyword) => keyword.trim())
+ .filter(Boolean)
+
+/**
+ * Legislature of a `YYYY-MM-DD` date per `LEGISLATURE_RANGES`, or null when it
+ * falls outside the supported range.
+ *
+ * @param {string} iso
+ * @returns {string | null}
+ */
+export const legislatureForDate = (iso) => {
+ const date = speechDate(iso)
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null
+ for (const [legislature, [from, to]] of Object.entries(LEGISLATURE_RANGES)) {
+ if (date >= from && date <= to) return legislature
+ }
+ return null
+}
+
+/**
+ * Parses the Câmara duration spellings into seconds: `0h04'03"` (excerpt card)
+ * and `0:04:07` / `4:07` (VOD status). Null when unparseable.
+ *
+ * @param {unknown} value
+ * @returns {number | null}
+ */
+export const parseDurationToSeconds = (value) => {
+ const raw = String(value ?? '').trim()
+ if (raw === '') return null
+
+ const card = /^(\d+)h(\d{1,2})'(\d{1,2})"?$/.exec(raw)
+ if (card) {
+ const minutes = Number(card[2])
+ const seconds = Number(card[3])
+ if (minutes > 59 || seconds > 59) return null
+ return Number(card[1]) * 3600 + minutes * 60 + seconds
+ }
+
+ const parts = raw.split(':')
+ if (!parts.every((part) => /^\d{1,2}$/.test(part))) return null
+ if (parts.length === 3) {
+ const [hours, minutes, seconds] = parts.map(Number)
+ if (minutes > 59 || seconds > 59) return null
+ return hours * 3600 + minutes * 60 + seconds
+ }
+ if (parts.length === 2) {
+ const [minutes, seconds] = parts.map(Number)
+ if (seconds > 59) return null
+ return minutes * 60 + seconds
+ }
+ return null
+}
+
const integerOrNull = (value) => {
const n = Number(value)
return Number.isInteger(n) && n > 0 ? n : null
@@ -200,6 +279,67 @@ export const matchExcerpt = (excerpts, speechIso, speaker) => {
return bySpeaker.reduce((best, current) => (delta(current) < delta(best) ? current : best))
}
+/**
+ * Parses the "Troca da mesa" transition groups of an event page. Each heading
+ * `Troca da mesa Presidente X
+ * por Participante Y
` labels the excerpt cards that follow it; the newest
+ * card of the group (`t` epoch ms) marks the observed moment of the change.
+ * Returns transitions ascending by time; `[]` when the layout changed or the
+ * page recorded none.
+ *
+ * @param {string} html
+ * @returns {Array<{ from: string, to: string, tMs: number }>}
+ */
+export const parsePresidingOfficerTransitions = (html) => {
+ const page = String(html ?? '')
+ const headingPattern =
+ /