mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
fix(C180): select enxuto dos loaders, teto do id in da origem e paridade busca↔VM (Closes #1097)
This commit is contained in:
parent
5f64321cc5
commit
42f2f0ecaa
12 changed files with 373 additions and 49 deletions
1
docs/changelog/2026-09-16-c180.md
Normal file
1
docs/changelog/2026-09-16-c180.md
Normal 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.
|
||||
99
docs/plans/acervo-cortes-divida-pos-c174-impl.md
Normal file
99
docs/plans/acervo-cortes-divida-pos-c174-impl.md
Normal 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).**
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue