fix(C180): select enxuto dos loaders, teto do id in da origem e paridade busca↔VM (Closes #1097)

This commit is contained in:
Francisco Solla 2026-09-16 20:00:56 -03:00
parent 5f64321cc5
commit 42f2f0ecaa
12 changed files with 373 additions and 49 deletions

View file

@ -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/<id>`, 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.

View file

@ -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/<id>` 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).**

View file

@ -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) {

View file

@ -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 }) => (
<Link
href={campaignSpeechCutDetailHref(cut.id)}
aria-label={`Abrir corte: ${cut.title}`}
@ -34,7 +34,7 @@ export const SpeechNestedCuts = ({
query,
showDescription = false,
}: {
cuts: readonly SpeechCutViewModel[]
cuts: readonly SpeechCutSummaryViewModel[]
caption?: string
/** The search term, highlighted in the title. */
query?: string

View file

@ -263,12 +263,42 @@ export type SpeechCutViewModel = {
failureMessage: string | null
}
/**
* C180 — the narrow view of a cut for the discovery surfaces ("Cortes desta
* fala" and the search nesting): everything they render, nothing they don't
* (`media*`/`publicPath`/`youtubeVideoId`/`failureMessage`/`step` stay out).
*/
export type SpeechCutSummaryViewModel = {
id: number
status: SpeechCutStatus
title: string
description: string
/** Stored span, stable even if the speech row changes; falls back to end − start. */
durationSeconds: number
}
/**
* The shape the summary mapper needs. Payload does not reflect `select` in the
* result type, so this must stay field-for-field with `speechCutSummarySelect`
* in `utilities/speech/speechCutPageData.ts`.
*/
type SpeechCutSummaryRecord = Pick<
SpeechCutRecordForView,
'id' | 'status' | 'title' | 'description' | 'startSeconds' | 'endSeconds' | 'durationSeconds'
>
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<number>()
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

View file

@ -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.

View file

@ -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<string, string | string[] | undefined>
/**
* 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<SpeechCutViewModel[]> => {
): Promise<SpeechCutSummaryViewModel[]> => {
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<Map<number, SpeechCutViewModel[]>> => {
const bySpeech = new Map<number, SpeechCutViewModel[]>()
): Promise<Map<number, SpeechCutSummaryViewModel[]>> => {
const bySpeech = new Map<number, SpeechCutSummaryViewModel[]>()
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<number>()
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. */

View file

@ -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 => {

View file

@ -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),
}
}

View file

@ -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)
})
})

View file

@ -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(

View file

@ -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)
})
})