mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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
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:
parent
8f3118312a
commit
38e6742673
39 changed files with 21601 additions and 287 deletions
1
docs/changelog/2026-09-24-c219.md
Normal file
1
docs/changelog/2026-09-24-c219.md
Normal 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`.
|
||||
209
docs/plans/acervo-paridade-busca-filtros-impl.md
Normal file
209
docs/plans/acervo-paridade-busca-filtros-impl.md
Normal 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.
|
||||
36
docs/plans/omnibox-limpar-mobile.md
Normal file
36
docs/plans/omnibox-limpar-mobile.md
Normal 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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
162
scripts/backfill-recording-facets.mjs
Normal file
162
scripts/backfill-recording-facets.mjs
Normal 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)
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
296
src/components/campaign/recording/RecordingAcervoFilters.tsx
Normal file
296
src/components/campaign/recording/RecordingAcervoFilters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
51
src/components/campaign/recording/RecordingSortSelect.tsx
Normal file
51
src/components/campaign/recording/RecordingSortSelect.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
83
src/components/campaign/shared/CampaignSearchModeControl.tsx
Normal file
83
src/components/campaign/shared/CampaignSearchModeControl.tsx
Normal 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>
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
18696
src/migrations/20260924_040210_add_recording_facets.json
Normal file
18696
src/migrations/20260924_040210_add_recording_facets.json
Normal file
File diff suppressed because it is too large
Load diff
76
src/migrations/20260924_040210_add_recording_facets.ts
Normal file
76
src/migrations/20260924_040210_add_recording_facets.ts
Normal 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";`)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
97
src/utilities/recordings/recordingClassification.ts
Normal file
97
src/utilities/recordings/recordingClassification.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 } })),
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
286
src/utilities/recordings/recordingOmnibox.ts
Normal file
286
src/utilities/recordings/recordingOmnibox.ts
Normal 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 },
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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({})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
197
tests/unit/recordingOmnibox.unit.spec.ts
Normal file
197
tests/unit/recordingOmnibox.unit.spec.ts
Normal 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue