feat(C219): paridade de busca e filtros nas gravações do acervo (Closes #1295)
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions

Classificação automática com proveniência (job + CLI recordings:classify), facetas ano/tema/alcance/município/duração na URL canônica, busca por tema no corpus próprio, ordenação e a barra de filtros portada do design aprovado sobre os shells compartilhados. Deep-links existentes byte-idênticos; Câmara intocada.
This commit is contained in:
Francisco Solla 2026-09-24 03:20:51 -03:00
parent 8f3118312a
commit 38e6742673
39 changed files with 21601 additions and 287 deletions

View file

@ -0,0 +1 @@
**C219 — Paridade de busca e filtros entre as fontes do acervo (2026-09-24):** "Gravações enviadas" passa a ter os mesmos gestos de garimpo das "Falas da Câmara" — busca `termo|tema`, filtros de Ano, Tema, Alcance, Município citado e Duração (Pessoa segue exclusiva), chips ativos, contagem, ordenação e vazio honesto — com classificação automática e proveniência auditável sobre a transcrição já existente. Schema: `recording` ganha `year` derivado de `recordedAt` (hook espelho do `Speech`, que agora **limpa** o ano quando a data é limpa), `topics`/`scopes` (mesma taxonomia `lib/speechFacets`), `classifiedBy` (`gazetteer|llm|manual`, **nullable** — nulo = "não classificada", nunca proveniência inventada) e `mentionedMunicipalities` (relationship), com `preserveManualFacets` protegendo curadoria humana de escritas sem `req.user` (job/CLI); migration aditiva `20260924_040210_add_recording_facets` com backfill SQL determinístico do ano. Classificação: `classifyRecordingFacets` (novo, `server-only`) reusa o dono `classifySpeech` (gazetteer offline + refinamento LLM validado na DeepInfra, nunca lança) e resolve os slugs do gazetteer no catálogo de municípios; o seam é o fim do `runRecordingJob` (injetável, fora da transação de save — o LLM não entra no lock) e o acervo existente é coberto pelo CLI idempotente `pnpm recordings:classify` (`--dry-run`, `--limit`, `--all`, guard de DB local + `RECORDING_CLASSIFY_CONFIRM=1` para alvo remoto), com o shape de escrita em dono único (`recordingFacetWriteData`). URL/filtros: `recordingListUrl` ganha `mode|year|topic|scope|municipality|duration|sort` na ordem canônica da Câmara, com `sort=recentes`/`mode=termo` nunca serializados e os deep-links `?source=enviadas&q&person&page` byte-idênticos; `buildRecordingListWhere` ANDa as facetas (duração reusa `durationBucketWhere` exportado do dono da Câmara) e, nos sorts de duração, exige `durationSeconds` existir (senão o NULLS FIRST do Postgres abriria a lista com as gravações sem duração); o loader ganha opções de ano/municípios, injeção da expansão de tema e a degradação honesta (`themeUnavailable`/`themeApplied`); `expandSpeechSearchTheme` ganha o corpus `'recording'` (3º consumidor do mesmo mecanismo, prompt próprio) e o adaptador puro `recordingOmnibox` monta chips/seeds/toggles sobre `lib/campaignListOmnibox`. UI (Impeccable B, porte do design aprovado `docs/plans/acervo-paridade-busca-filtros-ui-design.html`): `RecordingAcervoFilters` novo sobre os shells compartilhados (omnibox com chips dentro do campo, seletor `Termo exato|Por tema`, seis facetas em popovers-chip, absorvendo o antigo `RecordingPersonFilter`), `RecordingSortSelect` ("Mais recentes" default · "Duração (maior)" · "Duração (menor)" com o hint das gravações sem duração), `RecordingThemeFallbackNotice` + `CampaignThemeRetryButton` (movido para `shared/`), chip de tema no card e o vazio honesto do modo tema com "Limpar filtros" + "Trocar para termo exato"; no /simplify o seletor de modo e o rótulo de chip viraram donos compartilhados (`CampaignSearchModeControl`, `selectedFilterTriggerLabel`). Access/Consent intocados (gate `communicationCatalog` fail-closed, sem PII nova, sem rota/collection nova). Verificação: unit (URL canônica/deep-link/ordenação, `where` das facetas+tema+gate de duração, omnibox, corpus novo), int (job grava facetas e degrada sem chave, curadoria `manual` preservada inclusive no escape autenticado, ano derivado/limpo, loader filtra/ordena/degrada o tema), e2e do `campaignSpeechAcervo` estendido (barra de facetas, estreitamento + vazio, ordenação por duração, tema degradando) e `campaignSpeechAcervo` no conjunto curado high-risk do PR (a migration torna a entrega high-risk), `pnpm gate:fast`, `pnpm test:int`, `pnpm build` e os e2e locais. Crítica final do `designer` (trigger c) **certificada em tier primário** (`Design tier: openai/gpt-5.6-sol`); o débito do "X" de limpar do omnibox que quebra linha no mobile com chip longo (pré-existente do shell compartilhado, reproduzido na Câmara) ficou fora e virou a Issue C221, com os demais adiamentos anotados com gatilho no `*-impl.md`.

View file

@ -0,0 +1,209 @@
# Impl: C219 — Paridade de busca e filtros entre as fontes do acervo
Status: aprovado
Atualizado em: 2026-09-24
Issue: #1295
Intenção: docs/plans/acervo-paridade-busca-filtros.md
Appetite restante: herdado (~2–3 dias eng)
## Leitura da intenção
- **Outcome:** "Gravações enviadas" passa a oferecer os mesmos gestos de garimpo da Câmara — busca `termo|tema`, filtros por Ano, Tema, Alcance, Município citado, Duração (e Pessoa, que segue exclusiva), chips ativos, contagem, ordenação e vazio honesto — com classificação automática e proveniência auditável sobre a transcrição já existente, sem tocar no player/download nem no contrato da Câmara.
- **O que NÃO negociar:**
- "Pessoa" continua **exclusiva** das gravações (rótulo derivado, nunca segundo cadastro de pessoa — C200).
- Gate do acervo **fail-closed** (`requireCampaignPageActor({gate:'communicationCatalog'})` + `canReadRecording`/`canReadCommunicationCatalog`); `advisor`/`leader` negados.
- Deep-link atual **byte-compatível**: `?source=enviadas&q&person&page` continua canonicalizando para exatamente o mesmo href; `mode=termo` e o sort default nunca serializados.
- **Sem lista única** entre fontes; **sem redesenho da barra da Câmara** (ela é a referência).
- Classificação **automática e revisável, nunca inventa** — falha degrada para o que existir e é sinalizada; proveniência é rótulo auditável (`gazetteer|llm|manual`), nunca score.
- Buckets de duração idênticos à Câmara ("Até 2 min" · "2 a 5 min" · "Mais de 5 min" · "Sem duração"); **Ano derivado de `recordedAt`**, nunca um segundo campo digitado.
- **O que reavaliar (hipóteses da intenção):**
- "Direção no codebase" aponta `speechClassifier` e `expandSpeechSearchTheme` corretamente, mas o seam real do job é `runRecordingJob` (roda via `after()`, sem request) — a classificação entra ali, **fora** da transação de save, e o backfill das linhas existentes é CLI.
- `classifySpeech` é reusável como está, mas pede `transcript` **cru** (o `searchText` da gravação é normalizado; o input tem de ser a concatenação dos `text` dos segmentos).
- `expandSpeechSearchTheme` já tem o parâmetro `corpus` desde S28 — a extensão é **um prompt novo**, não mecanismo novo.
- Gravações **não têm `keywords`**: a perna textual delas é só `searchText` (a Câmara tem `searchText` OR `keywords`).
- `Recording` **não tem** os campos de faceta/proveniência de `Speech`: schema novo + migration são inevitáveis (a intenção já previa "facets + proveniência").
## Abordagem recomendada
```mermaid
flowchart LR
UP[upload / retry] --> JOB[runRecordingJob]
JOB --> ASR[extrair + transcrever + diarizar]
ASR --> CLS{classifyRecordingFacets<br/>classify injetável}
CLS -- "sem DEEPINFRA_API_KEY / erro" --> GZ[gazetteer: topics/scopes/municípios]
CLS -- ok --> LLM[gazetteer ∪ LLM validado]
GZ --> TX[(transação: segments + recording)]
LLM --> TX
TX --> RDY[ready + facets + classifiedBy]
CLI["scripts/backfill-recording-facets.mjs<br/>pnpm recordings:classify"] -. mesma função .-> CLS
Q["assessoria: ?source=enviadas&q&mode&year&topic&scope&municipality&duration&sort&person&page"] --> URL[recordingListUrl<br/>parse + canônico]
URL --> MODE{mode=tema?}
MODE -- não --> WHERE[buildRecordingListWhere<br/>facetas AND + texto OR + pessoa]
MODE -- sim --> EXP[expandRecordingSearchTheme<br/>corpus 'recording']
EXP -- null --> DEG[literal + aviso discreto]
EXP -- termos --> WHERE
DEG --> WHERE
WHERE --> FIND["payload.find<br/>user · overrideAccess:false"]
FIND --> VM[recordingViewModels<br/>excerto + destaque + chips]
VM --> UI[RecordingAcervoFilters + lista + contagem + ordenação]
```
**Opções consideradas | Recomendação | Rejeitadas (decisões caras):**
| # | Decisão | Opções | Recomendação | Rejeitadas |
| --- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| i | Onde a classificação roda | A) seam no `runRecordingJob` (injetável) + CLI backfill · B) hook `afterChange` da collection · C) on-demand no loader da busca · D) hook por `recordingSegment` | **A** — o job já tem os segmentos em memória no fim da transcrição; o seam injetável é precedente (`catalogContentPiece`); o CLI cobre as linhas já existentes | B (sem transcrição no momento do hook, roda em caminho quente e re-executa por `step`); C (LLM por request, leitura viraria escrita, faceta inexistente não filtra); D (N chamadas por gravação, unidade errada) |
| ii | Schema em `Recording` | A) espelhar `Speech` (`year` derivado + `topics`/`scopes`/`classifiedBy`/`mentionedMunicipalities` + `preserveManualFacets`) com migration `add_recording_facets` · B) facetas por segmento · C) sem schema (derivar na busca) · D) ano sem coluna (range sobre `recordedAt`) | **A** — mesma forma de `where`/opções/canônico da Câmara e curadoria manual preservada; a migration é aditiva | B (unidade errada — corte explícito da intenção); C (não filtra/pagina, recalcula a cada busca e morre o "revisável"); D (dois shapes de filtro para o mesmo conceito; a coluna derivada é o que a Câmara tem e o que a intenção chama de "derivado") |
| iii | Contrato de duração + omnibox | A) reusar donos da Câmara (buckets/labels/tipo de `speechListUrl`, `durationBucketWhere` exportado de `speechListFilters`, parsers de ano/município movidos para `campaignListUrl`) + adaptador novo `recordingOmnibox` · B) extrair módulo compartilhado novo do acervo e migrar a Câmara · C) duplicar no domínio de gravações | **A** — a paridade manda que o bucket tenha UM dono; o adaptador por domínio já é padrão (`municipalityOmnibox`, `municipalityUpdateOmnibox`) | B (churn na Câmara sem volatilidade; extrair quando C216 chegar — 3º consumidor); C (twin: mudança de bucket divergiria as duas fontes) |
| iv | Busca por tema | A) `ThemeSearchCorpus` ganha `'recording'` + prompt próprio + `expandRecordingSearchTheme` · B) reusar o corpus `'speech'` · C) só busca literal | **A** — o mecanismo é um só (S28); o prompt de discursos da Câmara não descreve plenárias enviadas/debates | B (prompt errado degrada a expansão); C (rejeitada no gate: a ponte foi confirmada) |
| v | UI | A) `RecordingAcervoFilters` novo sobre os shells compartilhados (absorve o `RecordingPersonFilter`) · B) generalizar `SpeechAcervoFilters` configurável · C) estender `SpeechAcervoFilters` com `source` | **A** — shells compartilhados são os donos das mecânicas; a Câmara não é tocada | B (API genérica rasa e mexe na Câmara — anti-goal); C (acopla os dois contratos num componente) |
| vi | Ordenação | A) param `sort` com valores pt-BR (`recentes` default omitido, `duracao_maior`, `duracao_menor`); sort de duração só sobre linhas com duração medida · B) par genérico `sort`/`dir` (C117) · C) sort só de UI | **A** — contrato fechado e legível no deep link; default preserva `-createdAt` (zero regressão) | B (o acervo não é tabela; a Câmara não tem esse contrato); C (quebra "paginação preservada no deep link" e back/forward) |
**Recomendação (geral):** uma única passada de classificação no job (com o mesmo `classifySpeech`, injetável e com degradação para gazetteer), um CLI idempotente para o acervo já enviado, e a paridade de filtros construída sobre o contrato de URL, o `where`, o adaptador de omnibox e os shells que a Câmara já usa — mudando **só** a fonte de gravações.
**Rejeitadas (geral):** busca global/lista única (anti-goal); pgvector/embeddings (C192 já rejeitou); facetas por trecho/falante (corte da intenção); qualquer alteração na barra/contrato da Câmara.
### Componentes / mudanças
- **`Recording`** (`src/collections/Recording.ts`): novos campos `year` (number, index, readOnly, derivado), `topics` (select hasMany, index, opções `SPEECH_TOPICS`), `scopes` (select hasMany, index, opções `SPEECH_SCOPES`), `classifiedBy` (select, index, **opcional, sem default**, valores `SPEECH_CLASSIFICATION_SOURCES`), `mentionedMunicipalities` (relationship hasMany → `municipality`, index). Hooks: `deriveRecordingYear` (beforeValidate, espelho de `deriveSpeechYear` — `recordedAt` string `slice(0,4)`, com fallback `originalDoc`) e `preserveManualFacets` (beforeChange, espelho de `Speech.ts:43-57`, `FACET_FIELDS = topics|scopes|classifiedBy|mentionedMunicipalities`; escrita sem `req.user` — job/CLI — nunca sobrescreve `manual`). `admin.defaultColumns` ganha `topics` e `classifiedBy` (superfície de revisão da classificação no admin). `admin.description` registra que a classificação é automática sobre o início da transcrição.
- **Migration** (`pnpm migrate:create add_recording_facets` → `src/migrations/20260924_120000_add_recording_facets.ts` + `.json` + `index.ts`): tabelas de join `recording_topics`/`recording_scopes` (enums `enum_recording_topics`/`enum_recording_scopes`/`enum_recording_classified_by`), tabela `recording_rels` (`mentionedMunicipalities`), colunas `year`/`classified_by`. **Hand-edit** (precedente `20260913_150000_backfill_speech_search_text_trgm_index`): backfill determinístico de `year` a partir de `recorded_at` (`WHERE recorded_at IS NOT NULL`). Nada destrutivo; `recording_search_text_trgm_idx` já existe (não mexer).
- **`src/payload-types.ts`**: regenerado (`pnpm generate:types`) — `Recording` ganha os campos.
- **`classifyRecordingFacets`** (`src/utilities/recordings/recordingClassification.ts`, novo, `import 'server-only'`): `({ payload, transcript, classify = classifySpeech })` → `{ topics, scopes, classifiedBy, mentionedMunicipalities } | null`. Reusa `SpeechFacetInput`/`SpeechClassificationResult`, `classifySpeech` (DeepInfra + validação `lib/speechFacets`) e resolve `municipalities[].slug → municipality.id` numa query (bypass justificado — geografia read-only — precedente `speechImport.ts:176-196`). Nunca lança: transcrição vazia ou throw defensivo devolvem `null` e o job segue sem facets.
- **`runRecordingJob`** (`src/utilities/recordings/recordingJob.ts`): ganha `classify` injetável (default `classifySpeech`), chamado **depois** de diarizar e **antes** de `markStep('saving')` (o LLM não entra na transação); transcript = `keyedSegments.map(s => s.text).join(' ')`. O update transacional final (`status:'ready'`, `searchText`, `speakerLabels`) passa a incluir `year` (quando houver `recordedAt`), `topics`, `scopes`, `classifiedBy`, `mentionedMunicipalities`.
- **`scripts/backfill-recording-facets.mjs`** (novo) + `package.json` `"recordings:classify"`: reusa `classifyRecordingFacets`; default = linhas `ready` com `classifiedBy` ausente (`--all` reclassifica não-`manual`), `--dry-run`, `--limit N`; `assertLocalDatabase` + `RECORDING_CLASSIFY_CONFIRM=1` para alvo remoto/produção (precedente `CAMARA_IMPORT_CONFIRM`); relatório (classificadas/puladas-manual/falhas/tokens/custo) e idempotência por `classifiedBy`.
- **URL** (`src/utilities/recordings/recordingListUrl.ts`): `RecordingListState` ganha `mode?` (reusa `SpeechSearchMode`), `years?`, `topics?`, `scopes?`, `municipalities?`, `durations?` (reusa `SpeechDurationBucket`) e `sort?`; `recordingListParamNames` ganha `mode|year|topic|scope|municipality|duration|sort`. Ordem canônica: `source, q, mode, year[], topic[], scope[], municipality[], duration[], sort, person[], page` — os parâmetros novos não perturbam o href dos deep links atuais. Serialização: `mode=tema` só com `q`; `sort` só quando ≠ `recentes`; `page` só > 1. Novos: `RECORDING_SORT_OPTIONS`/`recordingSortLabels` ("Mais recentes" · "Duração (maior)" · "Duração (menor)"), `recordingSortKeys(state)` (`-createdAt` default / `-durationSeconds` / `durationSeconds`) e `buildRecordingFiltersKey(state)` (espelho de `buildSpeechFiltersKey`, usado como `key` do remount da barra).
- **`campaignListUrl.ts`**: recebe `parseYearValues`/`parseMunicipalityValues` (movidos de `speechListUrl.ts`, que passa a importá-los) — um dono para a validação estrutural de ano/município nas duas fontes.
- **`speechListFilters.ts`**: exporta `durationBucketWhere` (sem mudança de comportamento) para o `where` das gravações.
- **Filtros** (`src/utilities/recordings/recordingListFilters.ts`): `buildRecordingListWhere(state, themeTerms = [])` — facetas AND (`year in`, `topics in`, `scopes in`, `mentionedMunicipalities in`, OR de `durationBucketWhere`, OR de `speakerNames contains`), perna textual OR de `{ searchText: { like: normalizeForSearch(term) } }` para `[q, ...themeTerms]` (sem `keywords`), e `{ durationSeconds: { exists: true } }` quando `sort` é de duração (o Postgres ordena NULLS FIRST em DESC; sem isso "Duração (maior)" abriria com as linhas sem duração).
- **Loader** (`src/utilities/recordings/recordingPageData.ts`): `RecordingFilterOptions` ganha `years` e `municipalities` (via `loadMunicipalityLabelsByIds`, precedente `loadSpeechFilterOptions`); `loadRecordingsPageData(payload, user, params, expandTheme = expandRecordingSearchTheme)` com o gate de tema (`mode=tema && q && canReadCommunicationCatalog(role)` antes de qualquer chamada externa) e retorno `themeUnavailable`/`themeApplied`; `sort` aplicado; `loadMatchedSegments` passa a receber os termos (`[q, ...themeTerms]`) e casa `or` de `searchText like`; `select` da lista ganha `topics` (chip do card).
- **View models** (`src/utilities/recordings/recordingViewModels.ts`): `toRecordingListItemViewModel` ganha `themeTerms`; o excerto prefere a evidência de tema (espelho C192: primeiro termo que casa em `searchText`, segmento via `pickMatchingSegment`, `buildHighlightedExcerpt(..., { phrase: true })`) e cai no literal quando não há; `watchHref` mantém `?t=&q=` com o **`q` literal** (byte-compat; espelho de `buildWatchHref`); `RecordingListItemViewModel` ganha `topics: { value, label }[]` (labels de `speechTopicLabels`).
- **Omnibox** (`src/utilities/recordings/recordingOmnibox.ts`, novo, puro/client-safe): chips (`q`, `year`, `topic`, `scope`, `municipality`, `duration`, `person`), seeds (Tema/Alcance/Duração visíveis com query vazia; Ano/Município citado/Pessoa pesquisáveis por palavra-chave), `applyRecordingOmniboxSuggestion`/`removeRecordingOmniboxChip`/`applyRecordingSearchMode`/`clearRecordingOmnibox` (preservando `source: 'enviadas'` no clear) sobre `lib/campaignListOmnibox.ts`; `person` reusa `toggleRecordingPerson`/`removeRecordingPerson` (dedupe case-insensitive já dono) e o prefixo `person:` lê o valor por `slice` (o rótulo pode conter `:`).
- **Tema** (`src/utilities/ai/expandSpeechSearchTheme.ts`): `ThemeSearchCorpus` ganha `'recording'`; novo prompt de corpus (plenárias/debates/material próprio enviados pela equipe), `themeExpansionRules('gravação')`; wrapper `expandRecordingSearchTheme` (mesmo timeout/degradação/`normalizeSpeechThemeTerms`).
- **UI (Impeccable B — port de `docs/plans/acervo-paridade-busca-filtros-ui-design.html`):**
- `src/components/campaign/recording/RecordingAcervoFilters.tsx` (novo, client) — **cenas 01/02**: form `campaignListOmniboxFormClassName` com label "Buscar nas gravações enviadas", `CampaignListOmnibox` (chips dentro do campo, como na Câmara — B184/B127), seletor segmentado `role="group"` `grid grid-cols-2 rounded-lg bg-muted p-1` + `aria-pressed` + `min-h-11 md:min-h-9` (com o estado degradado `themeUnavailable`), e as seis facetas como `CampaignHeaderFilterPopover triggerVariant="chip"` (`Ano`, `Tema`, `Alcance`, `Município citado`, `Duração`, `Pessoa`) com `selectedTriggerLabel` dobrando a seleção. A linha "Ativos:" do artefato é realizada pelos chips do próprio omnibox (shell compartilhado), sem segunda fileira.
- `src/components/campaign/recording/RecordingSortSelect.tsx` (novo, client) — **cena 01**: label "Ordenar por" + `select` nativo (`rounded-md border border-input`) via `useCampaignListFilterNavigation`; em sort de duração, hint "Gravações sem duração aparecem apenas em Mais recentes." A contagem fica no `CampaignListFooter` (padrão de todas as listas — sem duplicar a linha).
- `src/components/campaign/recording/RecordingThemeFallbackNotice.tsx` (novo) — **cena 04/C192**: `Alert variant="pending"` com a copy de gravações + `CampaignThemeRetryButton`; move `SpeechThemeRetryButton` → `src/components/campaign/shared/CampaignThemeRetryButton.tsx` (componente genérico, zero copy própria) e atualiza o import da página da Câmara.
- `src/components/campaign/recording/RecordingResultCard.tsx` — **cena 01 (card)**: chip de tema (`bg-muted`) ao lado do chip de pessoa; destaque do termo continua via `SpeechHighlightParts`.
- `src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx` — branch `enviadas`: `key={buildRecordingFiltersKey(data.state)}`, barra nova, aviso de tema, `RecordingSortSelect`, `RecordingStatusRefresher` (intocado), lista/vazio/footer; vazio honesto da **cena 03** (título "Nenhuma gravação encontrada para este tema", descrição do artefato, "Limpar filtros" + "Trocar para termo exato") e `hasFilters` por `buildRecordingFiltersKey`. O `RecordingsSearchForm` inline e o `RecordingPersonFilter` somem (absorvidos).
- **Cena 04 (sem permissão)** permanece o redirect fail-closed do gate — a cena é ilustrativa, não vira superfície nova. **Cena 05 (carregando)** é o `CampaignListPendingBoundary`/`CampaignListResults` existente (dima o resultado, não o controle).
- Remover `src/components/campaign/recording/RecordingPersonFilter.tsx`.
- **Manifest e2e** (`scripts/lib/e2e-affected-manifest.mjs`): `src/collections/Recording.ts` entra no entry da vertical C154/C199 (`campaignSpeechAcervo`) — hoje um diff só nesse arquivo cairia no smoke da home; e `campaignSpeechAcervo` entra em `E2E_CURATED_SPECS` (precedente C167/S19: a migration torna todo PR da entrega high-risk), com o pin de congelamento atualizado em `tests/unit/e2eAffectedManifest.unit.spec.ts`.
- **Access / Consent:** **nada muda**. Sem chave `Consent` nova (acervo interno; sem PII nova), sem collection nova, sem rota nova. O classificador fala com a DeepInfra (mesmo provedor/chave que já transcreve a gravação) e o tema com a DeepSeek (só o texto digitado, como na Câmara).
### Dados → forma (se aplicável)
N/A justificado. O item não apresenta métrica, agregado nem score: resultados e facetas são recorte editorial. A proveniência é rótulo auditável (`Gazetteer`/`LLM`/`Manual`) no admin — nunca número na UI; "contagem de resultados" é paginação, não métrica de vaidade.
## Fases verificáveis
1. **Tracer / schema + server** (~1 dia) — `Recording` com os campos/hooks + migration `add_recording_facets` (com backfill de `year`) + `pnpm generate:types` + `recordingClassification` + seam `classify` no `runRecordingJob` + `scripts/backfill-recording-facets.mjs`.
- Prova: `tests/int/recording.int.spec.ts` — job end-to-end grava `topics`/`scopes`/`classifiedBy`/`mentionedMunicipalities`/`year` (classificador injetado determinístico + caminho gazetteer sem `DEEPINFRA_API_KEY`); curadoria `manual` não é sobrescrita por escrita sem `user`; retry reclassifica; `pnpm migrate` local verde.
2. **URL / filtros / loader** (~0,5 dia) — params e estado novos, `buildRecordingFiltersKey`, `recordingSortKeys`, `where` com facetas + tema + gate de sort, loader com opções (`years`/`municipalities`) e injeção do tema, corpus `'recording'`.
- Prova: `tests/unit/recordingListUrl.unit.spec.ts` (deep-link byte-compat, canônico, enums exaustivos, `sort` default omitido), `tests/unit/recordingListFilters.unit.spec.ts` (facetas AND, tema OR, duração, `exists` do sort), `tests/unit/recordingOmnibox.unit.spec.ts` (novo), `tests/unit/expandSpeechSearchTheme.unit.spec.ts` (corpus novo), `tests/int/recording.int.spec.ts` (loader filtra/ordena/tema e opções).
3. **UI port** (~0,5–1 dia) — `RecordingAcervoFilters`, `RecordingSortSelect`, `RecordingThemeFallbackNotice`, `CampaignThemeRetryButton` movido, chip de tema no card, página; remoção do `RecordingPersonFilter`.
- Prova: `tests/e2e/campaignSpeechAcervo.e2e.spec.ts` (describe de gravações estendido: barra com chips, filtro que estreita + vazio honesto, modo tema degradando, sort, contagem; o teste de "Pessoa" passa a exercitar a barra nova); `pnpm dev` manual com a cena 01 do artefato.
4. **Testes/gates** (~0,5 dia) — `pnpm gate:fast`; depois a cascata cheia (`pnpm lint`, `pnpm format:check`, `tsc --noEmit`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm build`); `pnpm push`; entrada em `docs/changelog/<data>-c219.md`; PR ready com auto-merge (o check `checks` é o gate; o e2e curado roda `campaignSpeechAcervo`).
## Rabbit holes / Não escopo (engenharia)
- **Generalizar a barra da Câmara** (`SpeechAcervoFilters` configurável / componente `AcervoFilters` genérico) — anti-goal; shells compartilhados já são os donos.
- **Extrair o adaptador de omnibox/helpers de estado para um genérico** — 2 call sites; extrair quando C216 ("Falas na internet") chegar (3º).
- **Módulo compartilhado novo do contrato do acervo** (mover buckets/parsers para um terceiro) — churn na Câmara sem volatilidade; deferido para C216.
- **pgvector/embeddings/índice vetorial** — já rejeitado no C192.
- **Classificar por segmento/falante** ou reclassificar a taxonomia (18 temas fixos) — cortes de produto.
- **Segundo cadastro de pessoa / inferir falantes** — "Pessoa" segue rótulo (C200).
- **Mudar o default de ordenação para `-recordedAt`** — regressão do comportamento atual (`-createdAt`); fora do aceite.
- **Busca global entre fontes / lista única** — anti-goal.
- **UI de curadoria de facetas além do form do admin** — o form da collection é a superfície de revisão; qualquer coisa além disso é outro item.
- **Cache/rerank da expansão de tema** — deferido no C192 (sem cache no v1).
**Adiado com gatilho (triage do /simplify desta entrega):**
- **Extração dos helpers de faceta/estado restantes** (`facetRows`/`clearFacet`/`runAction` e o aviso de fallback por domínio) — gatilho: C216 (3º consumidor do acervo), junto do adaptador de omnibox (S2/S3).
- **N+1 do backfill** (`scripts/backfill-recording-facets.mjs` consulta os segmentos de cada gravação em queries separadas) — gatilho: acervo na casa das centenas de gravações ou `--all` perceptivelmente lento (S4).
- **Ternários aninhados do ramo de gravações** em `acervo/page.tsx` — gatilho: extração do 3º consumidor na C216, quando o ramo for compartilhado (S5).
- **"X" de limpar do omnibox quebra linha no mobile com chip longo** — débito do shell compartilhado, pré-existente e reproduzido na Câmara; registrado como Issue própria (C221, `docs/plans/omnibox-limpar-mobile.md`) — fora deste item (S1).
## Riscos e mitigação
- **Deep-link/redirect:** os nomes novos entram em `recordingListParamNames` (antes seriam "unsupported" e redirecionariam); o serializer precisa reproduzir `?source=enviadas&q=…&person=…&page=…` **byte a byte** (ordem canônica acima; `page` só >1). Mitigação: pins no `recordingListUrl.unit.spec.ts` (incluindo `mode=tema` sem `q` canonicalizando fora e `sort` default omitido).
- **`select` allowlist:** esquecer `topics` no `recordingListSelect` some com o chip do card silenciosamente. Mitigação: o campo é exigido pelo input do view model (typecheck) + int test do loader.
- **Manifest e2e:** `src/collections/Recording.ts` está fora do manifest (um diff só nele cairia no smoke da home); `src/payload-types.ts` é `HIGH_RISK_EXACT` → o PR roda o e2e **curado**, que hoje não inclui `campaignSpeechAcervo`. Mitigação: adicionar o arquivo ao entry da vertical e `campaignSpeechAcervo` ao `E2E_CURATED_SPECS` + pin (precedente C167/S19); a suíte full continua no `verify` do deploy.
- **Ausência de `DEEPINFRA_API_KEY`:** `classifySpeech` degrada para gazetteer (topics/scopes/municípios do léxico) e nunca lança; o CLI reporta `llm: false` e o `mergeFacetClassification` garante que `classifiedBy: 'llm'` só sai com resposta validada.
- **Gravações já existentes:** `classifiedBy` **nulo = "não classificada"** (honesto; nunca inventar `gazetteer` por default); ficam visíveis e sem faceta até o `pnpm recordings:classify` rodar; o CLI é idempotente e não toca `manual`.
- **Transcrição longa:** o prompt do LLM trunca em 12k chars (dono: `classifySpeech`) — classificação pelo início da transcrição, registrada no `admin.description`; a busca (trgm) não é afetada. O custo é 1 chamada por gravação `ready` (mesmo provedor/chave do ASR), nunca por request.
- **Sort × duração nula:** sem o gate `durationSeconds exists`, o Postgres colocaria as linhas sem duração no topo de "Duração (maior)"; o gate + hint no controle mantêm a ordem honesta. Int test cobre.
- **Matriz de papéis:** intocada — gate da página e `canReadRecording` idênticos; a expansão de tema só roda depois de `canReadCommunicationCatalog(role)`; os bypasses do job/CLI são internos e justificados.
- **Migration em produção:** aditiva (colunas nulas + joins), aplicada pelo `pnpm build` do deploy; o backfill de `year` é SQL determinístico; `down` gerado cobre rollback.
- **Ciclos/convenções:** `recordings → speech` não pode criar ciclo (`speech` não importa `recordings`) — `pnpm check:cycles`; `recordingClassification.ts` declara `import 'server-only'` (regra do `codebaseConventions.unit.spec.ts`).
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (ano, duração, tema, alcance, município citado, pessoa exclusiva, contagem, chips, ordenação, vazio honesto, tema degradando honestamente).
- [ ] Invariantes AGENTS/engineering-standards: `user` + `overrideAccess: false` em toda leitura; bypass do job/CLI justificado; sem `Consent`/PII nova; `leader`/`advisor` fail-closed; sem top-level novo em `utilities/`; copy pt-BR / identificadores em inglês; sem `openai/*` (OPS123).
- [ ] Migrations: só arquivos novos (nunca editar antigas); `pnpm migrate` local; `pnpm generate:types`; prod aplica no build.
- [ ] Testes de domínio previstos: unit (URL/where/omnibox/corpus), int (job escreve facetas, curadoria manual preservada, loader filtra/ordena/tema), e2e (barra/filtros/ordenação/vazio) — onde os write paths e o contrato de URL mudam.
- [ ] Manifest/e2e atualizado (`src/collections/Recording.ts` na vertical; `campaignSpeechAcervo` no curado + pin).
- [ ] Gates: `tsc --noEmit`, `pnpm lint` (0 warnings), `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm build`; `pnpm push`; entrada em `docs/changelog/`.
## Decisões de engenharia
**(i) Onde a classificação roda**
- **Opções:** A) seam no `runRecordingJob` (injetável) + CLI backfill | B) hook `afterChange` da collection | C) on-demand no loader da busca | D) hook por `recordingSegment`.
- **Recomendação:** **A** — o job já tem `keyedSegments` em memória ao fim da transcrição, roda fora do request e faz o save transacional; o `classify` injetável (default `classifySpeech`) é o mesmo padrão de `catalogContentPiece` e mantém os testes offline; o CLI cobre as linhas anteriores à entrega com a mesma função.
- **Rejeitadas:** B porque o hook dispara em cada transição de `status`/`step` (sem transcrição no momento útil), prenderia um LLM de 60s dentro do ciclo de escrita e re-executaria a cada passo; C porque pagaria o LLM em toda página, transformaria leitura em escrita e não filtraria pelo que ainda não foi classificado; D porque faria N chamadas por gravação e classificaria por trecho — unidade rejeitada pela intenção.
**(ii) Schema em `Recording`**
- **Opções:** A) espelhar `Speech` (`year` derivado, `topics`/`scopes`/`classifiedBy`/`mentionedMunicipalities`, `preserveManualFacets`) com a migration `add_recording_facets` | B) facetas por segmento | C) sem schema, derivar na busca | D) ano por range sobre `recordedAt` (sem coluna).
- **Recomendação:** **A** — mantém `where`, opções de faceta e canônico idênticos aos da Câmara (a paridade é o objetivo), a curadoria manual preservada (hook espelho) e o "revisável" no admin. `year` derivado no `beforeValidate` com backfill SQL na migration; `classifiedBy` opcional (ver vii).
- **Rejeitadas:** B porque a unidade da Câmara é a fala e o corte da intenção é a gravação; C porque não filtra com `where`/paginação, recalcula a cada busca e perde a revisão; D porque criaria um segundo shape de filtro para o mesmo conceito (e teria de repetir a regra de fuso em cada branch), enquanto a coluna derivada é literalmente o "derivado da data já informada" da intenção.
**(iii) Reuso do contrato de duração e do omnibox**
- **Opções:** A) reusar os donos da Câmara (buckets/labels/tipo em `speechListUrl`, `durationBucketWhere` exportado de `speechListFilters`, parsers de ano/município em `campaignListUrl`) + `recordingOmnibox` novo | B) extrair módulo compartilhado novo do acervo e migrar a Câmara | C) duplicar buckets/where no domínio de gravações.
- **Recomendação:** **A** — um dono para o bucket (mudança na Câmara reflete nas gravações, que é a paridade pedida) e o adaptador por domínio segue o padrão já existente (`municipalityOmnibox`, `municipalityUpdateOmnibox`); o que é genérico (`lib/campaignListOmnibox`) já é compartilhado.
- **Rejeitadas:** B porque mover o contrato de referência para um terceiro neutro gera churn na Câmara sem volatilidade real — gatilho de revisitação: a chegada da C216 (3º consumidor); C porque duplicar o bucket/where faz as duas fontes divergirem em silêncio, exatamente o que o item existe para impedir.
**(iv) Corpus da busca por tema**
- **Opções:** A) `ThemeSearchCorpus` ganha `'recording'` + prompt próprio + wrapper | B) reusar o corpus `'speech'` | C) só busca literal.
- **Recomendação:** **A** — `expandSearchTheme(theme, corpus)` é o mecanismo único desde S28; o que muda é o prompt (plenárias/debates/material próprio enviados pela equipe). Degradação e `normalizeSpeechThemeTerms` são herdados.
- **Rejeitadas:** B porque o prompt descreve discursos da Câmara e degradaria a expansão para gravações; C porque a pergunta de produto foi confirmada no gate (estender a ponte é a paridade pedida).
**(v) UI: componente novo × generalização**
- **Opções:** A) `RecordingAcervoFilters` novo sobre os shells compartilhados | B) generalizar `SpeechAcervoFilters` configurável | C) estender `SpeechAcervoFilters` com `source`.
- **Recomendação:** **A** — os donos das mecânicas são os shells (`CampaignListOmnibox`, `CampaignHeaderFilterPopover`, `useCampaignListFilterNavigation`, `CampaignListPending*`, `CampaignListEmptyState/Footer`); o componente de domínio só monta os facets da fonte, como o da Câmara.
- **Rejeitadas:** B porque uma API genérica configurável é pass-through raso e mexeria na Câmara (anti-goal); C porque acoplaria os dois contratos de URL num único componente e twinaria a Câmara dentro dele.
**(vi) Contrato de ordenação**
- **Opções:** A) `sort` com valores pt-BR (`recentes` default omitido, `duracao_maior`, `duracao_menor`) + exclusão de linhas sem duração nos sorts de duração | B) `sort`/`dir` genérico (C117) | C) sort só de UI.
- **Recomendação:** **A** — contrato fechado (o canonicalizador redireciona valor desconhecido), default preserva `-createdAt` (zero regressão) e o gate `durationSeconds exists` evita NULLS FIRST no Postgres; o controle avisa que gravações sem duração aparecem só em "Mais recentes".
- **Rejeitadas:** B porque o acervo não é tabela e a Câmara não tem esse contrato (a paridade pede o gesto, não o par genérico); C porque quebraria "paginação preservada no deep link" e o back/forward.
**(vii) Proveniência nula nas gravações existentes**
- **Opções:** A) `classifiedBy` opcional, sem default — nulo = "não classificada" | B) `required` com `defaultValue: 'gazetteer'` (espelho exato de `Speech`).
- **Recomendação:** **A** — o default de B afirmaria que o gazetteer classificou linhas que nunca passaram por ele (viola o "nunca inventa"); nulo é o estado honesto até o `pnpm recordings:classify`, e o seletor do CLI fica trivial (`classifiedBy` ausente). Os **valores** persistidos são os mesmos da Câmara (`gazetteer|llm|manual`) — a paridade é do vocabulário, não do `required`.
- **Rejeitadas:** B porque a migration carimbaria proveniência falsa nas linhas antigas e o backfill não teria como distinguir "classificada" de "nunca classificada".
**Decisões baratas (com gatilho de revisitação):**
- **Excerto no modo tema:** evidência de tema vence o excerto (espelho C192) e o `watchHref` mantém o `q` literal no `?t=&q=` (byte-compat; espelho `buildWatchHref`). Revisitar se a Câmara mudar a regra.
- **`CampaignThemeRetryButton` movido para `shared/`** (componente genérico, 1 import atualizado na Câmara) — o aviso em si fica por domínio (copy).
- **Helpers de estado do omnibox (`withPageReset`/`toggleValue`/`removeValue`)** duplicados no adaptador de gravações; extrair na C216 (3º).
- **Contagem no `CampaignListFooter`** (não na linha do sort, como no artefato) — padrão de todas as listas; sem duplicar a linha.
- **Chip de tema no card** usa `bg-muted` (mesma linguagem do chip de pessoa) — sem selo "Tema" (o artefato não pede).
**Self-score (0–5, gate ≥4): 5/5.**
1. **Decisões caras com rejeitadas:** 5/5 — as seis decisões caras (seam, schema, reuso de contrato, corpus, UI, sort) e a de proveniência nula têm opções/recomendação/rejeitadas; as baratas ficam com gatilho.
2. **Appetite:** 5/5 — sem infra/provedor novo, sem collection/rota nova, reusando classificador, ponte de tema, shells e a transação do job; cabe nos ~2–3 dias.
3. **Rabbit holes nomeados:** 5/5 — barra genérica, módulo compartilhado precoce, embeddings, classificação por trecho, segundo cadastro, mudança de default de sort, lista única.
4. **Depth check:** 5/5 — reusa `classifySpeech`/`lib/speechFacets`, `expandSearchTheme`, `campaignListOmnibox`, shells de lista, `loadMunicipalityLabelsByIds`, `SpeechHighlightParts`, `withPayloadTransaction`; nada de camada cerimonial.
5. **Intenção preservada:** 5/5 — Pessoa exclusiva, gate fail-closed, deep-link byte-compatível, sem lista única, sem redesenho da Câmara, classificação auditável e degradando honestamente.

View file

@ -0,0 +1,36 @@
# C221 — "Limpar" do omnibox não pode quebrar linha no mobile
Status: rascunho
Atualizado em: 2026-09-24
Issue: (a registrar)
Priority: P3
Impeccable: B — ajuste de encaixe do shell de filtros existente (não é fluxo novo)
Appetite: ~2h eng (fill-in)
## Intenção
Débito capturado na crítica de design da C219 (`docs/plans/acervo-paridade-busca-filtros.md`): no mobile, quando o chip de busca do omnibox compartilhado é longo, o botão global "Limpar" (o "X" circular dentro do campo, `md:hidden`) quebra para uma linha própria e aparece órfão abaixo do chip, criando uma faixa vazia entre o campo e o seletor de modo. Reproduzido igualmente na fonte Câmara e na fonte "Gravações enviadas" — é comportamento pré-existente do `CampaignListOmnibox`, não regressão da C219.
Evidência: `/tmp/opencode/c219-shots/06-mobile-empty.png` (gravações) e `/tmp/opencode/c219-shots/11-mobile-camara-long-chip.png` (Câmara) na sessão da C219; o código é o cluster `flex-wrap` do campo em `src/components/campaign/shared/CampaignListOmnibox.tsx` (chips + input + botão de limpar na mesma linha flexível).
## Objetivo e aceite
- Com um chip de busca longo no mobile, o "X" de limpar permanece visualmente vinculado ao campo/chips (não quebra para uma linha órfã), em todas as listas que usam o shell.
- Comportamento, acessibilidade (`aria-label="Limpar"`, anel de foco, região `sr-only`) e desktop intocados.
- Sem mudança de contrato de URL/filtros.
## Direção no codebase (hipótese)
- `src/components/campaign/shared/CampaignListOmnibox.tsx` — cluster do campo (`flex min-h-10 w-full flex-wrap items-center gap-1.5 ...`) com chips, input e o botão de limpar `md:hidden`.
- Consumidores para regressão visual: `SpeechAcervoFilters`, `RecordingAcervoFilters`, listas de municípios/apoiadores (qualquer `<form>` com `campaignListOmniboxFormClassName`).
- Estratégias possíveis (decidir no impl): manter o botão fora do fluxo dos chips (posição absoluta no campo), agrupar chips+input num contêiner que quebra e ancorar o X na linha do input, ou limitar a largura do chip no mobile. A mais simples que preserve o layout desktop.
## Fora de escopo
- Redesenho da barra da Câmara ou dos shells de lista.
- Mudanças nos adaptadores de omnibox por domínio.
## Aceite de engenharia
- [ ] Regressão visual mobile (390) em pelo menos uma lista de cada vertical que usa o shell.
- [ ] Nenhuma mudança de markup/ARIA que quebre os e2e existentes das listas.

View file

@ -90,6 +90,7 @@
"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",
"recordings:classify": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/backfill-recording-facets.mjs",
"typecheck": "tsc --noEmit",
"typecheck:tsc6": "tsc6 --noEmit",
"prepare": "husky",

View file

@ -0,0 +1,162 @@
/**
* C219 — facet backfill of the uploaded recordings ("Gravações enviadas").
*
* The transcription job classifies every recording it finishes; this CLI
* classifies the rows that already existed (and can reclassify on demand) with
* the SAME function the job uses (`classifyRecordingFacets` → `classifySpeech`:
* offline gazetteer + one validated LLM refinement on Deep Infra). It never
* invents provenance: a row is only written when the classifier produced a
* result, a `manual` curation is never touched, and the default selection is
* exactly the rows with no `classifiedBy` (never classified).
*
* Idempotent: re-running skips what it already classified (default mode);
* `--all` reclassifies everything except `manual` rows.
*
* The local-DB guard refuses a non-local DATABASE_URL, and any write run
* targeting production (or a remote/override DB) additionally requires
* `RECORDING_CLASSIFY_CONFIRM=1` (same family as `CAMARA_IMPORT_CONFIRM`).
*
* Usage:
* pnpm recordings:classify --dry-run
* pnpm recordings:classify --limit 5
* pnpm recordings:classify
* pnpm recordings:classify --all
* RECORDING_CLASSIFY_CONFIRM=1 pnpm recordings:classify --all
*/
import { getPayload } from 'payload'
import { assertLocalDatabase } from './assert-local-database.mjs'
import { dieWithLabel, isTruthyEnv, loadCliEnv, requiresWriteConfirm } from './lib/cli.mjs'
loadCliEnv()
const die = dieWithLabel('recordings:classify')
const WRITE_CONFIRM_FLAG = 'RECORDING_CLASSIFY_CONFIRM'
const parseArgs = (argv) => {
const options = { dryRun: false, all: false, limit: null }
for (const arg of argv) {
if (arg === '--dry-run') options.dryRun = true
else if (arg === '--all') options.all = true
else if (arg.startsWith('--limit=')) {
const limit = Number(arg.slice('--limit='.length))
if (!Number.isSafeInteger(limit) || limit <= 0) die(`--limit inválido: ${arg}`)
options.limit = limit
} else die(`argumento desconhecido: ${arg}`)
}
return options
}
const options = parseArgs(process.argv.slice(2))
assertLocalDatabase(
'recordings:classify',
'A classificação escreve em `recording`; aponte DATABASE_URL para o banco local.',
)
if (requiresWriteConfirm() && !isTruthyEnv(process.env[WRITE_CONFIRM_FLAG])) {
die(
`Este run escreve em um banco remoto/de produção. Se é isso mesmo, re-execute com:\n ${WRITE_CONFIRM_FLAG}=1 pnpm recordings:classify …`,
)
}
const config = (await import('../src/payload.config.ts')).default
const { classifyRecordingFacets, recordingFacetWriteData } =
await import('../src/utilities/recordings/recordingClassification.ts')
const payload = await getPayload({ config })
const where = options.all
? { and: [{ status: { equals: 'ready' } }, { classifiedBy: { not_equals: 'manual' } }] }
: { and: [{ status: { equals: 'ready' } }, { classifiedBy: { exists: false } }] }
const targets = await payload.find({
collection: 'recording',
where,
depth: 0,
limit: options.limit ?? 0,
pagination: false,
sort: 'createdAt',
select: { title: true, classifiedBy: true },
// Intentional admin bypass: the backfill CLI is a trusted actor with no session.
overrideAccess: true,
})
const report = {
targets: targets.docs.length,
classified: 0,
skippedEmptyTranscript: 0,
failed: 0,
llmUsed: 0,
totalTokens: 0,
estimatedCostUsd: 0,
}
console.log(
`[recordings:classify] ${report.targets} gravação(ões) alvo${options.dryRun ? ' (dry-run)' : ''}.`,
)
for (const recording of targets.docs) {
const segments = await payload.find({
collection: 'recordingSegment',
where: { recording: { equals: recording.id } },
depth: 0,
limit: 0,
pagination: false,
sort: 'order',
select: { text: true },
// Intentional admin bypass: reconstructing the transcript the job owns.
overrideAccess: true,
})
const transcript = segments.docs.map((segment) => segment.text).join(' ')
const classification = await classifyRecordingFacets({ payload, transcript })
if (!classification) {
report.skippedEmptyTranscript += 1
console.warn(`[recordings:classify] #${recording.id} sem transcrição utilizável — pulada.`)
continue
}
if (classification.llm.used) report.llmUsed += 1
if (classification.llm.totalTokens !== null) report.totalTokens += classification.llm.totalTokens
if (classification.llm.estimatedCostUsd !== null) {
report.estimatedCostUsd += classification.llm.estimatedCostUsd
}
if (options.dryRun) {
report.classified += 1
console.log(
`[recordings:classify] (dry-run) #${recording.id} "${recording.title}" → ` +
`${classification.topics.join(', ') || '—'} · ${classification.classifiedBy}`,
)
continue
}
try {
await payload.update({
collection: 'recording',
id: recording.id,
data: recordingFacetWriteData(classification),
// Intentional admin bypass: the backfill CLI is a trusted actor with no session.
overrideAccess: true,
})
report.classified += 1
console.log(
`[recordings:classify] #${recording.id} "${recording.title}" → ` +
`${classification.topics.join(', ') || '—'} · ${classification.classifiedBy}`,
)
} catch (error) {
report.failed += 1
console.warn(
`[recordings:classify] #${recording.id} falhou ao gravar: ` +
`${error instanceof Error ? error.message : String(error)}`,
)
}
}
console.log(
`\n[recordings:classify] alvos=${report.targets} classificadas=${report.classified} ` +
`sem-transcrição=${report.skippedEmptyTranscript} falhas=${report.failed} ` +
`llm=${report.llmUsed} tokens=${report.totalTokens} custo≈$${report.estimatedCostUsd.toFixed(4)}`,
)
process.exit(report.failed > 0 ? 1 : 0)

View file

@ -47,6 +47,10 @@ export const E2E_CURATED_SPECS = [
// and the vote share sheet are new contracts over the C211 collection
// (curated only).
'frontendConteudos',
// C219 — deliberate: the recordings acervo gained its own search/facet
// contract and the migration makes every PR of this delivery high-risk
// (curated only).
'campaignSpeechAcervo',
]
/**
@ -443,6 +447,10 @@ export const E2E_AFFECTED_MANIFEST = [
'src/components/campaign/reels',
// C199 — the uploaded recordings source and its private media owner.
'src/components/campaign/recording',
// C219 — the recording collection owns the facet/provenance hooks the
// recordings list filters on (the PR is high-risk anyway; the entry keeps
// the mapping honest for the curated/local paths).
'src/collections/Recording.ts',
// C211 — the Central de Conteúdos surfaces.
'src/components/campaign/content',
'src/utilities/speech',

View file

@ -5,9 +5,11 @@ import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { AcervoSourceToggle } from '@/components/campaign/recording/AcervoSourceToggle'
import { RecordingPersonFilter } from '@/components/campaign/recording/RecordingPersonFilter'
import { RecordingAcervoFilters } from '@/components/campaign/recording/RecordingAcervoFilters'
import { RecordingResultList } from '@/components/campaign/recording/RecordingResultList'
import { RecordingSortSelect } from '@/components/campaign/recording/RecordingSortSelect'
import { RecordingStatusRefresher } from '@/components/campaign/recording/RecordingStatusRefresher'
import { RecordingThemeFallbackNotice } from '@/components/campaign/recording/RecordingThemeFallbackNotice'
import { RecordingUploadDialog } from '@/components/campaign/recording/RecordingUploadDialog'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
@ -15,21 +17,22 @@ import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
import { CampaignThemeRetryButton } from '@/components/campaign/shared/CampaignThemeRetryButton'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { SpeechAcervoFilters } from '@/components/campaign/speech/SpeechAcervoFilters'
import { SpeechRefineSearchButton } from '@/components/campaign/speech/SpeechRefineSearchButton'
import { SpeechResultList } from '@/components/campaign/speech/SpeechResultList'
import { SpeechThemeFallbackNotice } from '@/components/campaign/speech/SpeechThemeFallbackNotice'
import { SpeechThemeRetryButton } from '@/components/campaign/speech/SpeechThemeRetryButton'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { CAMPAIGN_COMMUNICATION_ACERVO, CAMPAIGN_COMMUNICATION_CORTES } from '@/lib/campaignPaths'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
buildAcervoSourceHref,
buildRecordingFiltersKey,
buildRecordingListHref,
parseAcervoSource,
recordingHasActiveFilters,
} from '@/utilities/recordings/recordingListUrl'
import { loadRecordingsPageData } from '@/utilities/recordings/recordingPageData'
import { buildSpeechFiltersKey, buildSpeechListHref } from '@/utilities/speech/speechListUrl'
@ -78,80 +81,93 @@ const AcervoHeader = ({ source }: { source: 'camara' | 'enviadas' }) => (
</div>
)
const RecordingsSearchForm = ({
query,
people,
}: {
query?: string
people?: readonly string[]
}) => (
<form method="get" action={CAMPAIGN_COMMUNICATION_ACERVO} className="flex flex-col gap-1.5">
<input type="hidden" name="source" value="enviadas" />
{/* The "Pessoa" facet survives a new textual search. */}
{(people ?? []).map((person) => (
<input key={person} type="hidden" name="person" value={person} />
))}
{/* The shared input owns the accessible label; this is its visible twin. */}
<p className="text-sm font-medium">Buscar nas gravações enviadas</p>
<CampaignSearchInput
id="recording-search"
name="q"
label="Buscar nas gravações enviadas"
defaultValue={query}
placeholder="Busque por palavra ou trecho da transcrição…"
/>
<button type="submit" className="sr-only">
Buscar
</button>
</form>
)
const RecordingsSource = ({
data,
}: {
data: Awaited<ReturnType<typeof loadRecordingsPageData>>
}) => {
const hasSearch = Boolean(data.state.q)
const hasPeople = (data.state.people?.length ?? 0) > 0
const hasFilters = hasSearch || hasPeople
const hasFilters = recordingHasActiveFilters(data.state)
const themeMode = data.state.mode === 'tema'
const themeActive = themeMode && data.themeApplied && !data.themeUnavailable
return (
<CampaignListPendingBoundary>
<RecordingsSearchForm query={data.state.q} people={data.state.people} />
<RecordingAcervoFilters
key={buildRecordingFiltersKey(data.state)}
state={data.state}
filterOptions={data.filterOptions}
themeUnavailable={data.themeUnavailable}
/>
<RecordingPersonFilter state={data.state} options={data.filterOptions.people} />
{data.themeUnavailable ? <RecordingThemeFallbackNotice /> : null}
<RecordingStatusRefresher
recordings={data.rows.map((row) => ({ id: row.id, status: row.status }))}
/>
<CampaignListResults>
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="text-sm font-semibold">
{data.themeUnavailable
? 'Resultados por termo exato'
: themeActive
? 'Resultados por tema'
: 'Gravações encontradas'}
</h2>
{themeMode ? (
<p className="mt-0.5 text-xs text-muted-foreground">
{data.themeUnavailable
? 'Comportamento atual do acervo.'
: themeActive
? 'Confira o indício em cada gravação antes de abrir.'
: 'Nenhum termo relacionado foi acrescentado; mostramos a busca literal.'}
</p>
) : null}
</div>
<div className="flex flex-wrap items-end gap-2">
{data.themeUnavailable ? <CampaignThemeRetryButton /> : null}
<RecordingSortSelect state={data.state} />
</div>
</div>
{data.rows.length > 0 ? (
<RecordingResultList rows={data.rows} />
) : (
<CampaignListEmptyState
className="border-solid"
className={themeActive ? 'border-solid' : undefined}
icon={hasFilters ? SearchIcon : VideoIcon}
title={
hasSearch
? `Nenhuma gravação encontrada para "${data.state.q}"`
: hasPeople
? 'Nenhuma gravação encontrada com esse filtro'
: 'Nenhuma gravação enviada ainda'
themeActive
? 'Nenhuma gravação encontrada para este tema'
: data.state.q
? `Nenhuma gravação encontrada para "${data.state.q}"`
: hasFilters
? 'Nenhuma gravação encontrada com esses filtros'
: 'Nenhuma gravação enviada ainda'
}
description={
hasSearch
? 'Tente outro termo ou limpe a busca para ver todas as gravações enviadas.'
: hasPeople
themeActive
? 'Não encontramos uma gravação que corresponda ao sentido desta busca com os filtros atuais. Não vamos preencher a lista com resultados pouco relacionados.'
: hasFilters
? 'Tente outro termo, remova filtros ou limpe a busca.'
: 'Envie um arquivo de vídeo para acompanhar a transcrição e pesquisar o conteúdo no acervo.'
}
>
{hasFilters ? (
{themeActive ? (
<div className="flex flex-wrap items-center justify-center gap-2">
<Button asChild className="min-h-11">
<Link href={buildAcervoSourceHref('enviadas')}>Limpar filtros</Link>
</Button>
<Button asChild variant="outline" className="min-h-11">
<Link href={buildRecordingListHref({ ...data.state, mode: undefined }, 1)}>
Trocar para termo exato
</Link>
</Button>
</div>
) : hasFilters ? (
<Button asChild variant="outline" className="min-h-11">
<Link href={buildAcervoSourceHref('enviadas')}>
{hasPeople ? 'Limpar filtros' : 'Limpar busca'}
</Link>
<Link href={buildAcervoSourceHref('enviadas')}>Limpar filtros</Link>
</Button>
) : (
<RecordingUploadDialog triggerLabel="Enviar gravação" />
@ -233,7 +249,7 @@ export default async function SpeechAcervoPage({ searchParams }: SpeechAcervoPag
: 'Nenhum termo relacionado foi acrescentado; mostramos a busca literal.'}
</p>
</div>
{data.themeUnavailable ? <SpeechThemeRetryButton /> : null}
{data.themeUnavailable ? <CampaignThemeRetryButton /> : null}
</div>
) : null}

View file

@ -1,4 +1,5 @@
import type {
CollectionBeforeChangeHook,
CollectionBeforeDeleteHook,
CollectionBeforeValidateHook,
CollectionConfig,
@ -14,6 +15,7 @@ import {
recordingStepLabels,
} from '@/lib/recording'
import { speakerNamesFromLabels } from '@/lib/recordingDiarization'
import { SPEECH_CLASSIFICATION_SOURCES, SPEECH_SCOPES, SPEECH_TOPICS } from '@/lib/speechFacets'
import {
canDeleteRecording,
canReadRecording,
@ -67,6 +69,44 @@ const deriveSpeakerNames: CollectionBeforeValidateHook = ({ data, originalDoc })
return data
}
/**
* C219 — the "Ano" facet is derived from the recording date the team already
* informed (`recordedAt`), never a second hand-typed field. A recording without
* a date honestly has no year (it stays out of the year filter).
*/
const deriveRecordingYear: CollectionBeforeValidateHook = ({ data, originalDoc }) => {
if (!data) return data
const recordedAt =
typeof data.recordedAt === 'string' ? data.recordedAt : (originalDoc?.recordedAt ?? null)
const year = Number(String(recordedAt ?? '').slice(0, 4))
// Clearing the date clears the derived year too — a stale year would keep the
// recording in an "Ano" facet the team just removed.
data.year = Number.isInteger(year) && year > 1900 ? year : null
return data
}
const FACET_FIELDS = ['topics', 'scopes', 'classifiedBy', 'mentionedMunicipalities'] as const
/**
* C219 — a manual facet correction is curation: unauthenticated writes (the
* transcription job and the backfill CLI) never overwrite it. Authenticated
* admin edits stay free to change both the facets and the provenance.
*/
const preserveManualFacets: CollectionBeforeChangeHook = ({
data,
operation,
originalDoc,
req,
}) => {
if (!data || operation !== 'update' || originalDoc?.classifiedBy !== 'manual') return data
if (req.user) return data
const next = data as Record<string, unknown>
for (const field of FACET_FIELDS) {
if (field in next) next[field] = originalDoc[field]
}
return data
}
export const Recording: CollectionConfig = {
slug: 'recording',
labels: {
@ -76,9 +116,9 @@ export const Recording: CollectionConfig = {
admin: {
group: 'Comunicação',
useAsTitle: 'title',
defaultColumns: ['title', 'recordedAt', 'status', 'durationSeconds', 'createdAt'],
defaultColumns: ['title', 'recordedAt', 'status', 'topics', 'classifiedBy'],
description:
'Gravações próprias da equipe no acervo. O arquivo é privado; a transcrição é somente leitura.',
'Gravações próprias da equipe no acervo. O arquivo é privado; a transcrição é somente leitura. Tema, alcance e municípios citados são classificados automaticamente pelo início da transcrição e podem ser corrigidos aqui (a correção vira proveniência "Manual").',
},
access: {
create: canUploadRecording,
@ -87,8 +127,8 @@ export const Recording: CollectionConfig = {
delete: canDeleteRecording,
},
hooks: {
beforeValidate: [deriveSpeakerNames],
beforeChange: [stampCampaignCreatedBy],
beforeValidate: [deriveSpeakerNames, deriveRecordingYear],
beforeChange: [preserveManualFacets, stampCampaignCreatedBy],
beforeDelete: [deleteRecordingSegments],
},
fields: [
@ -108,6 +148,16 @@ export const Recording: CollectionConfig = {
description: 'Data em que a gravação foi feita (opcional).',
},
},
{
name: 'year',
type: 'number',
label: 'Ano',
index: true,
admin: {
readOnly: true,
description: 'Derivado da data da gravação.',
},
},
{
name: 'status',
type: 'select',
@ -155,6 +205,47 @@ export const Recording: CollectionConfig = {
description: 'Concatenação normalizada dos segmentos (sem acentos, minúsculas).',
},
},
{
name: 'topics',
type: 'select',
label: 'Temas',
hasMany: true,
index: true,
options: SPEECH_TOPICS.map(({ value, label }) => ({ value, label })),
},
{
name: 'scopes',
type: 'select',
label: 'Alcance',
hasMany: true,
index: true,
options: SPEECH_SCOPES.map(({ value, label }) => ({ value, label })),
},
{
name: 'classifiedBy',
type: 'select',
label: 'Proveniência da classificação',
// Optional on purpose: a recording from before C219 was never classified,
// and stamping it `gazetteer` would claim a pass that never ran. Null is
// the honest "não classificada" until the backfill CLI runs.
index: true,
options: SPEECH_CLASSIFICATION_SOURCES.map((value) => ({
value,
label: value === 'gazetteer' ? 'Gazetteer' : value === 'llm' ? 'LLM' : 'Manual',
})),
admin: {
description:
'Automática e auditável: Gazetteer (léxico), LLM (refinamento validado) ou Manual (correção humana).',
},
},
{
name: 'mentionedMunicipalities',
type: 'relationship',
relationTo: 'municipality',
label: 'Municípios citados',
hasMany: true,
index: true,
},
{
name: 'speakerLabels',
type: 'array',

View file

@ -0,0 +1,296 @@
'use client'
import { useMemo, useOptimistic, useState } from 'react'
import {
CampaignHeaderFilterPopover,
type CampaignHeaderFilterRow,
} from '@/components/campaign/shared/CampaignHeaderFilterPopover'
import {
CampaignListOmnibox,
campaignListOmniboxFormClassName,
} from '@/components/campaign/shared/CampaignListOmnibox'
import { CampaignSearchModeControl } from '@/components/campaign/shared/CampaignSearchModeControl'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { selectedFilterTriggerLabel } from '@/lib/campaignListOmnibox'
import { SPEECH_SCOPES, SPEECH_TOPICS } from '@/lib/speechFacets'
import {
buildRecordingListHref,
toggleRecordingPerson,
type RecordingFilterOptions,
type RecordingListState,
} from '@/utilities/recordings/recordingListUrl'
import {
applyRecordingOmniboxSuggestion,
applyRecordingSearchMode,
buildRecordingOmniboxChips,
buildRecordingOmniboxSuggestionSeeds,
clearRecordingOmnibox,
filterRecordingOmniboxSuggestions,
removeRecordingOmniboxChip,
type RecordingOmniboxAction,
} from '@/utilities/recordings/recordingOmnibox'
import {
SPEECH_DURATION_BUCKETS,
speechDurationLabels,
speechScopeLabels,
speechTopicLabels,
type SpeechSearchMode,
} from '@/utilities/speech/speechListUrl'
const topicOptions = SPEECH_TOPICS.map(({ value, label }) => ({ value, label }))
const scopeOptions = SPEECH_SCOPES.map(({ value, label }) => ({ value, label }))
const durationOptions = SPEECH_DURATION_BUCKETS.map(({ value, label }) => ({ value, label }))
type FacetKey = 'years' | 'topics' | 'scopes' | 'municipalities' | 'durations' | 'people'
type RecordingFacet = {
id: string
label: string
/** Prefix of the omnibox suggestion id (`${prefix}:${value}`). */
prefix: string
stateKey: FacetKey
options: readonly { value: string; label: string }[]
selected: readonly string[]
labels?: Readonly<Record<string, string>>
}
/**
* C219 — the filter bar of "Gravações enviadas": same gestures as the Câmara
* acervo (search by term or theme, year, topic, scope, cited municipality,
* duration) plus the exclusive "Pessoa" facet. The mechanics (omnibox, chips,
* facet popovers, pending boundary) are the shared shells; this component only
* assembles the recordings facets. Ported from the approved hi-fi design
* (`docs/plans/acervo-paridade-busca-filtros-ui-design.html`, scenes 01/02).
*/
export const RecordingAcervoFilters = ({
state,
filterOptions,
themeUnavailable = false,
}: {
state: RecordingListState
filterOptions: RecordingFilterOptions
/** C219 — the theme expansion is down; the selector reflects the fallback. */
themeUnavailable?: boolean
}) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
toHref: (next) => buildRecordingListHref(next, 1),
})
const [query, setQuery] = useState('')
const [viewState, setOptimisticState] = useOptimistic(state)
const activeMode: SpeechSearchMode =
viewState.mode === 'tema' && !themeUnavailable ? 'tema' : 'termo'
const municipalityLabelsById = useMemo(() => {
const map = new Map<number, string>()
for (const option of filterOptions.municipalities) {
const id = Number(option.value)
if (Number.isSafeInteger(id) && id > 0) map.set(id, option.label)
}
return map
}, [filterOptions.municipalities])
const chips = useMemo(
() => buildRecordingOmniboxChips({ state: viewState, municipalityLabelsById }),
[viewState, municipalityLabelsById],
)
const suggestionSeeds = useMemo(
() =>
buildRecordingOmniboxSuggestionSeeds({
years: filterOptions.years,
municipalityOptions: filterOptions.municipalities,
personOptions: filterOptions.people,
}),
[filterOptions],
)
const suggestions = useMemo(
() => filterRecordingOmniboxSuggestions(suggestionSeeds, query),
[suggestionSeeds, query],
)
const runAction = (action: RecordingOmniboxAction) => {
if (action.kind === 'clear') {
setQuery('')
navigate(action.state)
return
}
navigate(action.state)
}
const runMode = (mode: SpeechSearchMode) => {
const next = applyRecordingSearchMode({ state: viewState, mode }).state
setOptimisticState(next)
navigate(next)
}
const facetRows = (
prefix: string,
options: readonly { value: string; label: string }[],
selected: readonly string[],
): CampaignHeaderFilterRow[] =>
options.map((option) => {
const next = applyRecordingOmniboxSuggestion({
state: viewState,
suggestionId: `${prefix}:${option.value}`,
}).state
return {
value: option.value,
label: option.label,
href: buildRecordingListHref(next, 1),
selected: selected.includes(option.value),
checkbox: true,
onChoose: () => setOptimisticState(next),
}
})
const personRows = (): CampaignHeaderFilterRow[] =>
filterOptions.people.map((person) => {
const next = toggleRecordingPerson(viewState, person)
return {
value: person,
label: person,
href: buildRecordingListHref(next, 1),
selected: (viewState.people ?? []).some(
(selected) => selected.toLocaleLowerCase('pt-BR') === person.toLocaleLowerCase('pt-BR'),
),
checkbox: true,
onChoose: () => setOptimisticState(next),
}
})
const clearFacet = (facet: FacetKey) => {
const next: RecordingListState = { ...viewState, page: 1, [facet]: undefined }
return { href: buildRecordingListHref(next, 1), onChoose: () => setOptimisticState(next) }
}
const years = (viewState.years ?? []).map(String)
const topics = viewState.topics ?? []
const scopes = viewState.scopes ?? []
const municipalities = (viewState.municipalities ?? []).map(String)
const durations = viewState.durations ?? []
const people = viewState.people ?? []
const facets: RecordingFacet[] = [
{
id: 'recording-filter-year',
label: 'Ano',
prefix: 'year',
stateKey: 'years',
options: filterOptions.years.map((year) => ({ value: String(year), label: String(year) })),
selected: years,
},
{
id: 'recording-filter-topic',
label: 'Tema',
prefix: 'topic',
stateKey: 'topics',
options: topicOptions,
selected: topics,
labels: speechTopicLabels,
},
{
id: 'recording-filter-scope',
label: 'Alcance',
prefix: 'scope',
stateKey: 'scopes',
options: scopeOptions,
selected: scopes,
labels: speechScopeLabels,
},
{
id: 'recording-filter-municipality',
label: 'Município citado',
prefix: 'municipality',
stateKey: 'municipalities',
options: filterOptions.municipalities,
selected: municipalities,
labels: Object.fromEntries(municipalityLabelsById),
},
{
id: 'recording-filter-duration',
label: 'Duração',
prefix: 'duration',
stateKey: 'durations',
options: durationOptions,
selected: durations,
labels: speechDurationLabels,
},
]
return (
<form
role="search"
className={campaignListOmniboxFormClassName}
onSubmit={(event) => {
event.preventDefault()
}}
>
<CampaignListOmnibox
id="recording-omnibox"
label="Buscar nas gravações enviadas"
placeholder="Busque por palavra, trecho ou tema da transcrição…"
chips={chips}
suggestions={suggestions}
query={query}
onQueryChange={setQuery}
isPending={isPending}
onSelectSuggestion={(suggestionId) => {
runAction(applyRecordingOmniboxSuggestion({ state: viewState, suggestionId }))
}}
onCommitQuery={(text) => {
runAction(
applyRecordingOmniboxSuggestion({ state: viewState, suggestionId: `q:${text}` }),
)
}}
onRemoveChip={(chipId) => {
runAction(removeRecordingOmniboxChip({ state: viewState, chipId }))
}}
onClearAll={() => {
runAction(clearRecordingOmnibox(viewState))
}}
/>
<CampaignSearchModeControl
activeMode={activeMode}
themeUnavailable={themeUnavailable}
onSelect={runMode}
relatedHint="Encontra gravações relacionadas pelo sentido."
/>
<div
role="group"
aria-label="Filtros do acervo de gravações"
className="mt-2 flex gap-2 overflow-x-auto pb-1 md:flex-wrap md:overflow-visible"
>
{facets.map((facet) => (
<CampaignHeaderFilterPopover
key={facet.id}
id={facet.id}
label={facet.label}
triggerVariant="chip"
triggerLabel={selectedFilterTriggerLabel(facet.label, facet.selected, facet.labels)}
active={facet.selected.length > 0}
closeOnChoose={false}
optionRows={facetRows(facet.prefix, facet.options, facet.selected)}
clear={facet.selected.length ? clearFacet(facet.stateKey) : undefined}
/>
))}
<CampaignHeaderFilterPopover
id="recording-filter-person"
label="Pessoa"
triggerVariant="chip"
triggerLabel={selectedFilterTriggerLabel('Pessoa', people)}
active={people.length > 0}
closeOnChoose={false}
emptyLabel="Nenhuma pessoa identificada nas gravações."
optionRows={personRows()}
clear={people.length ? clearFacet('people') : undefined}
/>
</div>
</form>
)
}

View file

@ -1,87 +0,0 @@
'use client'
import { FunnelIcon, XIcon } from 'lucide-react'
import { useOptimistic } from 'react'
import {
CampaignHeaderFilterPopover,
type CampaignHeaderFilterRow,
} from '@/components/campaign/shared/CampaignHeaderFilterPopover'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { foldSpeakerName } from '@/lib/recordingDiarization'
import {
buildRecordingListHref,
removeRecordingPerson,
toggleRecordingPerson,
type RecordingListState,
} from '@/utilities/recordings/recordingListUrl'
const PERSON_FILTER_ID = 'recording-filter-person'
/**
* C200 — the "Pessoa" facet of the uploaded recordings (design scene 3): one
* removable chip per selected label plus the multi-select popover backed by the
* labels actually present. Options come from the server (`speakerNames`), never
* from a free-form directory — the label is text, not a `Contact`.
*/
export const RecordingPersonFilter = ({
state,
options,
}: {
state: RecordingListState
options: readonly string[]
}) => {
const { navigate } = useCampaignListFilterNavigation({
state,
toHref: (next) => buildRecordingListHref(next, 1),
})
const [viewState, setOptimisticState] = useOptimistic(state)
const selected = viewState.people ?? []
const optionRows: CampaignHeaderFilterRow[] = options.map((option) => {
const next = toggleRecordingPerson(viewState, option)
return {
value: option,
label: option,
href: buildRecordingListHref(next, 1),
selected: selected.some((person) => foldSpeakerName(person) === foldSpeakerName(option)),
checkbox: true,
onChoose: () => setOptimisticState(next),
}
})
const clear = {
href: buildRecordingListHref({ ...viewState, page: 1, people: undefined }, 1),
onChoose: () => setOptimisticState({ ...viewState, page: 1, people: undefined }),
}
return (
<div role="group" aria-label="Filtrar por pessoa" className="flex flex-wrap items-center gap-2">
<CampaignHeaderFilterPopover
id={PERSON_FILTER_ID}
label="Pessoa"
triggerVariant="chip"
triggerLabel={selected.length > 1 ? `Pessoa: ${selected.length}` : 'Pessoa'}
active={selected.length > 0}
closeOnChoose={false}
emptyLabel="Nenhuma pessoa identificada nas gravações."
optionRows={optionRows}
clear={selected.length ? clear : undefined}
/>
{selected.map((person) => (
<button
key={person}
type="button"
className="inline-flex h-8 min-h-8 items-center gap-1.5 rounded-lg border border-input bg-white px-3 text-xs font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => navigate(removeRecordingPerson(viewState, person))}
aria-label={`Remover filtro de ${person}`}
>
<FunnelIcon className="size-3.5 shrink-0" aria-hidden="true" />
<span className="max-w-56 truncate">Pessoa: {person}</span>
<XIcon className="size-3.5 shrink-0" aria-hidden="true" />
</button>
))}
</div>
)
}

View file

@ -66,8 +66,16 @@ export const RecordingResultCard = ({ recording }: { recording: RecordingListIte
</p>
)}
{recording.matchedPersons.length > 0 ? (
{recording.topics.length > 0 || recording.matchedPersons.length > 0 ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
{recording.topics.map((topic) => (
<span
key={topic.value}
className="inline-flex min-h-7 items-center rounded-md bg-muted px-2 py-1 text-xs font-semibold"
>
{topic.label}
</span>
))}
{recording.matchedPersons.map((person) => (
<span
key={person}
@ -77,7 +85,9 @@ export const RecordingResultCard = ({ recording }: { recording: RecordingListIte
{person}
</span>
))}
<span className="text-muted-foreground">aparece nesta gravação</span>
{recording.matchedPersons.length > 0 ? (
<span className="text-muted-foreground">aparece nesta gravação</span>
) : null}
</div>
) : null}
</div>

View file

@ -0,0 +1,51 @@
'use client'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import {
buildRecordingListHref,
RECORDING_SORT_OPTIONS,
recordingSortIsDuration,
type RecordingListState,
type RecordingSortKey,
} from '@/utilities/recordings/recordingListUrl'
/**
* C219 — the list ordering control (design scene 01): "Mais recentes" (the
* default, never serialized) plus the two duration orders. A duration order
* only lists rows with a measured duration, so the hint says it out loud.
*/
export const RecordingSortSelect = ({ state }: { state: RecordingListState }) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
toHref: (next) => buildRecordingListHref(next, 1),
})
const value: RecordingSortKey = state.sort ?? 'recentes'
return (
<div className="flex flex-col items-end gap-1">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
Ordenar por
<select
value={value}
disabled={isPending}
className="min-h-10 rounded-md border border-input bg-white px-3 py-2 text-sm text-foreground disabled:opacity-70"
onChange={(event) => {
const next = event.target.value as RecordingSortKey
navigate({ ...state, page: 1, sort: next === 'recentes' ? undefined : next })
}}
>
{RECORDING_SORT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
{recordingSortIsDuration(state) ? (
<p className="text-xs text-muted-foreground">
Gravações sem duração aparecem apenas em Mais recentes.
</p>
) : null}
</div>
)
}

View file

@ -0,0 +1,26 @@
import { TriangleAlertIcon } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
/**
* C219 — the discreet fallback notice when `mode=tema` was asked on the
* recordings source but the theme expansion was unavailable. The list below
* shows the literal results; the notice keeps the user's request honest
* instead of hiding the downgrade (same contract as the Câmara acervo, C192).
*/
export const RecordingThemeFallbackNotice = () => (
<Alert
variant="pending"
data-testid="recording-theme-fallback"
className="gap-3 border-estimate-pending/80 bg-estimate-pending/55 px-4 py-3"
>
<TriangleAlertIcon aria-hidden="true" />
<AlertTitle className="text-sm font-medium">
A busca por tema está indisponível agora.
</AlertTitle>
<AlertDescription className="text-xs leading-5">
Você pediu “Por tema”. Para não interromper o trabalho, mostramos abaixo os resultados por
termo exato.
</AlertDescription>
</Alert>
)

View file

@ -0,0 +1,83 @@
'use client'
import { SparklesIcon, TriangleAlertIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { SpeechSearchMode } from '@/utilities/speech/speechListUrl'
/** C192/C219 — segmented search-mode button (mobile full width, desktop inline). */
const modeButtonClass = (active: boolean, tone: 'termo' | 'tema'): string =>
cn(
'min-h-11 rounded-md px-3 text-sm md:min-h-9',
active
? cn(
'bg-white font-semibold shadow-sm ring-1 ring-border',
tone === 'tema' ? 'text-primary' : 'text-foreground',
)
: 'font-medium text-muted-foreground',
)
/**
* C219 — the "Termo exato | Por tema" selector shared by the two acervo
* sources. The mechanics (mode semantics, degradation) belong to the URL
* contract and the loader; this component only renders the choice and the
* honest state of the theme bridge.
*/
export const CampaignSearchModeControl = ({
activeMode,
themeUnavailable = false,
onSelect,
relatedHint,
}: {
activeMode: SpeechSearchMode
/** The theme expansion is down; the selector reflects the fallback. */
themeUnavailable?: boolean
onSelect: (mode: SpeechSearchMode) => void
/** Desktop hint of the corpus ("Encontra falas relacionadas pelo sentido."). */
relatedHint: string
}) => (
<div className="mt-3 flex flex-col gap-3 border-t border-border pt-3 md:flex-row md:flex-wrap md:items-center md:justify-between">
<div className="flex w-full items-center gap-2 md:w-auto">
<span className="hidden text-xs font-medium text-muted-foreground md:inline">Buscar por</span>
<div
role="group"
aria-label="Modo de busca"
className="grid w-full grid-cols-2 rounded-lg bg-muted p-1 md:inline-flex md:w-auto"
>
<button
type="button"
aria-pressed={activeMode === 'termo'}
className={modeButtonClass(activeMode === 'termo', 'termo')}
onClick={() => onSelect('termo')}
>
Termo exato
</button>
<button
type="button"
aria-pressed={activeMode === 'tema'}
aria-disabled={themeUnavailable || undefined}
className={modeButtonClass(activeMode === 'tema', 'tema')}
onClick={() => {
if (themeUnavailable) return
onSelect('tema')
}}
>
<span className="inline-flex items-center gap-1.5">
{themeUnavailable ? (
<TriangleAlertIcon
className="size-3.5 text-estimate-pending-foreground"
aria-hidden="true"
/>
) : (
<SparklesIcon className="size-3.5" aria-hidden="true" />
)}
Por tema
</span>
</button>
</div>
</div>
<p className="hidden text-xs text-muted-foreground md:block">
{themeUnavailable ? 'Indisponível agora; mostramos o termo exato.' : relatedHint}
</p>
</div>
)

View file

@ -5,8 +5,11 @@ import { useTransition } from 'react'
import { Button } from '@/components/ui/button'
/** C192 — re-runs the same `mode=tema` request after a degraded fallback. */
export const SpeechThemeRetryButton = () => {
/**
* C192/C219 — re-runs the same `mode=tema` request after a degraded fallback.
* Shared by both acervo sources; the notice copy stays per domain.
*/
export const CampaignThemeRetryButton = () => {
const router = useRouter()
const [isPending, startTransition] = useTransition()

View file

@ -1,6 +1,5 @@
'use client'
import { SparklesIcon, TriangleAlertIcon } from 'lucide-react'
import { useMemo, useOptimistic, useState } from 'react'
import {
@ -11,9 +10,10 @@ import {
CampaignListOmnibox,
campaignListOmniboxFormClassName,
} from '@/components/campaign/shared/CampaignListOmnibox'
import { CampaignSearchModeControl } from '@/components/campaign/shared/CampaignSearchModeControl'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { selectedFilterTriggerLabel } from '@/lib/campaignListOmnibox'
import { SPEECH_SCOPES, SPEECH_TOPICS } from '@/lib/speechFacets'
import { cn } from '@/lib/utils'
import {
buildSpeechListHref,
SPEECH_DURATION_BUCKETS,
@ -53,28 +53,6 @@ type SpeechFacet = {
labels?: Readonly<Record<string, string>>
}
const selectedTriggerLabel = (
base: string,
values: readonly string[],
labels?: Readonly<Record<string, string>>,
): string => {
if (values.length === 0) return base
if (values.length === 1) return `${base}: ${labels?.[values[0]!] ?? values[0]}`
return `${base}: ${values.length}`
}
/** C192 — segmented search-mode button (mobile full width, desktop inline). */
const modeButtonClass = (active: boolean, tone: 'termo' | 'tema'): string =>
cn(
'min-h-11 rounded-md px-3 text-sm md:min-h-9',
active
? cn(
'bg-white font-semibold shadow-sm ring-1 ring-border',
tone === 'tema' ? 'text-primary' : 'text-foreground',
)
: 'font-medium text-muted-foreground',
)
export const SpeechAcervoFilters = ({
state,
filterOptions,
@ -257,54 +235,12 @@ export const SpeechAcervoFilters = ({
}}
/>
<div className="mt-3 flex flex-col gap-3 border-t border-border pt-3 md:flex-row md:flex-wrap md:items-center md:justify-between">
<div className="flex w-full items-center gap-2 md:w-auto">
<span className="hidden text-xs font-medium text-muted-foreground md:inline">
Buscar por
</span>
<div
role="group"
aria-label="Modo de busca"
className="grid w-full grid-cols-2 rounded-lg bg-muted p-1 md:inline-flex md:w-auto"
>
<button
type="button"
aria-pressed={activeMode === 'termo'}
className={modeButtonClass(activeMode === 'termo', 'termo')}
onClick={() => runMode('termo')}
>
Termo exato
</button>
<button
type="button"
aria-pressed={activeMode === 'tema'}
aria-disabled={themeUnavailable || undefined}
className={modeButtonClass(activeMode === 'tema', 'tema')}
onClick={() => {
if (themeUnavailable) return
runMode('tema')
}}
>
<span className="inline-flex items-center gap-1.5">
{themeUnavailable ? (
<TriangleAlertIcon
className="size-3.5 text-estimate-pending-foreground"
aria-hidden="true"
/>
) : (
<SparklesIcon className="size-3.5" aria-hidden="true" />
)}
Por tema
</span>
</button>
</div>
</div>
<p className="hidden text-xs text-muted-foreground md:block">
{themeUnavailable
? 'Indisponível agora; mostramos o termo exato.'
: 'Encontra falas relacionadas pelo sentido.'}
</p>
</div>
<CampaignSearchModeControl
activeMode={activeMode}
themeUnavailable={themeUnavailable}
onSelect={runMode}
relatedHint="Encontra falas relacionadas pelo sentido."
/>
<div
role="group"
@ -317,7 +253,7 @@ export const SpeechAcervoFilters = ({
id={facet.id}
label={facet.label}
triggerVariant="chip"
triggerLabel={selectedTriggerLabel(facet.label, facet.selected, facet.labels)}
triggerLabel={selectedFilterTriggerLabel(facet.label, facet.selected, facet.labels)}
active={facet.selected.length > 0}
closeOnChoose={false}
optionRows={facetRows(facet.prefix, facet.options, facet.selected)}

View file

@ -18,6 +18,21 @@ export type CampaignListOmniboxSuggestion = {
keywords?: readonly string[]
}
/**
* The trigger text of a facet chip: the dimension alone when nothing is
* selected, "Ano: 2026" for one value and "Tema: 3" for several — one spelling
* for every acervo filter bar (C154/C219).
*/
export const selectedFilterTriggerLabel = (
base: string,
values: readonly string[],
labels?: Readonly<Record<string, string>>,
): string => {
if (values.length === 0) return base
if (values.length === 1) return `${base}: ${labels?.[values[0]!] ?? values[0]}`
return `${base}: ${values.length}`
}
const omniboxQueryMatches = (
haystack: string,
needle: string,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,76 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
/**
* C219 — facet + provenance columns of the uploaded recordings, mirroring the
* speech catalog (C153): `year` derived from the informed recording date,
* `topics`/`scopes` enums, `classifiedBy` (nullable: a row from before C219 was
* never classified) and the `mentionedMunicipalities` join. Additive only.
*
* The `year` backfill is deterministic SQL (never an LLM pass in a migration):
* it derives exactly what the `deriveRecordingYear` hook derives on the next
* write, so old rows join the "Ano" facet before the classification CLI runs.
*/
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_recording_topics" AS ENUM('saude', 'educacao', 'cultura', 'esporte', 'seguranca-publica', 'meio-ambiente', 'economia-trabalho', 'direitos-humanos', 'infraestrutura', 'ciencia-tecnologia', 'politica-instituicoes', 'agricultura', 'habitacao-cidades', 'comunicacao-midia', 'igualdade-racial', 'mulheres-genero', 'juventude', 'pessoa-deficiencia');
CREATE TYPE "public"."enum_recording_scopes" AS ENUM('bahia', 'brasil', 'internacional');
CREATE TYPE "public"."enum_recording_classified_by" AS ENUM('gazetteer', 'llm', 'manual');
CREATE TABLE "recording_topics" (
"order" integer NOT NULL,
"parent_id" integer NOT NULL,
"value" "enum_recording_topics",
"id" serial PRIMARY KEY NOT NULL
);
CREATE TABLE "recording_scopes" (
"order" integer NOT NULL,
"parent_id" integer NOT NULL,
"value" "enum_recording_scopes",
"id" serial PRIMARY KEY NOT NULL
);
CREATE TABLE "recording_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"municipality_id" integer
);
ALTER TABLE "recording" ADD COLUMN "year" numeric;
ALTER TABLE "recording" ADD COLUMN "classified_by" "enum_recording_classified_by";
ALTER TABLE "recording_topics" ADD CONSTRAINT "recording_topics_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."recording"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "recording_scopes" ADD CONSTRAINT "recording_scopes_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."recording"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "recording_rels" ADD CONSTRAINT "recording_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."recording"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "recording_rels" ADD CONSTRAINT "recording_rels_municipality_fk" FOREIGN KEY ("municipality_id") REFERENCES "public"."municipality"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "recording_topics_order_idx" ON "recording_topics" USING btree ("order");
CREATE INDEX "recording_topics_parent_idx" ON "recording_topics" USING btree ("parent_id");
CREATE INDEX "recording_topics_value_idx" ON "recording_topics" USING btree ("value");
CREATE INDEX "recording_scopes_order_idx" ON "recording_scopes" USING btree ("order");
CREATE INDEX "recording_scopes_parent_idx" ON "recording_scopes" USING btree ("parent_id");
CREATE INDEX "recording_scopes_value_idx" ON "recording_scopes" USING btree ("value");
CREATE INDEX "recording_rels_order_idx" ON "recording_rels" USING btree ("order");
CREATE INDEX "recording_rels_parent_idx" ON "recording_rels" USING btree ("parent_id");
CREATE INDEX "recording_rels_path_idx" ON "recording_rels" USING btree ("path");
CREATE INDEX "recording_rels_municipality_id_idx" ON "recording_rels" USING btree ("municipality_id");
CREATE INDEX "recording_year_idx" ON "recording" USING btree ("year");
CREATE INDEX "recording_classified_by_idx" ON "recording" USING btree ("classified_by");
UPDATE "recording" SET "year" = EXTRACT(YEAR FROM "recorded_at") WHERE "recorded_at" IS NOT NULL;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "recording_topics" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "recording_scopes" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "recording_rels" DISABLE ROW LEVEL SECURITY;
DROP TABLE "recording_topics" CASCADE;
DROP TABLE "recording_scopes" CASCADE;
DROP TABLE "recording_rels" CASCADE;
DROP INDEX "recording_year_idx";
DROP INDEX "recording_classified_by_idx";
ALTER TABLE "recording" DROP COLUMN "year";
ALTER TABLE "recording" DROP COLUMN "classified_by";
DROP TYPE "public"."enum_recording_topics";
DROP TYPE "public"."enum_recording_scopes";
DROP TYPE "public"."enum_recording_classified_by";`)
}

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_040210_add_recording_facets from './20260924_040210_add_recording_facets';
import * as migration_20260924_042028_add_web_speech_origin from './20260924_042028_add_web_speech_origin';
import * as migration_20260924_045625_add_content_piece_link_failure_reason from './20260924_045625_add_content_piece_link_failure_reason';
@ -506,6 +507,11 @@ export const migrations = [
down: migration_20260923_190414_add_content_event_variant.down,
name: '20260923_190414_add_content_event_variant',
},
{
up: migration_20260924_040210_add_recording_facets.up,
down: migration_20260924_040210_add_recording_facets.down,
name: '20260924_040210_add_recording_facets'
},
{
up: migration_20260924_042028_add_web_speech_origin.up,
down: migration_20260924_042028_add_web_speech_origin.down,

View file

@ -1362,7 +1362,7 @@ export interface ContentEvent {
createdAt: string;
}
/**
* Gravações próprias da equipe no acervo. O arquivo é privado; a transcrição é somente leitura.
* Gravações próprias da equipe no acervo. O arquivo é privado; a transcrição é somente leitura. Tema, alcance e municípios citados são classificados automaticamente pelo início da transcrição e podem ser corrigidos aqui (a correção vira proveniência "Manual").
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "recording".
@ -1374,6 +1374,10 @@ export interface Recording {
* Data em que a gravação foi feita (opcional).
*/
recordedAt?: string | null;
/**
* Derivado da data da gravação.
*/
year?: number | null;
status: 'uploading' | 'processing' | 'ready' | 'failed';
/**
* Progresso honesto da transcrição em andamento.
@ -1391,6 +1395,34 @@ export interface Recording {
* Concatenação normalizada dos segmentos (sem acentos, minúsculas).
*/
searchText?: string | null;
topics?:
| (
| 'saude'
| 'educacao'
| 'cultura'
| 'esporte'
| 'seguranca-publica'
| 'meio-ambiente'
| 'economia-trabalho'
| 'direitos-humanos'
| 'infraestrutura'
| 'ciencia-tecnologia'
| 'politica-instituicoes'
| 'agricultura'
| 'habitacao-cidades'
| 'comunicacao-midia'
| 'igualdade-racial'
| 'mulheres-genero'
| 'juventude'
| 'pessoa-deficiencia'
)[]
| null;
scopes?: ('bahia' | 'brasil' | 'internacional')[] | null;
/**
* Automática e auditável: Gazetteer (léxico), LLM (refinamento validado) ou Manual (correção humana).
*/
classifiedBy?: ('gazetteer' | 'llm' | 'manual') | null;
mentionedMunicipalities?: (number | Municipality)[] | null;
/**
* Rótulos humanos por agrupamento acústico (chave → nome). Identificação sempre humana; o acervo nunca sugere ou infere pessoas.
*/
@ -2872,11 +2904,16 @@ export interface ContentEventSelect<T extends boolean = true> {
export interface RecordingSelect<T extends boolean = true> {
title?: T;
recordedAt?: T;
year?: T;
status?: T;
step?: T;
media?: T;
durationSeconds?: T;
searchText?: T;
topics?: T;
scopes?: T;
classifiedBy?: T;
mentionedMunicipalities?: T;
speakerLabels?:
| T
| {

View file

@ -15,17 +15,23 @@ const EXPAND_TIMEOUT_MS = 4000
const MAX_OUTPUT_TOKENS = 200
const MAX_THEME_LENGTH = 200
const EXPAND_SUBJECT_BY_CORPUS: Record<ThemeSearchCorpus, string> = {
speech: 'na transcrição de falas',
contentPiece: 'na descrição ou na transcrição de uma peça',
recording: 'na transcrição de uma gravação enviada pela equipe',
}
const expandSchemaFor = (corpus: ThemeSearchCorpus) =>
z.object({
terms: z
.array(z.string())
.describe(
`Termos e expressões curtas (${MIN_SPEECH_THEME_TERMS} a ${MAX_SPEECH_THEME_TERMS}) que podem aparecer ${corpus === 'speech' ? 'na transcrição de falas' : 'na descrição ou na transcrição de uma peça'} sobre o tema. Lista vazia quando não houver termo confiável.`,
`Termos e expressões curtas (${MIN_SPEECH_THEME_TERMS} a ${MAX_SPEECH_THEME_TERMS}) que podem aparecer ${EXPAND_SUBJECT_BY_CORPUS[corpus]} sobre o tema. Lista vazia quando não houver termo confiável.`,
),
})
/** The term rules are one contract; only the noun of the corpus changes. */
const themeExpansionRules = (subject: 'discurso' | 'peça'): string =>
const themeExpansionRules = (subject: 'discurso' | 'peça' | 'gravação'): string =>
`Devolva de ${MIN_SPEECH_THEME_TERMS} a ${MAX_SPEECH_THEME_TERMS} termos, cada um com no máximo ${MAX_SPEECH_THEME_TERM_LENGTH} caracteres, sem explicações, ` +
`sem pontuação extra e sem termos genéricos que apareceriam em qualquer ${subject} (ex.: "Brasil", ` +
'"povo", "governo"). Se não houver termo confiável, devolva a lista vazia. Nunca invente fatos.'
@ -48,14 +54,34 @@ const CONTENT_PIECE_CORPUS_PROMPT =
themeExpansionRules('peça')
/**
* S28 — the corpus a theme expansion serves: the internal speech acervo (C192)
* and the public Central de Conteúdos (S28). The mechanism is one; only the
* system prompt describes a different body of material and persona.
* C219 — the third corpus: the uploaded recordings of the acervo ("Gravações
* enviadas"), whose material is the team's own plenárias/debates rather than
* the Câmara's speeches, so the prompt describes that body of material.
*/
export type ThemeSearchCorpus = 'speech' | 'contentPiece'
const RECORDING_CORPUS_PROMPT =
'Você ajuda a assessoria de comunicação a achar, no acervo de gravações enviadas pela equipe ' +
'(plenárias, debates e materiais próprios do deputado Jorge Solla), trechos sobre um tema mesmo ' +
'quando a gravação não usa as palavras exatas da busca. ' +
'Receberá o tema digitado e deve devolver termos e expressões curtas que provavelmente aparecem ' +
'na transcrição de uma gravação sobre esse tema — sinônimos, nomes de programas, termos técnicos ' +
'e palavras relacionadas. Inclua a própria expressão do tema quando ela for útil. ' +
themeExpansionRules('gravação')
const systemPromptFor = (corpus: ThemeSearchCorpus): string =>
corpus === 'contentPiece' ? CONTENT_PIECE_CORPUS_PROMPT : SPEECH_CORPUS_PROMPT
/**
* S28/C219 — the corpus a theme expansion serves: the internal speech acervo
* (C192), the public Central de Conteúdos (S28) and the uploaded recordings
* (C219). The mechanism is one; only the system prompt describes a different
* body of material and persona.
*/
export type ThemeSearchCorpus = 'speech' | 'contentPiece' | 'recording'
const SYSTEM_PROMPT_BY_CORPUS: Record<ThemeSearchCorpus, string> = {
speech: SPEECH_CORPUS_PROMPT,
contentPiece: CONTENT_PIECE_CORPUS_PROMPT,
recording: RECORDING_CORPUS_PROMPT,
}
const systemPromptFor = (corpus: ThemeSearchCorpus): string => SYSTEM_PROMPT_BY_CORPUS[corpus]
export type ThemeSearchExpansion = { terms: string[] }
@ -110,3 +136,7 @@ export const expandSpeechSearchTheme: SpeechThemeExpansionResolver = (theme) =>
/** S28 — the public Central de Conteúdos expansion. */
export const expandContentPieceSearchTheme: ThemeSearchExpansionResolver = (theme) =>
expandSearchTheme(theme, 'contentPiece')
/** C219 — the uploaded recordings expansion ("Gravações enviadas"). */
export const expandRecordingSearchTheme: ThemeSearchExpansionResolver = (theme) =>
expandSearchTheme(theme, 'recording')

View file

@ -82,6 +82,21 @@ export const strictDecimalInteger = (value: string | undefined): number | undefi
return Number.isSafeInteger(number) ? number : undefined
}
/**
* Structural validation of the data-driven year facet (both acervo sources):
* a plausible calendar year, not a closed enum.
*/
export const parseYearValues = (raw: string | string[] | undefined): number[] =>
allParamValues(raw)
.map((token) => strictDecimalInteger(token))
.filter((year): year is number => year !== undefined && year >= 1900 && year <= 2100)
/** Structural validation of a municipality-id facet (positive safe integer). */
export const parseMunicipalityValues = (raw: string | string[] | undefined): number[] =>
allParamValues(raw)
.map((token) => strictDecimalInteger(token))
.filter((id): id is number => id !== undefined && id > 0)
export const inspectRawListParams = (
params: RawSearchParams,
paramNameSet: ReadonlySet<string>,

View file

@ -0,0 +1,97 @@
import 'server-only'
import type { Payload } from 'payload'
import type { MergedSpeechFacets, SpeechScope, SpeechTopic } from '@/lib/speechFacets'
import type { SpeechFacetInput } from '@/lib/speechGazetteer'
import type { Recording } from '@/payload-types'
import {
classifySpeech,
type SpeechClassificationResult,
} from '@/utilities/speech/speechClassifier'
/**
* C219 — facet classification of one uploaded recording. Reuses the speech
* classifier owner (`classifySpeech`: gazetteer + validated LLM refinement)
* over the transcript the job already has in memory, so recordings gain the
* same topic/scope/município facets with the same provenance vocabulary. The
* classifier never throws; this wrapper is also defensive (empty transcript or
* a database failure resolve to `null`) because the recording must still
* become `ready` with its transcript.
*/
export type RecordingFacetClassifier = (
input: SpeechFacetInput,
) => Promise<SpeechClassificationResult>
export type RecordingClassification = {
topics: SpeechTopic[]
scopes: SpeechScope[]
classifiedBy: MergedSpeechFacets['classifiedBy']
mentionedMunicipalities: number[]
/** Provider usage of the refinement pass; the backfill CLI reports it. */
llm: SpeechClassificationResult['llm']
}
/**
* The facet write shape, shared by the two writers (the job and the backfill
* CLI) so a new facet field lands in one place.
*/
export const recordingFacetWriteData = (
classification: RecordingClassification,
): Pick<Recording, 'topics' | 'scopes' | 'classifiedBy' | 'mentionedMunicipalities'> => ({
topics: classification.topics,
scopes: classification.scopes,
classifiedBy: classification.classifiedBy,
mentionedMunicipalities: classification.mentionedMunicipalities,
})
export const classifyRecordingFacets = async ({
payload,
transcript,
classify = classifySpeech,
}: {
payload: Pick<Payload, 'find' | 'logger'>
transcript: string
classify?: RecordingFacetClassifier
}): Promise<RecordingClassification | null> => {
const text = transcript.trim()
if (!text) return null
try {
const { facets, llm } = await classify({ transcript: text })
const slugs = [...new Set(facets.municipalities.map((entry) => entry.slug))]
const municipalities =
slugs.length === 0
? []
: (
await payload.find({
collection: 'municipality',
where: { slug: { in: slugs } },
limit: slugs.length,
depth: 0,
select: { slug: true },
// Intentional admin bypass: resolving gazetteer slugs against the
// read-only município catalog (same as the speech import).
overrideAccess: true,
})
).docs
return {
topics: facets.topics,
scopes: facets.scopes,
classifiedBy: facets.classifiedBy,
mentionedMunicipalities: municipalities.map((doc) => doc.id),
llm,
}
} catch (error) {
// Defensive: the classifier contract is "never throws"; a database failure
// while resolving the gazetteer slugs lands here. The recording still gets
// its transcript — but the failure is logged instead of vanishing.
payload.logger.warn(
`[recording-classification] falha ao classificar: ${error instanceof Error ? error.message : String(error)}`,
)
return null
}
}

View file

@ -39,6 +39,12 @@ import {
type PayloadTransactionRequest,
} from '@/utilities/payloadTransaction'
import { downloadPrivateMediaToFile } from '@/utilities/privateMedia/privateMediaResponse'
import {
classifyRecordingFacets,
recordingFacetWriteData,
type RecordingFacetClassifier,
} from '@/utilities/recordings/recordingClassification'
import { classifySpeech } from '@/utilities/speech/speechClassifier'
/**
* C199 — the transcription job: download the private recording, extract/split
@ -128,6 +134,7 @@ export const runRecordingJob = async (
recordingId: number,
transcribe: ChunkTranscriber = deepInfraTranscribeSegments,
diarize: SpeakerDiarizer | null = configuredSpeakerDiarizer(),
classify: RecordingFacetClassifier = classifySpeech,
): Promise<void> => {
let tempDir: string | null = null
let currentStep: RecordingStep = 'extracting'
@ -237,6 +244,15 @@ export const runRecordingJob = async (
}
}
// C219 — facet classification runs OUTSIDE the save transaction (the LLM
// may take a minute) and never fails the recording: a null result keeps the
// transcript with no facets and no provenance claim.
const classification = await classifyRecordingFacets({
payload,
transcript: keyedSegments.map((segment) => segment.text).join(' '),
classify,
})
await markStep('saving')
await withPayloadTransaction(payload, async ({ req }) => {
// Fresh reads inside the transaction: the labels a curator wrote while
@ -310,6 +326,7 @@ export const runRecordingJob = async (
searchText: recordingSearchText(segments),
speakerLabels: labels,
speakerLabelsDropped: dropped,
...(classification ? recordingFacetWriteData(classification) : {}),
},
req,
)

View file

@ -1,22 +1,67 @@
/**
* C199 — Payload `where` of the uploaded-recordings list: one textual branch
* over the normalized `recording.searchText` (the concatenation of the
* transcript segments). C200 adds the "Pessoa" facet: any selected label that
* appears in the recording's derived `speakerNames`. A recording still
* processing has no `searchText` and is honestly out of the search until the
* transcript is saved.
* C199/C219 — Payload `where` of the uploaded-recordings list. One textual
* branch over the normalized `recording.searchText` (the concatenation of the
* transcript segments), the "Pessoa" facet (any selected label present in the
* derived `speakerNames`) and the C219 parity facets — year, topic, scope,
* cited municipality and duration — AND-ed like the Câmara's. A duration sort
* also gates `durationSeconds` to exist, otherwise Postgres' NULLS FIRST would
* open "Duração (maior)" with the rows that have no duration.
*/
import type { Where } from 'payload'
import { normalizeForSearch } from '@/lib/speechSearch'
import { collapseListWhereOrBranches } from '@/utilities/campaignListUrl'
import type { RecordingListState } from '@/utilities/recordings/recordingListUrl'
import {
recordingSortIsDuration,
type RecordingListState,
} from '@/utilities/recordings/recordingListUrl'
import { durationBucketWhere } from '@/utilities/speech/speechListFilters'
export const buildRecordingListWhere = (state: RecordingListState): Where => {
const buildRecordingFacetWhere = (state: RecordingListState): Where[] => {
const filters: Where[] = []
if (state.years?.length) filters.push({ year: { in: state.years } })
if (state.topics?.length) filters.push({ topics: { in: state.topics } })
if (state.scopes?.length) filters.push({ scopes: { in: state.scopes } })
if (state.municipalities?.length) {
filters.push({ mentionedMunicipalities: { in: state.municipalities } })
}
if (state.durations?.length) {
const branch = collapseListWhereOrBranches(state.durations.map(durationBucketWhere))
if (branch) filters.push(branch)
}
if (recordingSortIsDuration(state)) filters.push({ durationSeconds: { exists: true } })
return filters
}
/**
* The textual branch: the literal query plus (C219) every expanded theme term,
* each matching the normalized recording text. Recordings have no official
* keywords, so the speech `keywords` branch has no counterpart here.
*/
const buildRecordingTextWhere = (terms: readonly string[]): Where | undefined => {
const branches: Where[] = []
const seen = new Set<string>()
const normalized = normalizeForSearch(state.q ?? '')
if (normalized) branches.push({ searchText: { like: normalized } })
for (const term of terms) {
const normalized = normalizeForSearch(term)
if (!normalized || seen.has(normalized)) continue
seen.add(normalized)
branches.push({ searchText: { like: normalized } })
}
return collapseListWhereOrBranches(branches)
}
export const buildRecordingListWhere = (
state: RecordingListState,
themeTerms: readonly string[] = [],
): Where => {
const branches = buildRecordingFacetWhere(state)
const textBranch = buildRecordingTextWhere([...(state.q ? [state.q] : []), ...themeTerms])
if (textBranch) branches.push(textBranch)
const peopleBranch = collapseListWhereOrBranches(
(state.people ?? []).map((person) => ({ speakerNames: { contains: person } })),

View file

@ -1,43 +1,108 @@
/**
* C199 — source switcher + list URL contract of the uploaded recordings. The
* Câmara acervo keeps its own frozen contract (`speechListUrl`); the recordings
* branch lives on the same `/campanha/comunicacao/acervo` page behind
* `?source=enviadas` and owns its parses/serialization over the shared helpers.
* C199/C219 — source switcher + list URL contract of the uploaded recordings.
* The Câmara acervo keeps its own frozen contract (`speechListUrl`); the
* recordings branch lives on the same `/campanha/comunicacao/acervo` page
* behind `?source=enviadas` and owns its parses/serialization over the shared
* helpers. C219 brings the parity facets — search mode, year, topic, scope,
* cited municipality, duration and sort — reusing the Câmara taxonomy, the
* duration buckets and the structural year/municipality parsers. The original
* `?source=enviadas&q&person&page` deep links stay byte-identical: the new
* params are only serialized when selected and `mode=termo`/the default sort
* are never serialized.
*/
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
import { RECORDING_SPEAKER_LABEL_MAX_LENGTH } from '@/lib/recording'
import { foldSpeakerName } from '@/lib/recordingDiarization'
import {
SPEECH_SCOPES,
SPEECH_TOPICS,
type SpeechScope,
type SpeechTopic,
} from '@/lib/speechFacets'
import {
allParamValues,
buildListHref,
firstValue,
normalizedText,
parseExhaustiveEnumParam,
parseMunicipalityValues,
parseYearValues,
resolveListUrl,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
import {
SPEECH_DURATION_BUCKETS,
type SpeechDurationBucket,
type SpeechSearchMode,
} from '@/utilities/speech/speechListUrl'
export const recordingPageSize = 25
/** Which acervo source the page is showing. `camara` is the default. */
export type AcervoSource = 'camara' | 'enviadas'
/**
* C219 — list ordering. `recentes` is the default and never serialized (the
* stored contract stays `-createdAt`); the duration orders only list rows with
* a measured duration, which is why the `where` gates `durationSeconds`.
*/
export const RECORDING_SORT_OPTIONS = [
{ value: 'recentes', label: 'Mais recentes' },
{ value: 'duracao_maior', label: 'Duração (maior)' },
{ value: 'duracao_menor', label: 'Duração (menor)' },
] as const
export type RecordingSortKey = (typeof RECORDING_SORT_OPTIONS)[number]['value']
const ACERVO_SOURCE_PARAM = 'source'
const ACERVO_SOURCE_ENVIADAS = 'enviadas'
const ACERVO_PERSON_PARAM = 'person'
const recordingListParamNames = [ACERVO_SOURCE_PARAM, 'q', 'page', ACERVO_PERSON_PARAM] as const
const recordingListParamNames = [
ACERVO_SOURCE_PARAM,
'q',
'mode',
'year',
'topic',
'scope',
'municipality',
'duration',
'sort',
ACERVO_PERSON_PARAM,
'page',
] as const
const recordingListParamNameSet = new Set<string>(recordingListParamNames)
const recordingTopicSet = new Set<string>(SPEECH_TOPICS.map(({ value }) => value))
const recordingScopeSet = new Set<string>(SPEECH_SCOPES.map(({ value }) => value))
const recordingDurationSet = new Set<string>(SPEECH_DURATION_BUCKETS.map(({ value }) => value))
const recordingSortSet = new Set<string>(RECORDING_SORT_OPTIONS.map(({ value }) => value))
export type RecordingListState = {
source: 'enviadas'
page: number
q?: string
/** C219 — `tema` asks the semantic bridge; `termo` (default) is never stored. */
mode?: SpeechSearchMode
years?: number[]
topics?: SpeechTopic[]
scopes?: SpeechScope[]
/** Cited municipalities (`mentionedMunicipalities`), not the uploader's. */
municipalities?: number[]
durations?: SpeechDurationBucket[]
sort?: RecordingSortKey
/** Selected "Pessoa" labels; repeated `person` params, literal text. */
people?: string[]
}
/** Server-loaded filter options (present years, cited municipalities, people). */
export type RecordingFilterOptions = {
people: string[]
years: number[]
municipalities: { value: string; label: string }[]
}
/** `enviadas` only for the explicit value; anything else is the Câmara default. */
export const parseAcervoSource = (params: RawSearchParams): AcervoSource =>
firstValue(params[ACERVO_SOURCE_PARAM]) === ACERVO_SOURCE_ENVIADAS ? 'enviadas' : 'camara'
@ -60,33 +125,99 @@ export const parseRecordingPeople = (raw: string | string[] | undefined): string
return people
}
/** Only `tema` is meaningful; anything else (and `termo`) means the default. */
const parseRecordingSearchMode = (
raw: string | string[] | undefined,
): SpeechSearchMode | undefined => (firstValue(raw) === 'tema' ? 'tema' : undefined)
/** Only the non-default duration orders are meaningful; anything else is `recentes`. */
const parseRecordingSort = (raw: string | string[] | undefined): RecordingSortKey | undefined => {
const value = firstValue(raw)
return value && value !== 'recentes' && recordingSortSet.has(value)
? (value as RecordingSortKey)
: undefined
}
export const parseRecordingListParams = (params: RawSearchParams): RecordingListState => {
const rawPage = strictDecimalInteger(firstValue(params.page))
const rawQ = normalizedText(firstValue(params.q))
const q = rawQ && isContactSearchQueryReady(rawQ) ? rawQ : undefined
const mode = parseRecordingSearchMode(params.mode)
const years = parseYearValues(params.year)
const topics = parseExhaustiveEnumParam<SpeechTopic>(params.topic, recordingTopicSet)
const scopes = parseExhaustiveEnumParam<SpeechScope>(params.scope, recordingScopeSet)
const municipalities = parseMunicipalityValues(params.municipality)
const durations = parseExhaustiveEnumParam<SpeechDurationBucket>(
params.duration,
recordingDurationSet,
)
const sort = parseRecordingSort(params.sort)
const people = parseRecordingPeople(params[ACERVO_PERSON_PARAM])
return {
source: 'enviadas',
page: rawPage ?? 1,
...(q ? { q } : {}),
...(mode ? { mode } : {}),
...(years.length ? { years } : {}),
...(topics.length ? { topics } : {}),
...(scopes.length ? { scopes } : {}),
...(municipalities.length ? { municipalities } : {}),
...(durations.length ? { durations } : {}),
...(sort ? { sort } : {}),
...(people.length > 0 ? { people } : {}),
}
}
const recordingListSearchParams = (
const recordingListStateToRawParams = (
state: RecordingListState,
page = state.page,
): RawSearchParams => ({
[ACERVO_SOURCE_PARAM]: ACERVO_SOURCE_ENVIADAS,
page: String(page),
q: state.q,
mode: state.mode,
year: state.years?.map(String),
topic: state.topics,
scope: state.scopes,
municipality: state.municipalities?.map(String),
duration: state.durations,
sort: state.sort,
[ACERVO_PERSON_PARAM]: state.people,
})
/** Expects already-canonical state (from parse or a rule-preserving toggle). */
const serializeCanonicalRecordingListSearchParams = (
canonicalState: RecordingListState,
): URLSearchParams => {
const params = new URLSearchParams()
// `source=enviadas` is always serialized — it is what selects the source.
params.set(ACERVO_SOURCE_PARAM, ACERVO_SOURCE_ENVIADAS)
if (state.q) params.set('q', state.q)
for (const person of state.people ?? []) params.append(ACERVO_PERSON_PARAM, person)
if (page > 1) params.set('page', String(page))
if (canonicalState.q) params.set('q', canonicalState.q)
// `termo` is the default and never serialized; `tema` only makes sense with a
// query, so a theme param without `q` canonicalizes away.
if (canonicalState.mode === 'tema' && canonicalState.q) params.set('mode', 'tema')
for (const year of canonicalState.years ?? []) params.append('year', String(year))
for (const topic of canonicalState.topics ?? []) params.append('topic', topic)
for (const scope of canonicalState.scopes ?? []) params.append('scope', scope)
for (const municipality of canonicalState.municipalities ?? []) {
params.append('municipality', String(municipality))
}
for (const duration of canonicalState.durations ?? []) params.append('duration', duration)
// `recentes` is the default and never serialized.
if (canonicalState.sort && canonicalState.sort !== 'recentes') {
params.set('sort', canonicalState.sort)
}
for (const person of canonicalState.people ?? []) params.append(ACERVO_PERSON_PARAM, person)
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))
return params
}
const recordingListSearchParams = (state: RecordingListState, page = state.page): URLSearchParams =>
serializeCanonicalRecordingListSearchParams(
parseRecordingListParams(recordingListStateToRawParams(state, page)),
)
/** The URL of one "Pessoa" facet option with `person` toggled (page resets). */
export const toggleRecordingPerson = (
state: RecordingListState,
@ -114,6 +245,47 @@ export const removeRecordingPerson = (
export const buildRecordingListHref = (state: RecordingListState, page: number): string =>
buildListHref(state, recordingListSearchParams, CAMPAIGN_COMMUNICATION_ACERVO, page)
/**
* C219 — the canonical query drives the filter bar remount (`key`), exactly
* like `buildSpeechFiltersKey`, so optimistic state resets when the URL
* changes.
*/
export const buildRecordingFiltersKey = (state: RecordingListState): string =>
recordingListSearchParams(state).toString()
/** Payload sort order of the list: the default keeps the stored `-createdAt`. */
export const recordingSortOrder = (state: RecordingListState): string => {
switch (state.sort) {
case 'duracao_maior':
return '-durationSeconds'
case 'duracao_menor':
return 'durationSeconds'
default:
return '-createdAt'
}
}
/** Duration orders only make sense over rows with a measured duration. */
export const recordingSortIsDuration = (state: RecordingListState): boolean =>
state.sort === 'duracao_maior' || state.sort === 'duracao_menor'
/**
* C219 — whether the actor narrowed the list (query/mode or any facet). The
* empty state uses it to tell "no recording was ever sent" from "the filters
* hid everything"; pagination is not a filter.
*/
export const recordingHasActiveFilters = (state: RecordingListState): boolean =>
Boolean(
state.q ||
state.mode ||
state.years?.length ||
state.topics?.length ||
state.scopes?.length ||
state.municipalities?.length ||
state.durations?.length ||
state.people?.length,
)
/** Toggle hrefs of the source switcher (the Câmara side is the bare acervo). */
export const buildAcervoSourceHref = (source: AcervoSource): string =>
source === 'enviadas'

View file

@ -0,0 +1,286 @@
/**
* C219 — recordings acervo omnibox adapter. Pure / client-safe: builds the
* active chips, the suggestion seeds (facet constants plus server-loaded
* years/municipalities/people) and the toggle/remove/clear actions over the
* URL state, mirroring `speechOmnibox` on the same shared helpers. "Pessoa"
* reuses the case-insensitive dedupe owned by the recordings URL contract.
*/
import {
createOmniboxSuggestionSeed,
filterOmniboxSuggestionSeeds,
type CampaignListOmniboxChip,
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
import {
SPEECH_SCOPES,
SPEECH_TOPICS,
type SpeechScope,
type SpeechTopic,
} from '@/lib/speechFacets'
import {
removeRecordingPerson,
toggleRecordingPerson,
type RecordingListState,
} from '@/utilities/recordings/recordingListUrl'
import {
SPEECH_DURATION_BUCKETS,
speechDurationLabels,
speechScopeLabels,
speechTopicLabels,
type SpeechDurationBucket,
type SpeechSearchMode,
} from '@/utilities/speech/speechListUrl'
export type RecordingFilterOption = {
value: string
label: string
}
const MUNICIPALITY_CHIP_LABEL = 'Município citado'
export type RecordingOmniboxAction =
| { kind: 'url'; state: RecordingListState }
| { kind: 'clear'; state: RecordingListState }
const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
const withPageReset = (state: RecordingListState): RecordingListState => ({ ...state, page: 1 })
const toggleValue = <T>(values: T[] | undefined, value: T): T[] | undefined => {
const current = values ?? []
const next = current.includes(value)
? current.filter((item) => item !== value)
: [...current, value]
return next.length ? next : undefined
}
const removeValue = <T>(values: T[] | undefined, value: T): T[] | undefined => {
const next = (values ?? []).filter((item) => item !== value)
return next.length ? next : undefined
}
const chipPrefix = (chipId: string): { key: string; value: string } => {
const separator = chipId.indexOf(':')
if (separator === -1) return { key: chipId, value: '' }
return { key: chipId.slice(0, separator), value: chipId.slice(separator + 1) }
}
type FacetMutation = 'toggle' | 'remove'
/**
* ONE facet mutation for both omnibox actions: `toggle` (selecting a
* suggestion) and `remove` (dropping a chip) differ only in the values
* operation. Returns null when the id carries an invalid numeric value.
*/
const mutateFacet = (
state: RecordingListState,
key: string,
value: string,
mode: FacetMutation,
): RecordingListState | null => {
const mutate = mode === 'toggle' ? toggleValue : removeValue
switch (key) {
case 'year': {
const year = Number(value)
if (!Number.isSafeInteger(year)) return null
return { ...state, years: mutate(state.years, year) }
}
case 'topic':
return { ...state, topics: mutate(state.topics, value as SpeechTopic) }
case 'scope':
return { ...state, scopes: mutate(state.scopes, value as SpeechScope) }
case 'municipality': {
const municipality = Number(value)
if (!Number.isSafeInteger(municipality)) return null
return { ...state, municipalities: mutate(state.municipalities, municipality) }
}
case 'duration':
return { ...state, durations: mutate(state.durations, value as SpeechDurationBucket) }
default:
return null
}
}
export const buildRecordingOmniboxChips = ({
state,
municipalityLabelsById,
}: {
state: RecordingListState
municipalityLabelsById: ReadonlyMap<number, string>
}): CampaignListOmniboxChip[] => {
const chips: CampaignListOmniboxChip[] = []
if (state.q) chips.push({ id: 'q', label: chipLabel('Busca', state.q) })
for (const year of state.years ?? []) {
chips.push({ id: `year:${year}`, label: chipLabel('Ano', String(year)) })
}
for (const topic of state.topics ?? []) {
chips.push({ id: `topic:${topic}`, label: chipLabel('Tema', speechTopicLabels[topic]) })
}
for (const scope of state.scopes ?? []) {
chips.push({ id: `scope:${scope}`, label: chipLabel('Alcance', speechScopeLabels[scope]) })
}
for (const municipality of state.municipalities ?? []) {
chips.push({
id: `municipality:${municipality}`,
label: chipLabel(
MUNICIPALITY_CHIP_LABEL,
municipalityLabelsById.get(municipality) ?? `Município #${municipality}`,
),
})
}
for (const duration of state.durations ?? []) {
chips.push({
id: `duration:${duration}`,
label: chipLabel('Duração', speechDurationLabels[duration]),
})
}
for (const person of state.people ?? []) {
chips.push({ id: `person:${person}`, label: chipLabel('Pessoa', person) })
}
return chips
}
export const buildRecordingOmniboxSuggestionSeeds = ({
years,
municipalityOptions,
personOptions,
}: {
years: readonly number[]
municipalityOptions: readonly RecordingFilterOption[]
personOptions: readonly string[]
}) => {
const seeds = []
for (const { value, label } of SPEECH_TOPICS) {
seeds.push(
createOmniboxSuggestionSeed(
{ id: `topic:${value}`, group: 'Tema', label, keywords: ['tema'] },
{ emptyQueryVisible: true },
),
)
}
for (const { value, label } of SPEECH_SCOPES) {
seeds.push(
createOmniboxSuggestionSeed(
{ id: `scope:${value}`, group: 'Alcance', label, keywords: ['alcance'] },
{ emptyQueryVisible: true },
),
)
}
for (const { value, label } of SPEECH_DURATION_BUCKETS) {
seeds.push(
createOmniboxSuggestionSeed(
{ id: `duration:${value}`, group: 'Duração', label, keywords: ['duracao', 'tempo'] },
{ emptyQueryVisible: true },
),
)
}
for (const year of years) {
seeds.push(
createOmniboxSuggestionSeed({
id: `year:${year}`,
group: 'Ano',
label: String(year),
keywords: ['ano'],
}),
)
}
for (const option of municipalityOptions) {
seeds.push(
createOmniboxSuggestionSeed({
id: `municipality:${option.value}`,
group: MUNICIPALITY_CHIP_LABEL,
label: option.label,
keywords: ['municipio', 'cidade', 'citado'],
}),
)
}
for (const person of personOptions) {
seeds.push(
createOmniboxSuggestionSeed({
id: `person:${person}`,
group: 'Pessoa',
label: person,
keywords: ['pessoa', 'falante'],
}),
)
}
return seeds
}
export const filterRecordingOmniboxSuggestions = (
seeds: ReturnType<typeof buildRecordingOmniboxSuggestionSeeds>,
query: string,
): CampaignListOmniboxSuggestion[] => filterOmniboxSuggestionSeeds(seeds, query)
export const applyRecordingOmniboxSuggestion = ({
state,
suggestionId,
}: {
state: RecordingListState
suggestionId: string
}): RecordingOmniboxAction => {
if (suggestionId.startsWith('q:')) {
const q = suggestionId.slice(2).trim()
return { kind: 'url', state: withPageReset({ ...state, q: q || undefined }) }
}
const { key, value } = chipPrefix(suggestionId)
if (key === 'person') {
return { kind: 'url', state: toggleRecordingPerson(withPageReset(state), value) }
}
const next = mutateFacet(withPageReset(state), key, value, 'toggle')
return { kind: 'url', state: next ?? state }
}
export const removeRecordingOmniboxChip = ({
state,
chipId,
}: {
state: RecordingListState
chipId: string
}): RecordingOmniboxAction => {
// Dropping the query also drops the theme mode: a theme without a query has
// nothing to expand.
if (chipId === 'q') {
return { kind: 'url', state: withPageReset({ ...state, q: undefined, mode: undefined }) }
}
const { key, value } = chipPrefix(chipId)
if (key === 'person') {
return { kind: 'url', state: removeRecordingPerson(withPageReset(state), value) }
}
const next = mutateFacet(withPageReset(state), key, value, 'remove')
return { kind: 'url', state: next ?? state }
}
/**
* C219 — the search mode toggle. `tema` only sticks with a query; `termo`
* (the default) clears the param so the URL stays canonical and shareable.
*/
export const applyRecordingSearchMode = ({
state,
mode,
}: {
state: RecordingListState
mode: SpeechSearchMode
}): RecordingOmniboxAction => ({
kind: 'url',
state: withPageReset({ ...state, mode: mode === 'tema' && state.q ? 'tema' : undefined }),
})
/** Clearing keeps `source=enviadas` — it is what selects this source. */
export const clearRecordingOmnibox = (_state: RecordingListState): RecordingOmniboxAction => ({
kind: 'clear',
state: { source: 'enviadas', page: 1 },
})

View file

@ -2,15 +2,24 @@ import 'server-only'
import type { Payload } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { foldSpeakerName } from '@/lib/recordingDiarization'
import { RECORDING_NOT_FOUND_MESSAGE } from '@/lib/schemas/recording'
import { normalizeForSearch } from '@/lib/speechSearch'
import type { CampaignUser } from '@/payload-types'
import {
expandRecordingSearchTheme,
type ThemeSearchExpansionResolver,
} from '@/utilities/ai/expandSpeechSearchTheme'
import { collapseListWhereOrBranches } from '@/utilities/campaignListUrl'
import { createEntityNotFoundError } from '@/utilities/entityNotFound'
import { loadMunicipalityLabelsByIds } from '@/utilities/loadNamesByIds'
import { buildRecordingListWhere } from '@/utilities/recordings/recordingListFilters'
import {
recordingPageSize,
recordingSortOrder,
resolveRecordingListUrl,
type RecordingFilterOptions,
type RecordingListState,
} from '@/utilities/recordings/recordingListUrl'
import {
@ -35,6 +44,8 @@ const recordingListSelect = {
recordedAt: true,
durationSeconds: true,
speakerNames: true,
// C219 — the card's topic chip; the facet itself is not displayed.
topics: true,
} as const
const recordingDetailSelect = {
@ -63,17 +74,12 @@ const toSegmentRecord = (segment: {
speakerKey: segment.speakerKey ?? null,
})
/** C200 — the options the "Pessoa" facet offers. */
export type RecordingFilterOptions = {
people: string[]
}
/**
* C200 — distinct labels across the recordings the actor can read: the facet
* only ever lists what is reachable, and the derived `speakerNames` column is
* the single source. Small acervo now; if the list grows past a few thousand
* rows this becomes a `SELECT DISTINCT` (registered as a revisit trigger in the
* impl plan).
* C200/C219 — distinct labels/years/cited municipalities across the
* recordings the actor can read: a facet only ever lists what is reachable,
* and the derived columns are the single source. Municipality labels resolve
* through the justified `loadMunicipalityLabelsByIds` bypass — the ids come
* from recordings the actor was already authorized to read.
*/
export const loadRecordingFilterOptions = async (
payload: Payload,
@ -84,13 +90,15 @@ export const loadRecordingFilterOptions = async (
depth: 0,
limit: 0,
pagination: false,
select: { speakerNames: true },
select: { speakerNames: true, year: true, mentionedMunicipalities: true },
user,
overrideAccess: false,
})
const people: string[] = []
const seen = new Set<string>()
const years = new Set<number>()
const municipalityIds = new Set<number>()
for (const recording of result.docs) {
for (const name of recording.speakerNames ?? []) {
const trimmed = name.trim()
@ -99,35 +107,56 @@ export const loadRecordingFilterOptions = async (
seen.add(folded)
people.push(trimmed)
}
if (typeof recording.year === 'number') years.add(recording.year)
for (const value of recording.mentionedMunicipalities ?? []) {
municipalityIds.add(typeof value === 'number' ? value : value.id)
}
}
return { people: people.sort((left, right) => left.localeCompare(right, 'pt-BR')) }
const labels = await loadMunicipalityLabelsByIds(payload, [...municipalityIds])
const municipalities = [...municipalityIds]
.flatMap((id) => {
const entry = labels.get(id)
return entry ? [{ value: String(id), label: entry.name }] : []
})
.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR'))
return {
people: people.sort((left, right) => left.localeCompare(right, 'pt-BR')),
years: [...years].sort((left, right) => right - left),
municipalities,
}
}
/**
* The excerpt segment of each listed recording: ONE matching segment per row
* (the search runs `LIKE` on the joined `recording.searchText`, so a page of
* hour-long recordings never pays for all of its segments). The rows whose
* query only matches across the junction of two segments fall back together in
* a single batched query, so a result never renders without an excerpt.
* hour-long recordings never pays for all of its segments). C219 passes the
* literal query plus every expanded theme term, so a theme result still gets
* the passage that surfaced it. The rows whose query only matches across the
* junction of two segments fall back together in a single batched query, so a
* result never renders without an excerpt.
*/
const loadMatchedSegments = async (
payload: Payload,
user: CampaignUser,
recordingIds: readonly number[],
q: string,
terms: readonly string[],
): Promise<Map<number, RecordingSegmentRecord[]>> => {
const byRecording = new Map<number, RecordingSegmentRecord[]>()
const normalized = normalizeForSearch(q)
if (!normalized || recordingIds.length === 0) return byRecording
const normalizedTerms = [
...new Set(terms.map((term) => normalizeForSearch(term)).filter((term) => term.length > 0)),
]
const textBranch = collapseListWhereOrBranches(
normalizedTerms.map((term) => ({ searchText: { like: term } })),
)
if (!textBranch || recordingIds.length === 0) return byRecording
await Promise.all(
recordingIds.map(async (recordingId) => {
const match = await payload.find({
collection: 'recordingSegment',
where: {
and: [{ recording: { equals: recordingId } }, { searchText: { like: normalized } }],
},
where: { and: [{ recording: { equals: recordingId } }, textBranch] },
depth: 0,
limit: 1,
pagination: false,
@ -172,25 +201,44 @@ export type RecordingsPageData = {
redirectHref?: string
totalDocs: number
totalPages: number
/**
* C219 — true when the actor asked for `mode=tema` but the expansion
* mechanism was unavailable; the page shows the discreet fallback notice.
*/
themeUnavailable: boolean
/** C219 — true when the expansion actually contributed terms. */
themeApplied: boolean
}
export const loadRecordingsPageData = async (
payload: Payload,
user: CampaignUser,
searchParams: Promise<RecordingListSearchParams> | RecordingListSearchParams,
expandTheme: ThemeSearchExpansionResolver = expandRecordingSearchTheme,
): Promise<RecordingsPageData> => {
const rawSearchParams = await searchParams
const canonicalUrl = resolveRecordingListUrl(rawSearchParams)
const state = canonicalUrl.state
// C219 — only expand for an actor who may read the catalog (the `find` below
// is still the final, fail-closed barrier) and only with a query.
const themeRequested = state.mode === 'tema' && Boolean(state.q)
let themeTerms: readonly string[] = []
let themeUnavailable = false
if (themeRequested && canReadCommunicationCatalog(user.role)) {
const expansion = await expandTheme(state.q ?? '')
if (expansion) themeTerms = expansion.terms
else themeUnavailable = true
}
const [result, filterOptions] = await Promise.all([
payload.find({
collection: 'recording',
depth: 0,
limit: recordingPageSize,
page: state.page,
sort: '-createdAt',
where: buildRecordingListWhere(state),
sort: recordingSortOrder(state),
where: buildRecordingListWhere(state, themeTerms),
select: recordingListSelect,
user,
overrideAccess: false,
@ -200,12 +248,13 @@ export const loadRecordingsPageData = async (
const resolvedUrl = resolveRecordingListUrl(rawSearchParams, result.totalPages)
const recordings = result.docs
const segmentsByRecording = state.q
const matchedTerms = [...(state.q ? [state.q] : []), ...themeTerms]
const segmentsByRecording = matchedTerms.length
? await loadMatchedSegments(
payload,
user,
recordings.map((recording) => recording.id),
state.q,
matchedTerms,
)
: new Map<number, RecordingSegmentRecord[]>()
@ -215,6 +264,7 @@ export const loadRecordingsPageData = async (
recording,
matchedSegments: segmentsByRecording.get(recording.id) ?? [],
query: state.q,
themeTerms,
people: state.people,
}),
),
@ -223,6 +273,8 @@ export const loadRecordingsPageData = async (
redirectHref: resolvedUrl.redirectHref ?? canonicalUrl.redirectHref,
totalDocs: result.totalDocs,
totalPages: result.totalPages,
themeUnavailable,
themeApplied: themeTerms.length > 0,
}
}

View file

@ -11,6 +11,7 @@ import {
} from '@/lib/recording'
import { recordingSpeakerDefaultLabel, speakerNameMatches } from '@/lib/recordingDiarization'
import { formatSpeechClock } from '@/lib/speechClock'
import type { SpeechTopic } from '@/lib/speechFacets'
import {
buildHighlightedExcerpt,
pickMatchingSegment,
@ -18,6 +19,7 @@ import {
type SpeechHighlightedExcerpt,
type SpeechHighlightPart,
} from '@/lib/speechHighlight'
import { speechTopicLabels } from '@/utilities/speech/speechListUrl'
/** The segment shape the recording detail transcript renders. */
export type RecordingSegmentRecord = {
@ -40,6 +42,8 @@ type RecordingBaseRecord = {
export type RecordingListRecord = RecordingBaseRecord & {
/** C200 — derived distinct labels; drives the card chip under a "Pessoa" filter. */
speakerNames?: string[] | null
/** C219 — classified topics; the card shows them as chips. */
topics?: SpeechTopic[] | null
}
export type RecordingDetailRecord = RecordingBaseRecord & {
@ -55,6 +59,8 @@ export type RecordingListItemViewModel = RecordingViewModel & {
excerpt: SpeechHighlightedExcerpt | null
/** Selected "Pessoa" labels that this recording indeed contains. */
matchedPersons: string[]
/** C219 — classified topics with their pt-BR labels (card chips). */
topics: { value: SpeechTopic; label: string }[]
/**
* Detail link that seeks the player to the matching segment when the search
* found one (`?t=`), carrying the term for transcript highlighting (`?q=`).
@ -148,6 +154,24 @@ const withSeek = (href: string, segment: RecordingSegmentRecord | undefined, que
return queryString ? `${href}?${queryString}` : href
}
/**
* C219 — the first expanded theme term that actually surfaced this recording:
* the passage that carries it, preferred for the excerpt so a theme result
* shows why it appeared. Returns undefined when no theme term applies.
*/
const pickThemeSegment = (
segments: readonly RecordingSegmentRecord[],
themeTerms: readonly string[],
): { term: string; segment: RecordingSegmentRecord } | undefined => {
for (const term of themeTerms) {
const trimmed = term.trim()
if (!trimmed) continue
const segment = pickMatchingSegment(segments, trimmed)
if (segment) return { term: trimmed, segment }
}
return undefined
}
/**
* List item of one recording. The excerpt only exists for a `ready` row and a
* search term; the status cards of the other three states carry their own copy
@ -157,20 +181,31 @@ export const toRecordingListItemViewModel = ({
recording,
matchedSegments,
query,
themeTerms = [],
people,
}: {
recording: RecordingListRecord
/** Segments that matched the query (or the row's first one as fallback). */
matchedSegments: readonly RecordingSegmentRecord[]
query?: string
/** C219 — expanded theme terms; empty in the literal search. */
themeTerms?: readonly string[]
/** C200 — the selected "Pessoa" labels of the list state. */
people?: readonly string[]
}): RecordingListItemViewModel => {
const base = toRecordingViewModel(recording)
const isReady = base.status === 'ready'
const q = query?.trim()
const segment = isReady && q ? pickMatchingSegment(matchedSegments, q) : undefined
const excerpt = segment ? buildHighlightedExcerpt(segment.text, q ?? '') : null
const themeMatch = isReady ? pickThemeSegment(matchedSegments, themeTerms) : undefined
const segment =
themeMatch?.segment ?? (isReady && q ? pickMatchingSegment(matchedSegments, q) : undefined)
// The theme passage highlights the whole phrase as one band (the per-term
// split would break it into pieces), mirroring the speech acervo (C192).
const excerpt = themeMatch
? buildHighlightedExcerpt(themeMatch.segment.text, themeMatch.term, { phrase: true })
: segment
? buildHighlightedExcerpt(segment.text, q ?? '')
: null
const speakerNames = recording.speakerNames ?? []
return {
@ -179,6 +214,7 @@ export const toRecordingListItemViewModel = ({
matchedPersons: (people ?? []).filter((person) =>
speakerNames.some((name) => speakerNameMatches(name, person)),
),
topics: (recording.topics ?? []).map((value) => ({ value, label: speechTopicLabels[value] })),
watchHref: withSeek(base.detailHref, segment, q),
}
}

View file

@ -13,7 +13,11 @@ import type { SpeechDurationBucket, SpeechListState } from '@/utilities/speech/s
const DURATION_MEDIA_MIN_SECONDS = 120
const DURATION_LONGA_MIN_SECONDS = 300
const durationBucketWhere = (bucket: SpeechDurationBucket): Where => {
/**
* The ONE duration-bucket predicate of the acervo: the recordings list (C219)
* reuses it so the buckets can never diverge between the two sources.
*/
export const durationBucketWhere = (bucket: SpeechDurationBucket): Where => {
switch (bucket) {
case 'curta':
return { durationSeconds: { less_than: DURATION_MEDIA_MIN_SECONDS } }

View file

@ -19,6 +19,8 @@ import {
firstValue,
normalizedText,
parseExhaustiveEnumParam,
parseMunicipalityValues,
parseYearValues,
resolveListUrl,
strictDecimalInteger,
type RawSearchParams,
@ -95,16 +97,6 @@ const speechScopeSet = new Set<string>(SPEECH_SCOPES.map(({ value }) => value))
const speechDurationSet = new Set<string>(SPEECH_DURATION_BUCKETS.map(({ value }) => value))
const MAX_PHASE_LENGTH = 80
const parseYearValues = (raw: string | string[] | undefined): number[] =>
allParamValues(raw)
.map((token) => strictDecimalInteger(token))
.filter((year): year is number => year !== undefined && year >= 1900 && year <= 2100)
const parseMunicipalityValues = (raw: string | string[] | undefined): number[] =>
allParamValues(raw)
.map((token) => strictDecimalInteger(token))
.filter((id): id is number => id !== undefined && id > 0)
/** Only `tema` is meaningful; anything else (and `termo`) means the default. */
const parseSpeechSearchMode = (raw: string | string[] | undefined): SpeechSearchMode | undefined =>
firstValue(raw) === 'tema' ? 'tema' : undefined

View file

@ -534,6 +534,13 @@ test.describe('communication vertical (C154/C162)', () => {
speakerKey?: string | null
}[]
speakerLabels?: { speakerKey: string; label: string }[]
/** C219 — parity facets. */
recordedAt?: string
durationSeconds?: number
topics?: SpeechTopic[]
scopes?: SpeechScope[]
classifiedBy?: 'gazetteer' | 'llm' | 'manual'
mentionedMunicipalities?: number[]
},
) => {
const bytes = Buffer.from(`recording-${input.marker}`)
@ -552,9 +559,18 @@ test.describe('communication vertical (C154/C162)', () => {
data: {
title: `Gravação ${input.marker}`,
status: input.status ?? 'ready',
recordedAt: '2026-09-01T00:00:00.000Z',
recordedAt: input.recordedAt ?? '2026-09-01T00:00:00.000Z',
media: media.id,
...(input.speakerLabels ? { speakerLabels: input.speakerLabels } : {}),
...(input.durationSeconds !== undefined
? { durationSeconds: input.durationSeconds }
: {}),
...(input.topics ? { topics: input.topics } : {}),
...(input.scopes ? { scopes: input.scopes } : {}),
...(input.classifiedBy ? { classifiedBy: input.classifiedBy } : {}),
...(input.mentionedMunicipalities
? { mentionedMunicipalities: input.mentionedMunicipalities }
: {}),
},
depth: 0,
})
@ -806,6 +822,135 @@ test.describe('communication vertical (C154/C162)', () => {
expect(html).not.toContain(`/campanha/comunicacao/acervo/gravacoes/${other.id}`)
})
test('the parity facets narrow, order by duration and render the filter bar (C219)', async ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('gravacaofacetas')
const otherMarker = `${marker}b`
const municipality = await campaign.fixtures.claimMunicipality()
const { recording: health } = await createRecording(campaign, {
marker,
recordedAt: '2025-04-02T00:00:00.000Z',
durationSeconds: 90,
topics: ['saude'],
scopes: ['bahia'],
classifiedBy: 'llm',
mentionedMunicipalities: [municipality.id],
segments: [
{ startSeconds: 12, endSeconds: 20, text: `A plenária discutiu a saúde ${marker}.` },
],
})
const { recording: culture } = await createRecording(campaign, {
marker: otherMarker,
recordedAt: '2023-08-02T00:00:00.000Z',
durationSeconds: 400,
topics: ['cultura'],
segments: [
{
startSeconds: 12,
endSeconds: 20,
text: `A plenária discutiu a cultura ${otherMarker}.`,
},
],
})
const { recording: noDuration } = await createRecording(campaign, {
marker: `${marker}c`,
segments: [{ startSeconds: 12, endSeconds: 20, text: `Sem duração ${marker}.` }],
})
const user = await campaign.fixtures.createCampaignUser('communicator')
const request = await campaignRequest(user, user.password)
// The bar renders the Câmara-parity facets, the mode selector and the sort.
const bar = rendered(
await (
await request.get(`/campanha/comunicacao/acervo?source=enviadas&q=${marker}`)
).text(),
)
for (const label of ['Ano', 'Tema', 'Alcance', 'Município citado', 'Duração', 'Pessoa']) {
expect(bar).toContain(label)
}
expect(bar).toContain('Termo exato')
expect(bar).toContain('Por tema')
expect(bar).toContain('Ordenar por')
expect(bar).toContain('Mais recentes')
// Year + topic + cited municipality + duration narrow to the health row.
// The params ride in the canonical order: a hand-built URL out of order
// canonicalizes through a redirect the HTTP request helper does not follow.
const narrowed = rendered(
await (
await request.get(
`/campanha/comunicacao/acervo?source=enviadas&q=${marker}` +
`&year=2025&topic=saude&municipality=${municipality.id}&duration=curta`,
)
).text(),
)
expect(narrowed).toContain(`/gravacoes/${health.id}?`)
expect(narrowed).not.toContain(`/gravacoes/${culture.id}?`)
expect(narrowed).not.toContain(`/gravacoes/${noDuration.id}?`)
// A contradictory combination is an honest empty state with the clear CTA.
const empty = rendered(
await (
await request.get(
`/campanha/comunicacao/acervo?source=enviadas&q=${marker}&year=2023&topic=saude`,
)
).text(),
)
expect(empty).toContain('Nenhuma gravação encontrada')
expect(empty).toContain('Limpar filtros')
// Duration orders list the measured rows only, longest first.
const longestFirst = rendered(
await (
await request.get(
`/campanha/comunicacao/acervo?source=enviadas&q=${marker}&sort=duracao_maior`,
)
).text(),
)
const cultureAt = longestFirst.indexOf(`/gravacoes/${culture.id}?`)
const healthAt = longestFirst.indexOf(`/gravacoes/${health.id}?`)
expect(cultureAt).toBeGreaterThan(-1)
expect(healthAt).toBeGreaterThan(cultureAt)
expect(longestFirst).not.toContain(`/gravacoes/${noDuration.id}?`)
expect(longestFirst).toContain('Gravações sem duração aparecem apenas em Mais recentes.')
})
test('theme mode degrades honestly on the recordings source too (C219)', async ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('gravacaotemadeg')
await createRecording(campaign, { marker })
const user = await campaign.fixtures.createCampaignUser('communicator')
const request = await campaignRequest(user, user.password)
// The e2e environment blanks DEEPSEEK_API_KEY (playwright.config.ts), so
// the expansion is unavailable and the page must fall back with the notice.
const themed = await request.get(
`/campanha/comunicacao/acervo?source=enviadas&q=${marker}&mode=tema`,
)
expect(themed.status()).toBe(200)
const themedHtml = rendered(await themed.text())
expect(themedHtml).toContain('data-testid="recording-theme-fallback"')
expect(themedHtml).toContain('A busca por tema está indisponível agora.')
expect(themedHtml).toContain('Resultados por termo exato')
expect(themedHtml).toContain('Tentar por tema novamente')
expect(themedHtml).toContain(marker)
expect(themedHtml).toContain('<mark')
// The default mode stays untouched: no mode param means no notice.
const exact = await request.get(`/campanha/comunicacao/acervo?source=enviadas&q=${marker}`)
expect(exact.status()).toBe(200)
expect(rendered(await exact.text())).not.toContain(
'A busca por tema está indisponível agora.',
)
})
test('labels a cluster through the JSON route and denies the non-acervo roles', async ({
campaign,
campaignRequest,

View file

@ -32,6 +32,7 @@ import {
labelRecordingSpeakerForActor,
retryRecordingForActor,
} from '@/app/(campaign)/campanha/actions/recording'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import { RECORDING_MAX_BYTES } from '@/lib/recording'
import {
RECORDING_FORBIDDEN_MESSAGE,
@ -41,6 +42,7 @@ import { normalizeForSearch } from '@/lib/speechSearch'
import type { CampaignUser, RecordingMedia } from '@/payload-types'
import config from '@/payload.config'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import { classifyRecordingFacets } from '@/utilities/recordings/recordingClassification'
import {
reapStaleRecording,
RECORDING_STALE_MS,
@ -100,6 +102,12 @@ const createRecording = async ({
segments = [],
speakerLabels,
withMedia = true,
recordedAt,
durationSeconds,
topics,
scopes,
classifiedBy,
mentionedMunicipalities,
}: {
status?: 'uploading' | 'processing' | 'ready' | 'failed'
title?: string
@ -111,6 +119,12 @@ const createRecording = async ({
}[]
speakerLabels?: { speakerKey: string; label: string }[]
withMedia?: boolean
recordedAt?: string
durationSeconds?: number
topics?: ('saude' | 'cultura' | 'educacao')[]
scopes?: ('bahia' | 'brasil' | 'internacional')[]
classifiedBy?: 'gazetteer' | 'llm' | 'manual'
mentionedMunicipalities?: number[]
} = {}) => {
const media = withMedia ? await createMedia(`${title}.mp4`) : null
const recording = await payload.create({
@ -120,6 +134,12 @@ const createRecording = async ({
status,
...(media ? { media: media.id } : {}),
...(speakerLabels ? { speakerLabels } : {}),
...(recordedAt ? { recordedAt } : {}),
...(durationSeconds !== undefined ? { durationSeconds } : {}),
...(topics ? { topics } : {}),
...(scopes ? { scopes } : {}),
...(classifiedBy ? { classifiedBy } : {}),
...(mentionedMunicipalities ? { mentionedMunicipalities } : {}),
},
overrideAccess: true,
})
@ -431,6 +451,201 @@ describe('uploaded recordings (C199)', () => {
expect(segments.docs[0]?.searchText).toBe('bom dia a todas')
})
it('classifies the transcript with the injected classifier and derives the year', async () => {
const fixtures = campaignFixtures()
const municipality = await fixtures.getMunicipality()
const catalogEntry = getMunicipalityCatalogEntry(municipality.slug)
expect(catalogEntry).toBeDefined()
const { recording } = await createRecording({
status: 'processing',
title: 'Plenária sobre saúde',
})
await payload.update({
collection: 'recording',
id: recording.id,
data: { recordedAt: '2024-03-10T12:00:00.000Z' },
overrideAccess: true,
})
const restore = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
try {
await runRecordingJob(
payload,
recording.id,
async () => ({
ok: true,
segments: [{ start: 1, end: 4, text: 'Falamos sobre a saúde no município' }],
durationSeconds: 120,
}),
null,
async (input) => {
expect(input.transcript).toContain('saúde no município')
return {
facets: {
topics: ['saude'],
scopes: ['bahia'],
municipalities: [catalogEntry!],
people: [],
programs: [],
projects: [],
classifiedBy: 'llm',
},
llm: { used: true, totalTokens: 42, estimatedCostUsd: 0.001, error: null },
}
},
)
} finally {
restore()
}
const updated = await payload.findByID({
collection: 'recording',
id: recording.id,
depth: 0,
overrideAccess: true,
})
expect(updated.status).toBe('ready')
expect(updated.year).toBe(2024)
expect(updated.topics).toEqual(['saude'])
expect(updated.scopes).toEqual(['bahia'])
expect(updated.classifiedBy).toBe('llm')
expect(updated.mentionedMunicipalities).toEqual([municipality.id])
})
it('never overwrites a manual facet curation with the job result', async () => {
const { recording } = await createRecording({
status: 'processing',
title: 'Curadoria manual',
})
await payload.update({
collection: 'recording',
id: recording.id,
data: { topics: ['cultura'], scopes: ['brasil'], classifiedBy: 'manual' },
overrideAccess: true,
})
const restore = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
try {
await runRecordingJob(
payload,
recording.id,
async () => ({
ok: true,
segments: [{ start: 1, end: 4, text: 'Falamos sobre saúde' }],
durationSeconds: 120,
}),
null,
async () => ({
facets: {
topics: ['saude'],
scopes: ['bahia'],
municipalities: [],
people: [],
programs: [],
projects: [],
classifiedBy: 'llm',
},
llm: { used: true, totalTokens: 10, estimatedCostUsd: 0, error: null },
}),
)
} finally {
restore()
}
const updated = await payload.findByID({
collection: 'recording',
id: recording.id,
depth: 0,
overrideAccess: true,
})
expect(updated.status).toBe('ready')
expect(updated.topics).toEqual(['cultura'])
expect(updated.scopes).toEqual(['brasil'])
expect(updated.classifiedBy).toBe('manual')
})
it('lets an authenticated actor overwrite a manual curation (the escape hatch)', async () => {
const { recording } = await createRecording({
status: 'ready',
title: 'Curadoria corrigida no admin',
topics: ['cultura'],
scopes: ['brasil'],
classifiedBy: 'manual',
})
const updated = await payload.update({
collection: 'recording',
id: recording.id,
data: { topics: ['saude'], scopes: ['bahia'], classifiedBy: 'llm' },
user: communicator,
overrideAccess: false,
})
expect(updated.topics).toEqual(['saude'])
expect(updated.scopes).toEqual(['bahia'])
expect(updated.classifiedBy).toBe('llm')
})
it('keeps the transcript and claims no provenance when the classifier throws', async () => {
const { recording } = await createRecording({
status: 'processing',
title: 'Classificador fora do ar',
})
const restore = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
try {
await runRecordingJob(
payload,
recording.id,
async () => ({
ok: true,
segments: [{ start: 1, end: 4, text: 'Bom dia a todas' }],
durationSeconds: 120,
}),
null,
async () => {
throw new Error('classificador fora do ar')
},
)
} finally {
restore()
}
const updated = await payload.findByID({
collection: 'recording',
id: recording.id,
depth: 0,
overrideAccess: true,
})
expect(updated.status).toBe('ready')
// The fake ffmpeg emits two chunks and the injected transcriber repeats the
// answer for both, exactly like the end-to-end job test above.
expect(updated.searchText).toBe('bom dia a todas bom dia a todas')
expect(updated.classifiedBy).toBeNull()
expect(updated.topics ?? []).toEqual([])
})
it('resolves the gazetteer municipality without the LLM key (degraded provenance)', async () => {
const fixtures = campaignFixtures()
const municipality = await fixtures.getMunicipality()
const catalogEntry = getMunicipalityCatalogEntry(municipality.slug)
expect(catalogEntry).toBeDefined()
const restore = withEnv('DEEPINFRA_API_KEY', undefined)
try {
const classification = await classifyRecordingFacets({
payload,
transcript: `Falamos em ${catalogEntry!.city} sobre saúde e educação`,
})
expect(classification?.classifiedBy).toBe('gazetteer')
expect(classification?.topics).toEqual(expect.arrayContaining(['saude', 'educacao']))
expect(classification?.mentionedMunicipalities).toContain(municipality.id)
} finally {
restore()
}
})
it('marks a provider failure as failed, preserves the file and retries', async () => {
const { recording } = await createRecording({ status: 'processing', title: 'Debate' })
@ -604,6 +819,123 @@ describe('uploaded recordings (C199)', () => {
expect(options.people).toContain(`Dep. ${marker}`)
})
it('filters by the C219 facets and lists their options', async () => {
const fixtures = campaignFixtures()
const municipality = await fixtures.getMunicipality()
const marker = `facetas${Date.now().toString(36)}`
const { recording: health } = await createRecording({
title: `Plenária de saúde ${marker}`,
recordedAt: '2025-05-04T10:00:00.000Z',
durationSeconds: 90,
topics: ['saude'],
scopes: ['bahia'],
classifiedBy: 'llm',
mentionedMunicipalities: [municipality.id],
segments: [{ startSeconds: 0, endSeconds: 5, text: `Saúde em pauta ${marker}` }],
})
const { recording: culture } = await createRecording({
title: `Debate de cultura ${marker}`,
recordedAt: '2023-01-01T10:00:00.000Z',
durationSeconds: 400,
topics: ['cultura'],
segments: [{ startSeconds: 0, endSeconds: 5, text: `Cultura em pauta ${marker}` }],
})
const { recording: noDuration } = await createRecording({
title: `Material sem duração ${marker}`,
segments: [{ startSeconds: 0, endSeconds: 5, text: `Ainda processando ${marker}` }],
})
const load = (extra: Record<string, string>) =>
loadRecordingsPageData(payload, communicator, {
source: 'enviadas',
q: marker,
...extra,
})
expect((await load({ year: '2025' })).rows.map((row) => row.id)).toEqual([health.id])
expect((await load({ topic: 'saude' })).rows.map((row) => row.id)).toEqual([health.id])
expect((await load({ scope: 'bahia' })).rows.map((row) => row.id)).toEqual([health.id])
expect(
(await load({ municipality: String(municipality.id) })).rows.map((row) => row.id),
).toEqual([health.id])
expect((await load({ duration: 'curta' })).rows.map((row) => row.id)).toEqual([health.id])
expect((await load({ duration: 'longa' })).rows.map((row) => row.id)).toEqual([culture.id])
expect((await load({ duration: 'sem_duracao' })).rows.map((row) => row.id)).toEqual([
noDuration.id,
])
const options = await loadRecordingFilterOptions(payload, communicator)
expect(options.years).toContain(2025)
expect(options.municipalities.map((option) => option.value)).toContain(String(municipality.id))
})
it('orders by duration only over rows with a measured duration', async () => {
const marker = `ordem${Date.now().toString(36)}`
const { recording: short } = await createRecording({
title: `Curta ${marker}`,
durationSeconds: 60,
segments: [{ startSeconds: 0, endSeconds: 5, text: `Trecho ${marker}` }],
})
const { recording: long } = await createRecording({
title: `Longa ${marker}`,
durationSeconds: 900,
segments: [{ startSeconds: 0, endSeconds: 5, text: `Trecho ${marker}` }],
})
await createRecording({
title: `Sem duração ${marker}`,
segments: [{ startSeconds: 0, endSeconds: 5, text: `Trecho ${marker}` }],
})
const load = (sort?: string) =>
loadRecordingsPageData(payload, communicator, {
source: 'enviadas',
q: marker,
...(sort ? { sort } : {}),
})
expect((await load('duracao_maior')).rows.map((row) => row.id)).toEqual([long.id, short.id])
expect((await load('duracao_menor')).rows.map((row) => row.id)).toEqual([short.id, long.id])
// The default order keeps every row (the gate only applies to duration orders).
expect((await load()).rows.length).toBe(3)
})
it('searches by theme with the injected expansion and degrades honestly', async () => {
const marker = `tema${Date.now().toString(36)}`
const { recording } = await createRecording({
title: `Merenda ${marker}`,
segments: [
{ startSeconds: 0, endSeconds: 6, text: `Falamos de alimentação escolar ${marker}.` },
],
})
const expanded = await loadRecordingsPageData(
payload,
communicator,
{ source: 'enviadas', q: 'merenda escolar', mode: 'tema' },
async (theme) => {
expect(theme).toBe('merenda escolar')
return { terms: ['alimentacao escolar'] }
},
)
expect(expanded.themeApplied).toBe(true)
expect(expanded.themeUnavailable).toBe(false)
const row = expanded.rows.find((item) => item.id === recording.id)
expect(row).toBeDefined()
expect(row?.excerpt?.parts.some((part) => part.highlighted)).toBe(true)
const degraded = await loadRecordingsPageData(
payload,
communicator,
{ source: 'enviadas', q: 'merenda escolar', mode: 'tema' },
async () => null,
)
expect(degraded.themeApplied).toBe(false)
expect(degraded.themeUnavailable).toBe(true)
// Honest fallback: the literal phrase does not occur, so the row is out.
expect(degraded.rows.map((item) => item.id)).not.toContain(recording.id)
})
it('labels one cluster through the action and rebuilds the facet names', async () => {
const marker = `falante${Date.now().toString(36)}`
const { recording } = await createRecording({

View file

@ -75,6 +75,10 @@ describe('E2E_AFFECTED_MANIFEST (OPS5)', () => {
// route and the vote share sheet are new contracts over the C211
// collection (curated only).
'frontendConteudos',
// C219 — deliberate: the recordings acervo gained its own search/facet
// contract and the migration makes every PR of this delivery high-risk
// (curated only).
'campaignSpeechAcervo',
])
const onDisk = new Set(specNamesOnDisk())
for (const spec of E2E_CURATED_SPECS) {

View file

@ -12,6 +12,7 @@ vi.mock('@ai-sdk/deepseek', () => ({ deepSeek: deepSeekMock }))
import {
expandContentPieceSearchTheme,
expandRecordingSearchTheme,
expandSearchTheme,
expandSpeechSearchTheme,
} from '@/utilities/ai/expandSpeechSearchTheme'
@ -98,4 +99,15 @@ describe('expandSearchTheme (S28 — corpus)', () => {
await expect(expandSearchTheme('defesa do SUS', 'contentPiece')).resolves.toBeNull()
expect(generateObjectMock).toHaveBeenCalledTimes(1)
})
it('uses the recordings prompt for the enviadas corpus (C219)', async () => {
generateObjectMock.mockResolvedValue({ object: { terms: ['merenda escolar'] } })
await expandRecordingSearchTheme('merenda')
const call = generateObjectMock.mock.calls[0]![0] as { system: string }
expect(call.system).toContain('gravações enviadas pela equipe')
expect(call.system).not.toContain('acervo de discursos')
expect(call.system).not.toContain('Central de Conteúdos')
})
})

View file

@ -41,4 +41,80 @@ describe('recording list filters (C199/C200)', () => {
],
})
})
it('ANDs the C219 facets (year, topic, scope, cited municipality)', () => {
expect(
buildRecordingListWhere({
source: 'enviadas',
page: 1,
years: [2026],
topics: ['saude'],
scopes: ['bahia'],
municipalities: [42],
}),
).toEqual({
and: [
{ year: { in: [2026] } },
{ topics: { in: ['saude'] } },
{ scopes: { in: ['bahia'] } },
{ mentionedMunicipalities: { in: [42] } },
],
})
})
it('maps each duration bucket to the shared Câmara predicate', () => {
expect(buildRecordingListWhere({ source: 'enviadas', page: 1, durations: ['curta'] })).toEqual({
durationSeconds: { less_than: 120 },
})
expect(buildRecordingListWhere({ source: 'enviadas', page: 1, durations: ['media'] })).toEqual({
durationSeconds: { greater_than_equal: 120, less_than: 300 },
})
expect(buildRecordingListWhere({ source: 'enviadas', page: 1, durations: ['longa'] })).toEqual({
durationSeconds: { greater_than_equal: 300 },
})
expect(
buildRecordingListWhere({ source: 'enviadas', page: 1, durations: ['sem_duracao'] }),
).toEqual({ durationSeconds: { exists: false } })
// Two buckets OR together inside the AND.
expect(
buildRecordingListWhere({ source: 'enviadas', page: 1, durations: ['curta', 'longa'] }),
).toEqual({
or: [
{ durationSeconds: { less_than: 120 } },
{ durationSeconds: { greater_than_equal: 300 } },
],
})
})
it('ORs the literal query with the expanded theme terms, deduped', () => {
expect(
buildRecordingListWhere({ source: 'enviadas', page: 1, q: 'saúde' }, [
'acesso universal à saúde',
'saúde',
]),
).toEqual({
or: [{ searchText: { like: 'saude' } }, { searchText: { like: 'acesso universal a saude' } }],
})
})
it('gates rows without a measured duration out of the duration orders', () => {
expect(buildRecordingListWhere({ source: 'enviadas', page: 1, sort: 'duracao_maior' })).toEqual(
{ durationSeconds: { exists: true } },
)
expect(
buildRecordingListWhere({
source: 'enviadas',
page: 1,
sort: 'duracao_menor',
q: 'merenda',
}),
).toEqual({
and: [{ durationSeconds: { exists: true } }, { searchText: { like: 'merenda' } }],
})
// The default order carries no gate.
expect(buildRecordingListWhere({ source: 'enviadas', page: 1 })).toEqual({})
})
})

View file

@ -2,16 +2,22 @@
import { describe, expect, it } from 'vitest'
import { SPEECH_TOPICS } from '@/lib/speechFacets'
import {
buildAcervoSourceHref,
buildRecordingFiltersKey,
buildRecordingListHref,
parseAcervoSource,
parseRecordingListParams,
parseRecordingPeople,
recordingHasActiveFilters,
recordingSortIsDuration,
recordingSortOrder,
removeRecordingPerson,
resolveRecordingListUrl,
toggleRecordingPerson,
} from '@/utilities/recordings/recordingListUrl'
import { SPEECH_DURATION_BUCKETS } from '@/utilities/speech/speechListUrl'
describe('recording list URL (C199)', () => {
it('detects the enviadas source and defaults everything else to the Câmara', () => {
@ -44,8 +50,8 @@ describe('recording list URL (C199)', () => {
const polluted = resolveRecordingListUrl({
source: 'enviadas',
q: 'merenda',
year: '2026',
topic: 'saude',
phase: 'Ordem do Dia',
foo: 'bar',
})
expect(polluted.redirectHref).toBe('/campanha/comunicacao/acervo?source=enviadas&q=merenda')
@ -130,4 +136,128 @@ describe('recording list URL (C199)', () => {
people: undefined,
})
})
it('parses the C219 parity facets and serializes them in canonical order', () => {
const canonical = resolveRecordingListUrl({
source: 'enviadas',
q: 'merenda',
mode: 'tema',
year: ['2026', '2024'],
topic: 'saude',
scope: 'bahia',
municipality: '42',
duration: 'curta',
sort: 'duracao_maior',
person: 'Solla',
page: '2',
})
expect(canonical.state).toEqual({
source: 'enviadas',
page: 2,
q: 'merenda',
mode: 'tema',
years: [2026, 2024],
topics: ['saude'],
scopes: ['bahia'],
municipalities: [42],
durations: ['curta'],
sort: 'duracao_maior',
people: ['Solla'],
})
expect(canonical.href).toBe(
'/campanha/comunicacao/acervo?source=enviadas&q=merenda&mode=tema&year=2026&year=2024' +
'&topic=saude&scope=bahia&municipality=42&duration=curta&sort=duracao_maior&person=Solla&page=2',
)
expect(canonical.redirectHref).toBeUndefined()
})
it('keeps the C199 deep link byte-identical (no new param is serialized)', () => {
const canonical = resolveRecordingListUrl({
source: 'enviadas',
q: 'merenda',
person: ['Solla', 'Presidente'],
page: '2',
})
expect(canonical.href).toBe(
'/campanha/comunicacao/acervo?source=enviadas&q=merenda&person=Solla&person=Presidente&page=2',
)
expect(canonical.redirectHref).toBeUndefined()
})
it('canonicalizes the default sort and a theme without a query away', () => {
const defaultSort = resolveRecordingListUrl({ source: 'enviadas', sort: 'recentes' })
expect(defaultSort.state.sort).toBeUndefined()
expect(defaultSort.href).toBe('/campanha/comunicacao/acervo?source=enviadas')
expect(defaultSort.redirectHref).toBe(defaultSort.href)
// The parsed state keeps `mode`, but the canonical href drops it and the
// redirect lands on the URL without the meaningless param (same as Câmara).
const themeWithoutQuery = resolveRecordingListUrl({ source: 'enviadas', mode: 'tema' })
expect(themeWithoutQuery.state.mode).toBe('tema')
expect(themeWithoutQuery.href).toBe('/campanha/comunicacao/acervo?source=enviadas')
expect(themeWithoutQuery.redirectHref).toBe(themeWithoutQuery.href)
// An unknown sort value can never widen the contract: it canonicalizes away.
const unknownSort = resolveRecordingListUrl({ source: 'enviadas', sort: 'aleatorio' })
expect(unknownSort.state.sort).toBeUndefined()
expect(unknownSort.redirectHref).toBe(unknownSort.href)
})
it('collapses an exhaustive enum selection to no filter (same rule as the Câmara)', () => {
const allTopics = resolveRecordingListUrl({
source: 'enviadas',
topic: SPEECH_TOPICS.map(({ value }) => value),
})
expect(allTopics.state.topics).toBeUndefined()
expect(allTopics.href).toBe('/campanha/comunicacao/acervo?source=enviadas')
const allDurations = resolveRecordingListUrl({
source: 'enviadas',
duration: SPEECH_DURATION_BUCKETS.map(({ value }) => value),
})
expect(allDurations.state.durations).toBeUndefined()
expect(allDurations.href).toBe('/campanha/comunicacao/acervo?source=enviadas')
})
it('drops structurally invalid year/municipality values on canonicalization', () => {
const canonical = resolveRecordingListUrl({
source: 'enviadas',
year: ['1800', 'abc', '2026'],
municipality: ['-3', '0', '7'],
})
expect(canonical.state.years).toEqual([2026])
expect(canonical.state.municipalities).toEqual([7])
expect(canonical.href).toBe(
'/campanha/comunicacao/acervo?source=enviadas&year=2026&municipality=7',
)
})
it('exposes the canonical filter key and the Payload sort of each order', () => {
expect(
buildRecordingFiltersKey({ source: 'enviadas', page: 1, q: 'merenda', topics: ['saude'] }),
).toBe('source=enviadas&q=merenda&topic=saude')
expect(recordingSortOrder({ source: 'enviadas', page: 1 })).toBe('-createdAt')
expect(recordingSortOrder({ source: 'enviadas', page: 1, sort: 'duracao_maior' })).toBe(
'-durationSeconds',
)
expect(recordingSortOrder({ source: 'enviadas', page: 1, sort: 'duracao_menor' })).toBe(
'durationSeconds',
)
expect(recordingSortIsDuration({ source: 'enviadas', page: 1 })).toBe(false)
expect(recordingSortIsDuration({ source: 'enviadas', page: 1, sort: 'duracao_maior' })).toBe(
true,
)
})
it('tells an unfiltered page (including pagination) from a narrowed one', () => {
expect(recordingHasActiveFilters({ source: 'enviadas', page: 1 })).toBe(false)
expect(recordingHasActiveFilters({ source: 'enviadas', page: 4 })).toBe(false)
expect(recordingHasActiveFilters({ source: 'enviadas', page: 1, sort: 'duracao_maior' })).toBe(
false,
)
expect(recordingHasActiveFilters({ source: 'enviadas', page: 1, q: 'merenda' })).toBe(true)
expect(recordingHasActiveFilters({ source: 'enviadas', page: 1, years: [2026] })).toBe(true)
expect(recordingHasActiveFilters({ source: 'enviadas', page: 1, people: ['Solla'] })).toBe(true)
})
})

View file

@ -0,0 +1,197 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
applyRecordingOmniboxSuggestion,
applyRecordingSearchMode,
buildRecordingOmniboxChips,
buildRecordingOmniboxSuggestionSeeds,
clearRecordingOmnibox,
filterRecordingOmniboxSuggestions,
removeRecordingOmniboxChip,
} from '@/utilities/recordings/recordingOmnibox'
const labels = new Map<number, string>([[42, 'Feira de Santana']])
describe('recordings omnibox (C219)', () => {
it('builds one chip per active filter with the facet labels', () => {
const chips = buildRecordingOmniboxChips({
state: {
source: 'enviadas',
page: 1,
q: 'merenda',
years: [2026],
topics: ['saude'],
scopes: ['bahia'],
municipalities: [42],
durations: ['curta'],
people: ['Dep. Jorge Solla'],
},
municipalityLabelsById: labels,
})
expect(chips.map((chip) => chip.label)).toEqual([
'Busca: merenda',
'Ano: 2026',
'Tema: Saúde',
'Alcance: Bahia',
'Município citado: Feira de Santana',
'Duração: Até 2 min',
'Pessoa: Dep. Jorge Solla',
])
})
it('falls back to the id when the municipality label is not loaded', () => {
const chips = buildRecordingOmniboxChips({
state: { source: 'enviadas', page: 1, municipalities: [99] },
municipalityLabelsById: labels,
})
expect(chips).toEqual([{ id: 'municipality:99', label: 'Município citado: Município #99' }])
})
it('applies the query suggestion resetting the page and keeping the source', () => {
const action = applyRecordingOmniboxSuggestion({
state: { source: 'enviadas', page: 4 },
suggestionId: 'q:merenda escolar',
})
expect(action).toEqual({
kind: 'url',
state: { source: 'enviadas', page: 1, q: 'merenda escolar' },
})
})
it('toggles facets and people (person dedupes case-insensitively)', () => {
const base = { source: 'enviadas' as const, page: 3 }
expect(applyRecordingOmniboxSuggestion({ state: base, suggestionId: 'topic:saude' })).toEqual({
kind: 'url',
state: { source: 'enviadas', page: 1, topics: ['saude'] },
})
expect(applyRecordingOmniboxSuggestion({ state: base, suggestionId: 'year:2026' })).toEqual({
kind: 'url',
state: { source: 'enviadas', page: 1, years: [2026] },
})
expect(
applyRecordingOmniboxSuggestion({ state: base, suggestionId: 'municipality:42' }),
).toEqual({ kind: 'url', state: { source: 'enviadas', page: 1, municipalities: [42] } })
const withPerson = applyRecordingOmniboxSuggestion({
state: base,
suggestionId: 'person:Dep. Jorge Solla',
})
expect(withPerson).toEqual({
kind: 'url',
state: { source: 'enviadas', page: 1, people: ['Dep. Jorge Solla'] },
})
const removedPerson = applyRecordingOmniboxSuggestion({
state: { source: 'enviadas', page: 1, people: ['Dep. Jorge Solla'] },
suggestionId: 'person:dep. jorge solla',
})
expect(removedPerson).toEqual({
kind: 'url',
state: { source: 'enviadas', page: 1, people: undefined },
})
// An invalid numeric id never mutates the state.
expect(applyRecordingOmniboxSuggestion({ state: base, suggestionId: 'year:abc' })).toEqual({
kind: 'url',
state: base,
})
})
it('removes chips (dropping the query also drops the theme mode)', () => {
expect(
removeRecordingOmniboxChip({
state: { source: 'enviadas', page: 2, q: 'merenda', mode: 'tema' },
chipId: 'q',
}),
).toEqual({
kind: 'url',
state: { source: 'enviadas', page: 1, q: undefined, mode: undefined },
})
expect(
removeRecordingOmniboxChip({
state: { source: 'enviadas', page: 2, topics: ['saude', 'cultura'] },
chipId: 'topic:saude',
}),
).toEqual({ kind: 'url', state: { source: 'enviadas', page: 1, topics: ['cultura'] } })
expect(
removeRecordingOmniboxChip({
state: { source: 'enviadas', page: 2, people: ['Solla'] },
chipId: 'person:Solla',
}),
).toEqual({ kind: 'url', state: { source: 'enviadas', page: 1, people: undefined } })
})
it('keeps a person label with a colon intact through the chip round trip', () => {
const state = { source: 'enviadas' as const, page: 1, people: ['Ana: assessora'] }
expect(buildRecordingOmniboxChips({ state, municipalityLabelsById: labels })).toEqual([
{ id: 'person:Ana: assessora', label: 'Pessoa: Ana: assessora' },
])
expect(removeRecordingOmniboxChip({ state, chipId: 'person:Ana: assessora' })).toEqual({
kind: 'url',
state: { source: 'enviadas', page: 1, people: undefined },
})
})
it('toggles the theme mode only with a query and clears back to the source', () => {
expect(
applyRecordingSearchMode({
state: { source: 'enviadas', page: 2, q: 'merenda' },
mode: 'tema',
}),
).toEqual({
kind: 'url',
state: { source: 'enviadas', page: 1, q: 'merenda', mode: 'tema' },
})
expect(
applyRecordingSearchMode({ state: { source: 'enviadas', page: 1 }, mode: 'tema' }),
).toEqual({ kind: 'url', state: { source: 'enviadas', page: 1 } })
expect(
applyRecordingSearchMode({
state: { source: 'enviadas', page: 1, q: 'merenda', mode: 'tema' },
mode: 'termo',
}),
).toEqual({ kind: 'url', state: { source: 'enviadas', page: 1, q: 'merenda' } })
expect(
clearRecordingOmnibox({ source: 'enviadas', page: 3, q: 'merenda', topics: ['saude'] }),
).toEqual({ kind: 'clear', state: { source: 'enviadas', page: 1 } })
})
it('seeds the taxonomy up front and the data-driven options by keyword', () => {
const seeds = buildRecordingOmniboxSuggestionSeeds({
years: [2026],
municipalityOptions: [{ value: '42', label: 'Feira de Santana' }],
personOptions: ['Dep. Jorge Solla'],
})
const emptyQuery = filterRecordingOmniboxSuggestions(seeds, '')
expect(emptyQuery.some((suggestion) => suggestion.id === 'topic:saude')).toBe(true)
expect(emptyQuery.some((suggestion) => suggestion.id === 'scope:bahia')).toBe(true)
expect(emptyQuery.some((suggestion) => suggestion.id === 'duration:curta')).toBe(true)
// Years, municipalities and people are searchable, not shown up front.
expect(emptyQuery.some((suggestion) => suggestion.id === 'year:2026')).toBe(false)
expect(emptyQuery.some((suggestion) => suggestion.id === 'person:Dep. Jorge Solla')).toBe(false)
// The typed query itself is always the first suggestion; the facet seed
// follows it when it matches.
expect(filterRecordingOmniboxSuggestions(seeds, 'feira').map((s) => s.id)).toContain(
'municipality:42',
)
expect(filterRecordingOmniboxSuggestions(seeds, '2026').map((s) => s.id)).toContain('year:2026')
expect(filterRecordingOmniboxSuggestions(seeds, 'falante').map((s) => s.id)).toContain(
'person:Dep. Jorge Solla',
)
expect(filterRecordingOmniboxSuggestions(seeds, 'municipio').map((s) => s.id)).toContain(
'municipality:42',
)
})
})