From 42f2f0ecaae95687adab43c6cfec0129b7ddef6d Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Wed, 16 Sep 2026 20:00:56 -0300 Subject: [PATCH] =?UTF-8?q?fix(C180):=20select=20enxuto=20dos=20loaders,?= =?UTF-8?q?=20teto=20do=20id=20in=20da=20origem=20e=20paridade=20busca?= =?UTF-8?q?=E2=86=94VM=20(Closes=20#1097)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/2026-09-16-c180.md | 1 + .../acervo-cortes-divida-pos-c174-impl.md | 99 +++++++++++++++++++ .../speech/SpeechCutsForSpeechSection.tsx | 4 +- .../campaign/speech/SpeechNestedCuts.tsx | 6 +- src/lib/speechCut.ts | 77 ++++++++++++++- src/lib/speechSearch.ts | 25 +++++ src/utilities/speech/speechCutPageData.ts | 61 ++++++++---- src/utilities/speech/speechListFilters.ts | 7 +- src/utilities/speech/speechViewModels.ts | 22 +---- tests/int/speechCut.int.spec.ts | 27 ++++- tests/unit/speechCut.unit.spec.ts | 65 ++++++++++++ tests/unit/speechSearch.unit.spec.ts | 28 +++++- 12 files changed, 373 insertions(+), 49 deletions(-) create mode 100644 docs/changelog/2026-09-16-c180.md create mode 100644 docs/plans/acervo-cortes-divida-pos-c174-impl.md diff --git a/docs/changelog/2026-09-16-c180.md b/docs/changelog/2026-09-16-c180.md new file mode 100644 index 00000000..3c251c2c --- /dev/null +++ b/docs/changelog/2026-09-16-c180.md @@ -0,0 +1 @@ +**C180 — Acervo cortes: dívida pós-C174 — select enxuto, teto do `id in` e paridade busca↔VM (2026-09-16):** três achados do `/simplify` do C174 atacados em `src/utilities/speech/`, sem mudança visual. (1) **Select enxuto + DTO estreito:** `loadSpeechCutsForSpeech`/`loadSpeechCutsForSpeeches`/`loadSpeechCutOriginSpeechIds` deixam de trazer a linha inteira do corte; novo `SpeechCutSummaryViewModel` (`lib/speechCut.ts`) entrega só `id`/`status`/`title`/`description`/`durationSeconds` — o que "Cortes desta fala" e o aninhamento da busca renderizam — reusando o fallback de duração e a normalização `status` desconhecido→`failed`, e o `select` espelha os campos do mapper. (2) **Predicado único busca↔VM (bug corrigido):** `speechMatchesSearchQuery` puro em `lib/speechSearch.ts` passa a ser o espelho do ramo textual do `where`; o `keywords` do VM vira case-insensitive como o ILIKE `%q%` do Postgres, então uma fala cujo keyword casa em caixa diferente deixa de ser marcada como "Fala de origem" indevidamente. (3) **Teto do `id in` da origem:** `SPEECH_CUT_ORIGIN_SPEECH_ID_LIMIT = 200` + `originSpeechIdsOfCuts` (dedupe + cap puro) limitam a query de origem e o `id in` do acervo, com degradação honesta (menos resultados origin-only, nunca resultado errado); gatilho de revisão ~5–10 mil cortes (C168). Sem collection, Consent ou migration; loaders seguem `user` + `overrideAccess:false`; `/corte/`, o acervo depth-1 e o dialog/player intocados. Testes: unit de `toSpeechCutSummaryViewModel`, `originSpeechIdsOfCuts` (dedupe/cap/`limit: 0`) e `speechMatchesSearchQuery` (acento/caixa); int do loader com chaves exatas, do keyword case-insensitive e da origem opção B; e2e curado `campaignSpeechAcervo` + `campaignSpeechCut` verde. diff --git a/docs/plans/acervo-cortes-divida-pos-c174-impl.md b/docs/plans/acervo-cortes-divida-pos-c174-impl.md new file mode 100644 index 00000000..f7d7df5d --- /dev/null +++ b/docs/plans/acervo-cortes-divida-pos-c174-impl.md @@ -0,0 +1,99 @@ +# Impl: Acervo cortes — dívida pós-C174: select enxuto dos loaders, teto do `id in` da origem e paridade busca↔VM + +Status: aprovado +Atualizado em: 2026-09-16 +Issue: #1097 +Intenção: docs/plans/acervo-cortes-divida-pos-c174.md +Appetite restante: herdado (~0,5–1 dia) + +## Leitura da intenção + +- **Outcome:** cortar o custo de query dos três loaders de corte e o risco de drift entre a busca (SQL) e o VM, sem mudar comportamento visível. +- **O que NÃO negociar:** nenhuma superfície visual nova; `lib/` nunca importa `utilities/`; sem módulo gêmeo; sem migration; `loadSpeechCutAcervoPageData` (depth 1), `loadSpeechCutDetailPageData`/`findSpeechCutForActor` e `/corte/` intocados. +- **O que reavaliar (surpresas do explorer):** + - `SpeechNestedCuts` com `showDescription` renderiza `description` (`SpeechNestedCuts.tsx:62-64`) → o DTO estreito PRECISA de `description`. + - As superfícies usam `durationSeconds` + `formatSpeechClock`, não `durationLabel` (`formatSpeechSpan`) → manter `durationSeconds`; NÃO incluir `durationLabel`. + - `failureMessage`/`error`/`step` não são lidos por nenhuma das duas superfícies → saem do DTO; mas a normalização `status` desconhecido → `'failed'` fica (senão `SpeechCutStatusBadge` quebra). + - `tests/int/speechCut.int.spec.ts:860-870` asserta `mediaUrl`/`publicPath` no retorno desse loader → muda com o novo shape. + +## Abordagem recomendada + +```mermaid +flowchart LR + L[loaders: select enxuto + limit] --> S[toSpeechCutSummaryViewModel] + S --> A[SpeechCutsForSpeechSection] + S --> B[SpeechNestedCuts + SpeechListItemViewModel.cuts] + P[lib/speechSearch: predicado puro] --> VM[speechViewModels.speechMatchesSearchQuery] + P -. mesma semântica .-> W[speechListFilters Where] + C[SPEECH_CUT_ORIGIN_SPEECH_ID_LIMIT + originSpeechIdsOfCuts] --> L +``` + +**Opções consideradas:** A — DTO estreito + predicado puro compartilhado + cap do `id in` (as três fases) | B — só adicionar `select` mantendo `toSpeechCutViewModel` completo | C — F3 via subquery/raw e F2 via comparar por id já resolvido. +**Recomendação:** A — remove o payload não usado no shape (não só no tráfego), unifica a semântica num só lugar puro e fecha o `in` com degradação honesta; menor caminho que cobre as três fases. +**Rejeitadas:** B — `select` enxuto com VM completo ainda mapeia `mediaUrl`/`youtubeVideoId`/`failureMessage` que ninguém lê e exigiria `error` no select; C — subquery exige SQL cru fora do repositório Payload (quebra depth) e comparar por id erra quando a fala casa por texto E é origem de um corte. + +### Componentes / mudanças + +- **`SpeechCutSummaryViewModel` + `toSpeechCutSummaryViewModel` + `cutDurationSeconds`** (`src/lib/speechCut.ts`): mapper puro que emite `{ id, status, title, description, durationSeconds }`, reusando o fallback `durationSeconds ?? end − start` (extraído de `toSpeechCutViewModel`) e a normalização de status. `toSpeechCutViewModel` passa a usar o helper extraído. +- **`speechMatchesSearchQuery`** (`src/lib/speechSearch.ts`): predicado puro do ramo textual; `speechViewModels` deixa de ter `speechMatchesText` local e chama este. +- **`SPEECH_CUT_ORIGIN_SPEECH_ID_LIMIT` + `originSpeechIdsOfCuts`** (`src/lib/speechCut.ts`): dedupe + cap puro dos ids de fala de origem. +- **`speechCutPageData.ts`**: `speechCutSummarySelect` (status/title/description/durationSeconds/startSeconds/endSeconds) nos dois loaders de corte; `speechCutOriginSelect` (`speech`) + `limit` na origem; retornos passam a `SpeechCutSummaryViewModel`; origem usa `originSpeechIdsOfCuts`. +- **`speechViewModels.ts` / `SpeechCutsForSpeechSection.tsx` / `SpeechNestedCuts.tsx`**: tipos de prop passam a `SpeechCutSummaryViewModel` (render inalterado). +- **Migration:** sem migration — nenhuma collection/global/field muda; só `select`, `limit` e DTO de leitura. +- **Access / Consent:** n/a — os loaders seguem `user` + `overrideAccess:false`; nenhum write path. +- **UI:** Impeccable A; nenhuma superfície visual nova nem copy nova → nenhum trigger do `designer` (a/b/c/d) é acionado. + +### Dados → forma + +- Não se aplica (sem KPI/série/mapa; o dado já é lista). + +## Fases verificáveis + +1. **F1 — DTO estreito + `select`** (`lib/speechCut.ts`, `speechCutPageData.ts`, 3 componentes). Prova: `tests/unit/speechCut.unit.spec.ts` novo `describe('toSpeechCutSummaryViewModel')` (chaves exatas sem `mediaUrl`/`failureMessage`; fallback `end − start`; status desconhecido → `failed`); int `speechCut.int.spec.ts:860-870` atualizado para as chaves do summary. +2. **F2 — predicado único** (`lib/speechSearch.ts`, `speechViewModels.ts`). Prova: `tests/unit/speechSearch.unit.spec.ts` `describe('speechMatchesSearchQuery')` (searchText acento/caixa; keyword case-insensitive; query vazia/`undefined` → true) + int novo em `speechCut.int.spec.ts` com `keywords` em caixa diferente do `q` (o falso "Fala de origem" de hoje vira `matchedTextSearch: true`). +3. **F3 — cap do `id in`** (`lib/speechCut.ts`, `speechCutPageData.ts`). Prova: unit de `originSpeechIdsOfCuts` (dedupe, relação não resolvida, corta no `limit`); int existente `speechCut.int.spec.ts:883-915` mantém a fiação ponta-a-ponta. +4. **Gates:** `pnpm gate:fast` na iteração; entrega com `pnpm push`. E2E local discricionário; o manifest dispara o conjunto curado `campaignSpeechAcervo` + `campaignSpeechCut` (`scripts/lib/e2e-affected-manifest.mjs:300-314`) e o CI/PR cobre o blast radius. + +## Rabbit holes / Não escopo (engenharia) + +- Não estreitar `loadSpeechCutAcervoPageData` (`speechCutPageData.ts:42-70`, depth 1 precisa de origem/media) nem `loadSpeechCutDetailPageData`/`findSpeechCutForActor`. +- Não tocar `toSpeechCutViewModel` além de extrair o helper de duração; dialog/player/result card seguem com o VM completo. +- Não mexer no índice/`searchText` de `speechCut` (gatilho de volume do C168) nem no rename `matchedTextSearch`. +- Não introduzir `durationLabel`/`formatSpeechSpan` nas duas superfícies (mudaria a saída). +- Lista fixa de "Explicitamente fora" da intenção permanece fechada. + +## Riscos e mitigação + +- **Select enxuto quebrar o VM:** o summary não lê `media`/`error`/`step`; risco morre por construção — unit do mapper + int de chaves. +- **Predicado divergir do SQL:** documentar a semântica (`searchText` normalizado dos dois lados; `keywords` case-insensitive mas acento-significante, como o ILIKE `%q%`) e fixar com unit + int. +- **Cap degradar a busca:** o cap só corta a superfície "fala de origem" (menos falas origin-only), nunca torna resultado errado — o flag vem do predicado, não dos ids. Volume-gatilho ~5–10 mil cortes. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (nenhuma saída visual muda) +- [ ] Invariantes AGENTS/engineering-standards (depth `lib/`→`utilities/`, sem top-level novo, sem migration, pt-BR/identificadores EN) +- [ ] Testes de domínio previstos (unit dos mappers/predicado/cap + int de loader) — sem write path novo + +## Decisões de engenharia + +- **D1 — DTO:** novo `SpeechCutSummaryViewModel` + `toSpeechCutSummaryViewModel` em `lib/speechCut.ts`. Inclui `description` (exigido pelo `showDescription` do `SpeechNestedCuts`), mantém `durationSeconds` (a superfície usa `formatSpeechClock`) e a normalização `status` desconhecido → `'failed'` (senão o badge quebra). Rejeitados: manter `toSpeechCutViewModel` completo (mapeia campos não lidos) e um DTO sem `description` (perde a evidência da CENA 05). Saem `failureMessage`/`step`/`media*`/`publicPath`/`youtubeVideoId`/`publishedAt`. +- **D2 — predicado único:** `speechMatchesSearchQuery` puro em `src/lib/speechSearch.ts` (lib não importa utilities; ambos os consumidores já importam `normalizeForSearch`), consumido pelo VM; o `where` segue única fonte do SQL. `searchText` casa normalizado/acento-insensível dos dois lados; `keywords` passa a `toLowerCase().includes()` para casar o ILIKE (case-insensitive) mantendo acento significante — corrige o falso "Fala de origem" de hoje. Rejeitados: comparar por id já resolvido (erra quando a fala casa por texto E é origem) e duplicar o predicado. +- **D3 — teto:** `SPEECH_CUT_ORIGIN_SPEECH_ID_LIMIT = 200` como `limit` da query de origem + cap no `originSpeechIdsOfCuts`; degradação honesta = menos resultados "só por corte", nunca resultado errado. Rejeitadas: subquery/raw (fora do Payload, quebra depth) e deixar sem teto (o `id in` cresce sem limite com termo comum; gatilho ~5–10 mil cortes/C168). + +## Explicitamente fora / Adiado com gatilho (triage do /simplify) + +- **Select ↔ mapper ligados por convenção (S1):** `speechCutSummarySelect` e `SpeechCutSummaryRecord` são mantidos em passo por comentário cruzado + o int exact-keys; um campo novo sem select key faz o int/unit falharem (não é `undefined` silencioso). Link compile-time (chave tipada derivando os dois) fica adiado — gatilho: o mapper ganhar um 2º consumidor ou o int deixar de cobrir o shape. +- **Paridade predicado ↔ `where` só por teste de branch (S6):** `speechListFilters.unit.spec.ts` prende o conjunto exato de branches (`toEqual`), então adicionar/remover branch falha e força revisitar o predicado (doc aponta o par). Teste de paridade automático adiado — gatilho: uma 3ª dimensão textual entrar na busca de falas. +- **`originSpeechIdsOfCuts` com 1 call site (S3):** mantido como dono puro do cap/dedupe (testável sem DB); o `limit` default duplica o da query de propósito (invariante). Gatilho para reavaliar: 3º call site ou 2º loader precisar dos ids de origem. +- **Int exact-keys (S10):** mantido de propósito — é o único guard do contrato loader-select↔mapper (S1); reavaliar só se S1 ganhar link compile-time. +- **Já resolvido no simplify (não reabrir):** `SpeechCutSummaryRecord` via `Pick` (S2); doc do `speechSearch` (S4); JSDoc do `originSpeechIdsOfCuts` (S5); export morto do record (S7); off-by-one de `limit: 0` (S8); tipo `CutDurationRecord & Pick` (S9); nome/fronteira do teste (S11). + +## Self-score (decision-quality ≥4) + +1. **Decisões caras com rejeitadas — 4/5:** D1–D3 nomeiam opção, porquê e rejeitadas, com shape de DTO, semântica de ILIKE e teto resolvidos. +2. **Cabe no appetite — 5/5:** 5 arquivos de produção + 5 de teste; reusa mappers/loaders/int existentes. +3. **Rabbit holes nomeados — 5/5:** lista fixa herdada + os loaders que NÃO podem ser estreitados. +4. **Depth check — 5/5:** `utilities/speech/` segue dono; predicado e mappers puros em `lib/`; nenhum gêmeo. +5. **Intenção de produto — 5/5:** zero mudança visível; só custo de query, paridade e teto. + +**Score: 5/5 (mínimo 4).** diff --git a/src/components/campaign/speech/SpeechCutsForSpeechSection.tsx b/src/components/campaign/speech/SpeechCutsForSpeechSection.tsx index f35245ec..03c18f72 100644 --- a/src/components/campaign/speech/SpeechCutsForSpeechSection.tsx +++ b/src/components/campaign/speech/SpeechCutsForSpeechSection.tsx @@ -6,7 +6,7 @@ import { SpeechExcerptCta } from '@/components/campaign/speech/SpeechExcerptCta' import { Button } from '@/components/ui/button' import { campaignSpeechCutDetailHref } from '@/lib/campaignPaths' import { formatSpeechClock } from '@/lib/speechClock' -import type { SpeechCutViewModel } from '@/lib/speechCut' +import type { SpeechCutSummaryViewModel } from '@/lib/speechCut' const SECTION_TITLE = 'Cortes desta fala' const SECTION_DESCRIPTION = 'Abra um corte existente para editar ou reaproveitar.' @@ -38,7 +38,7 @@ export const SpeechCutsForSpeechSection = ({ cuts, excerptSelectionAvailable, }: { - cuts: readonly SpeechCutViewModel[] + cuts: readonly SpeechCutSummaryViewModel[] excerptSelectionAvailable: boolean }) => { if (cuts.length === 0) { diff --git a/src/components/campaign/speech/SpeechNestedCuts.tsx b/src/components/campaign/speech/SpeechNestedCuts.tsx index 3f8b64a9..d2af22c4 100644 --- a/src/components/campaign/speech/SpeechNestedCuts.tsx +++ b/src/components/campaign/speech/SpeechNestedCuts.tsx @@ -4,14 +4,14 @@ import { SpeechCutStatusBadge } from '@/components/campaign/speech/SpeechCutStat import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts' import { campaignSpeechCutDetailHref } from '@/lib/campaignPaths' import { formatSpeechClock } from '@/lib/speechClock' -import type { SpeechCutViewModel } from '@/lib/speechCut' +import type { SpeechCutSummaryViewModel } from '@/lib/speechCut' import { splitHighlightedParts } from '@/lib/speechHighlight' const countLabel = (count: number): string => count === 1 ? '1 corte desta fala' : `${count} cortes desta fala` /** Touch-sized outlined button on mobile; the compact text link on `md+`. */ -const CutAction = ({ cut }: { cut: SpeechCutViewModel }) => ( +const CutAction = ({ cut }: { cut: SpeechCutSummaryViewModel }) => ( + const mediaOf = (media: SpeechCutRecordForView['media']): CutMediaRecord | null => typeof media === 'object' && media !== null ? media : null const youtubeUrlOf = (speech: SpeechCutRecordForView['speech']): string | null => typeof speech === 'object' && speech !== null ? (speech.youtubeUrl ?? null) : null +/** Stored span when present, else the raw [start, end] fallback, floored at 0. */ +const cutDurationSeconds = (record: SpeechCutSummaryRecord): number => + typeof record.durationSeconds === 'number' && Number.isFinite(record.durationSeconds) + ? Math.max(0, Math.round(record.durationSeconds)) + : Math.max(0, Math.round(record.endSeconds) - Math.round(record.startSeconds)) + /** * Internal/status wire view of a cut. `error` and `createdBy` never pass * through here: the public page and the dialog receive only what they render — @@ -278,10 +308,7 @@ export const toSpeechCutViewModel = (record: SpeechCutRecordForView): SpeechCutV const media = mediaOf(record.media) const status = isSpeechCutStatus(record.status) ? record.status : 'failed' const step = isSpeechCutStep(record.step) ? record.step : null - const durationSeconds = - typeof record.durationSeconds === 'number' && Number.isFinite(record.durationSeconds) - ? Math.max(0, Math.round(record.durationSeconds)) - : Math.max(0, Math.round(record.endSeconds) - Math.round(record.startSeconds)) + const durationSeconds = cutDurationSeconds(record) return { id: record.id, @@ -303,6 +330,48 @@ export const toSpeechCutViewModel = (record: SpeechCutRecordForView): SpeechCutV } } +/** + * C180 — narrow view of a cut for the discovery surfaces, reusing the same + * duration fallback and status normalization as the full view (an unknown + * status still degrades to `failed`, so the badge never receives a stray value). + */ +export const toSpeechCutSummaryViewModel = ( + record: SpeechCutSummaryRecord, +): SpeechCutSummaryViewModel => ({ + id: record.id, + status: isSpeechCutStatus(record.status) ? record.status : 'failed', + title: record.title, + description: record.description, + durationSeconds: cutDurationSeconds(record), +}) + +/** + * C180 — volume ceiling for the origin `id in` the acervo ORs into its textual + * `where`. A common term can match thousands of cuts; the cap degrades the + * "origin-only" surface (fewer origin speeches), never the result's correctness + * — the "Fala de origem" flag comes from the predicate, not from these ids. + * Revisit when the catalog approaches ~5–10k cuts (same trigger as C168). + */ +export const SPEECH_CUT_ORIGIN_SPEECH_ID_LIMIT = 200 + +/** + * Deduped origin speech ids of a batch of cuts, capped at `limit`. Depth 0 + * keeps an unresolved relation as the raw id; a populated relation contributes + * its `.id`, and a missing/null relation is skipped. + */ +export const originSpeechIdsOfCuts = ( + cuts: readonly { speech?: number | CutSpeechRecord | null }[], + limit = SPEECH_CUT_ORIGIN_SPEECH_ID_LIMIT, +): number[] => { + const ids = new Set() + for (const cut of cuts) { + if (ids.size >= limit) break + const speechId = typeof cut.speech === 'number' ? cut.speech : cut.speech?.id + if (typeof speechId === 'number') ids.add(speechId) + } + return [...ids] +} + /** Origin of a cut in the library: the speech it was cut from, with its link. */ export type SpeechCutOriginViewModel = { id: number diff --git a/src/lib/speechSearch.ts b/src/lib/speechSearch.ts index 317569e9..1f9144f6 100644 --- a/src/lib/speechSearch.ts +++ b/src/lib/speechSearch.ts @@ -14,6 +14,31 @@ export const normalizeForSearch = (value: string): string => .replace(/\s+/g, ' ') .trim() +type SpeechSearchableRecord = { + /** Normalized segment text persisted by the segment hook (C153). */ + searchText?: string | null + keywords?: readonly string[] | null +} + +/** + * C180 — pure mirror of the textual `where` branch + * (`speechListFilters.buildSpeechTextBranches`): normalized `searchText` LIKE + * the normalized query, OR a raw official keyword containing the query. + * `keywords` compare case-insensitively (accent still significant) to match the + * ILIKE `%q%` Postgres runs. Adding or removing a branch there means changing + * this predicate and its test in the same commit. + */ +export const speechMatchesSearchQuery = ( + speech: SpeechSearchableRecord, + query: string | undefined, +): boolean => { + const trimmed = query?.trim() + if (!trimmed) return true + if (normalizeForSearch(speech.searchText ?? '').includes(normalizeForSearch(trimmed))) return true + const lowered = trimmed.toLowerCase() + return (speech.keywords ?? []).some((keyword) => keyword.toLowerCase().includes(lowered)) +} + /** * Dedupes values by their normalized form, preserving the first spelling. * Used by the facet classifier before persisting people/program mentions. diff --git a/src/utilities/speech/speechCutPageData.ts b/src/utilities/speech/speechCutPageData.ts index f32d42d9..a7cbb428 100644 --- a/src/utilities/speech/speechCutPageData.ts +++ b/src/utilities/speech/speechCutPageData.ts @@ -3,10 +3,12 @@ import 'server-only' import type { Payload } from 'payload' import { + originSpeechIdsOfCuts, + SPEECH_CUT_ORIGIN_SPEECH_ID_LIMIT, toSpeechCutLibraryItemViewModel, - toSpeechCutViewModel, + toSpeechCutSummaryViewModel, type SpeechCutLibraryItemViewModel, - type SpeechCutViewModel, + type SpeechCutSummaryViewModel, } from '@/lib/speechCut' import type { CampaignUser } from '@/payload-types' import { createEntityNotFoundError } from '@/utilities/entityNotFound' @@ -21,6 +23,26 @@ import { type SpeechCutListSearchParams = Record +/** + * C180 — lean `select` for the discovery surfaces: only the fields the summary + * view model renders, so the loaders stop hauling media/error/step rows. Keep + * field-for-field with `SpeechCutSummaryRecord` (`lib/speechCut.ts`); Payload + * does not reflect `select` in the result type, so the int test is the guard. + */ +const speechCutSummarySelect = { + status: true, + title: true, + description: true, + durationSeconds: true, + startSeconds: true, + endSeconds: true, + // Kept so the batch loader can group rows by their origin speech. + speech: true, +} as const + +/** The origin loader only needs the speech relation to dedupe the origins. */ +const speechCutOriginSelect = { speech: true } as const + export const SpeechCutNotFoundError = createEntityNotFoundError( 'SpeechCut', 'Corte não encontrado.', @@ -72,15 +94,15 @@ export const loadSpeechCutAcervoPageData = async ( /** * C174 — every cut made from one speech, newest first, for the "Cortes desta * fala" section of the speech detail. Depth 0 is enough: the origin here IS the - * page, and the section renders title/status/duration only (`error` still feeds - * the honest `failureMessage`, never the raw cause). No page cap: the number of - * cuts per speech is bounded by human effort. + * page, and the section renders title/status/duration only — the summary select + * keeps the query to what it shows. No page cap: the number of cuts per speech + * is bounded by human effort. */ export const loadSpeechCutsForSpeech = async ( payload: Payload, user: CampaignUser, speechId: number, -): Promise => { +): Promise => { const result = await payload.find({ collection: 'speechCut', where: { speech: { equals: speechId } }, @@ -88,24 +110,26 @@ export const loadSpeechCutsForSpeech = async ( limit: 0, pagination: false, sort: '-createdAt', + select: speechCutSummarySelect, user, overrideAccess: false, }) - return result.docs.map((cut) => toSpeechCutViewModel(cut)) + return result.docs.map((cut) => toSpeechCutSummaryViewModel(cut)) } /** * C174 — every cut of every speech on the acervo page, grouped by speech id, so * the result list nests them under the speech without a query per row. Same - * precedent as `loadSegmentsForSpeeches`; depth 0 keeps the origin as the id. + * precedent as `loadSegmentsForSpeeches`; depth 0 keeps the origin as the id and + * the summary select carries only what the nested card renders. */ export const loadSpeechCutsForSpeeches = async ( payload: Payload, user: CampaignUser, speechIds: readonly number[], -): Promise> => { - const bySpeech = new Map() +): Promise> => { + const bySpeech = new Map() if (speechIds.length === 0) return bySpeech const result = await payload.find({ @@ -115,6 +139,7 @@ export const loadSpeechCutsForSpeeches = async ( limit: 0, pagination: false, sort: '-createdAt', + select: speechCutSummarySelect, user, overrideAccess: false, }) @@ -123,7 +148,7 @@ export const loadSpeechCutsForSpeeches = async ( const speechId = typeof cut.speech === 'number' ? cut.speech : cut.speech?.id if (typeof speechId !== 'number') continue const list = bySpeech.get(speechId) ?? [] - list.push(toSpeechCutViewModel(cut)) + list.push(toSpeechCutSummaryViewModel(cut)) bySpeech.set(speechId, list) } return bySpeech @@ -133,6 +158,10 @@ export const loadSpeechCutsForSpeeches = async ( * C174 (option B) — the origin speech ids of the cuts whose own title/description * match `q`. The acervo search uses them to surface a speech through a cut even * when the speech text itself does not contain the term. + * + * C180 — the query is capped at `SPEECH_CUT_ORIGIN_SPEECH_ID_LIMIT`: a common + * term must not let the acervo's `id in [...]` grow without bound. The cap only + * trims origin-only hits; it never makes a returned row wrong. */ export const loadSpeechCutOriginSpeechIds = async ( payload: Payload, @@ -143,18 +172,14 @@ export const loadSpeechCutOriginSpeechIds = async ( collection: 'speechCut', where: buildSpeechCutListWhere({ page: 1, q }), depth: 0, - limit: 0, + limit: SPEECH_CUT_ORIGIN_SPEECH_ID_LIMIT, pagination: false, + select: speechCutOriginSelect, user, overrideAccess: false, }) - const ids = new Set() - for (const cut of result.docs) { - const speechId = typeof cut.speech === 'number' ? cut.speech : cut.speech?.id - if (typeof speechId === 'number') ids.add(speechId) - } - return [...ids] + return originSpeechIdsOfCuts(result.docs) } /** One cut with its origin and stored media, for the library detail page. */ diff --git a/src/utilities/speech/speechListFilters.ts b/src/utilities/speech/speechListFilters.ts index cc7f78f2..b59c5365 100644 --- a/src/utilities/speech/speechListFilters.ts +++ b/src/utilities/speech/speechListFilters.ts @@ -54,7 +54,12 @@ const buildSpeechTextBranches = (q: string): Where[] => [ { keywords: { contains: q } }, ] -/** The textual branch: normalized speech text OR a raw official keyword. */ +/** + * The textual branch: normalized speech text OR a raw official keyword. The + * keyword `contains` compiles to ILIKE `%q%` — case-insensitive, accent still + * significant. The view-model mirror is `lib/speechSearch.speechMatchesSearchQuery` + * (C180); a semantics change here has to land there too. + */ const buildSpeechTextWhere = (q: string): Where => ({ or: buildSpeechTextBranches(q) }) export const buildSpeechListWhere = (state: SpeechListState): Where => { diff --git a/src/utilities/speech/speechViewModels.ts b/src/utilities/speech/speechViewModels.ts index f2a8a0cd..69f3d5a6 100644 --- a/src/utilities/speech/speechViewModels.ts +++ b/src/utilities/speech/speechViewModels.ts @@ -5,7 +5,7 @@ */ import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths' import { formatSpeechClock, formatSpeechDate, formatSpeechSpan } from '@/lib/speechClock' -import type { SpeechCutViewModel } from '@/lib/speechCut' +import type { SpeechCutSummaryViewModel } from '@/lib/speechCut' import type { SpeechExcerptSegment } from '@/lib/speechExcerpt' import type { SpeechScope, SpeechTopic } from '@/lib/speechFacets' import { @@ -16,7 +16,7 @@ import { type SpeechHighlightedExcerpt, type SpeechHighlightPart, } from '@/lib/speechHighlight' -import { normalizeForSearch } from '@/lib/speechSearch' +import { normalizeForSearch, speechMatchesSearchQuery } from '@/lib/speechSearch' import { correctedExcerptOffsetSeconds, excerptOffsetSeconds, @@ -74,7 +74,7 @@ export type SpeechListItemViewModel = { */ officialTextUrl: string | null /** C174 — the cuts already made from this speech, newest first. */ - cuts: SpeechCutViewModel[] + cuts: SpeechCutSummaryViewModel[] /** * C174 — false when the speech surfaced only because one of its cuts matched * the term; the card then swaps the excerpt for the honest origin note. @@ -196,19 +196,7 @@ export const parseSpeechSeekSeconds = (raw: string | undefined): number | null = return Number.isFinite(seconds) && seconds >= 0 ? seconds : null } -type SpeechCuts = readonly SpeechCutViewModel[] - -/** - * C174 — does the speech itself match `q`? Mirrors the textual `where` branch - * (`speechListFilters.buildSpeechTextBranches`): normalized `searchText` LIKE - * and raw keyword contains. Keep the two spellings in step. - */ -const speechMatchesText = (speech: SpeechListRecord, query: string | undefined): boolean => { - const trimmed = query?.trim() - if (!trimmed) return true - if (normalizeForSearch(speech.searchText ?? '').includes(normalizeForSearch(trimmed))) return true - return (speech.keywords ?? []).some((keyword) => keyword.includes(trimmed)) -} +type SpeechCuts = readonly SpeechCutSummaryViewModel[] export const toSpeechListItemViewModel = ({ speech, @@ -251,7 +239,7 @@ export const toSpeechListItemViewModel = ({ watchHref: buildWatchHref(speech.id, matchedSegment, q), officialTextUrl: speech.officialTextUrl ?? null, cuts: [...cuts], - matchedTextSearch: speechMatchesText(speech, query), + matchedTextSearch: speechMatchesSearchQuery(speech, query), } } diff --git a/tests/int/speechCut.int.spec.ts b/tests/int/speechCut.int.spec.ts index cb3e16a5..75d501d5 100644 --- a/tests/int/speechCut.int.spec.ts +++ b/tests/int/speechCut.int.spec.ts @@ -863,9 +863,15 @@ describe('speech cut discovery (C174)', () => { const forSpeech = await loadSpeechCutsForSpeech(payload, communicator, speech) expect(forSpeech.map((cut) => cut.id)).toEqual([newer, older]) expect(forSpeech.map((cut) => cut.id)).not.toContain(unrelated) - // Depth 0: the row carries no media/file internals. - expect(forSpeech[0]?.mediaUrl).toBeNull() - expect(forSpeech[0]?.publicPath).toBe(`/corte/${newer}`) + // C180 — the summary view carries only what the section renders (no media/file internals). + expect(Object.keys(forSpeech[0] ?? {}).sort()).toEqual([ + 'description', + 'durationSeconds', + 'id', + 'status', + 'title', + ]) + expect(forSpeech[0]?.durationSeconds).toBe(20) const grouped = await loadSpeechCutsForSpeeches(payload, communicator, [speech, otherSpeech]) expect(grouped.get(speech)?.map((cut) => cut.id)).toEqual([newer, older]) @@ -916,4 +922,19 @@ describe('speech cut discovery (C174)', () => { expect(row).toBeDefined() expect(row?.cuts).toHaveLength(0) }) + + it('flags a speech whose keyword matches case-insensitively as a regular result', async () => { + // C180 — the SQL `contains` is ILIKE; the view-model mirror follows the same + // case-insensitive semantics instead of showing the origin-only note wrongly. + const marker = `saudepublica${randomUUID().slice(0, 8)}` + const speech = await createSpeech({ + keywords: [`SAÚDE ${marker.toUpperCase()}`], + segments: [{ startSeconds: 0, endSeconds: 2.7, text: `Discurso ${randomUUID()}` }], + }) + const { communicator } = await createUsers() + + const data = await loadSpeechAcervoPageData(payload, communicator, { q: `saúde ${marker}` }) + const row = data.rows.find((candidate) => candidate.id === speech) + expect(row?.matchedTextSearch).toBe(true) + }) }) diff --git a/tests/unit/speechCut.unit.spec.ts b/tests/unit/speechCut.unit.spec.ts index dd44917a..30b2e9ca 100644 --- a/tests/unit/speechCut.unit.spec.ts +++ b/tests/unit/speechCut.unit.spec.ts @@ -6,6 +6,7 @@ import { buildSpeechCutFallbackMetadata, buildSpeechCutFfmpegArgs, buildSpeechCutShare, + originSpeechIdsOfCuts, SPEECH_CUT_FAILURE_GENERATING, SPEECH_CUT_FAILURE_INTERRUPTED, SPEECH_CUT_FAILURE_SPEECH_GONE, @@ -14,6 +15,7 @@ import { speechCutFailureMessage, speechCutPublicPath, speechCutStepStates, + toSpeechCutSummaryViewModel, toSpeechCutViewModel, } from '@/lib/speechCut' @@ -175,6 +177,69 @@ describe('toSpeechCutViewModel', () => { }) }) +describe('toSpeechCutSummaryViewModel', () => { + it('emits only the fields the discovery surfaces render', () => { + const summary = toSpeechCutSummaryViewModel({ + id: 7, + status: 'published', + title: 'Acesso a medicamentos', + description: 'Descrição', + startSeconds: 43, + endSeconds: 118, + durationSeconds: 75, + }) + expect(summary).toEqual({ + id: 7, + status: 'published', + title: 'Acesso a medicamentos', + description: 'Descrição', + durationSeconds: 75, + }) + expect(Object.keys(summary).sort()).toEqual([ + 'description', + 'durationSeconds', + 'id', + 'status', + 'title', + ]) + }) + + it('falls back to end − start and degrades an unknown status to failed', () => { + const summary = toSpeechCutSummaryViewModel({ + id: 1, + status: 'weird', + title: 't', + description: 'd', + startSeconds: 10, + endSeconds: 30, + }) + expect(summary.status).toBe('failed') + expect(summary.durationSeconds).toBe(20) + }) +}) + +describe('originSpeechIdsOfCuts', () => { + it('dedupes resolved ids and skips null/missing relations', () => { + expect( + originSpeechIdsOfCuts([ + { speech: 7 }, + { speech: 7 }, + { speech: 9 }, + { speech: null }, + { speech: { id: 11 } }, + {}, + ]), + ).toEqual([7, 9, 11]) + }) + + it('caps the list at the limit and returns nothing at limit 0', () => { + const cuts = Array.from({ length: 10 }, () => ({ speech: 1 })) + cuts.push({ speech: 2 }, { speech: 3 }) + expect(originSpeechIdsOfCuts(cuts, 2)).toEqual([1, 2]) + expect(originSpeechIdsOfCuts(cuts, 0)).toEqual([]) + }) +}) + describe('speechCutFailureMessage', () => { it('maps the named causes of the first stage to honest pt-BR copy', () => { expect(speechCutFailureMessage({ error: SPEECH_CUT_FAILURE_SPEECH_GONE })).toBe( diff --git a/tests/unit/speechSearch.unit.spec.ts b/tests/unit/speechSearch.unit.spec.ts index f7756a9e..c9ea9615 100644 --- a/tests/unit/speechSearch.unit.spec.ts +++ b/tests/unit/speechSearch.unit.spec.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest' -import { normalizeForSearch, uniqueByNormalizedForm } from '@/lib/speechSearch' +import { + normalizeForSearch, + speechMatchesSearchQuery, + uniqueByNormalizedForm, +} from '@/lib/speechSearch' describe('normalizeForSearch', () => { it('strips accents, lowercases and collapses whitespace', () => { @@ -21,3 +25,25 @@ describe('uniqueByNormalizedForm', () => { expect(uniqueByNormalizedForm(['', ' ', 'Bahia'])).toEqual(['Bahia']) }) }) + +describe('speechMatchesSearchQuery', () => { + it('matches the normalized search text accent- and case-insensitively', () => { + const speech = { searchText: 'a saude publica baiana', keywords: [] } + expect(speechMatchesSearchQuery(speech, 'SAÚDE Pública')).toBe(true) + expect(speechMatchesSearchQuery(speech, 'educação')).toBe(false) + }) + + it('matches an official keyword case-insensitively, accent still significant', () => { + const speech = { searchText: '', keywords: ['Farmácia Popular', 'SUS'] } + expect(speechMatchesSearchQuery(speech, 'farmácia popular')).toBe(true) + expect(speechMatchesSearchQuery(speech, 'FARMÁCIA')).toBe(true) + // the SQL is ILIKE `%q%`: a different accent would not match there either + expect(speechMatchesSearchQuery(speech, 'farmacia')).toBe(false) + }) + + it('treats an empty or missing query as a match', () => { + const speech = { searchText: null, keywords: null } + expect(speechMatchesSearchQuery(speech, undefined)).toBe(true) + expect(speechMatchesSearchQuery(speech, ' ')).toBe(true) + }) +})