From 0b05dfe9a039eb506b02f542f5349cabb6c34cd6 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Fri, 25 Sep 2026 01:48:17 -0300 Subject: [PATCH] fix(C223): aceita falas longas no acervo --- docs/changelog/2026-09-25-c223.md | 1 + ...falas-web-texto-normalizado-limite-impl.md | 151 ++++++++++++++++++ src/collections/ContentPiece.ts | 3 + src/collections/Recording.ts | 2 + src/collections/RecordingSegment.ts | 4 +- src/collections/Speech.ts | 3 + src/collections/SpeechSegment.ts | 4 +- src/lib/schemas/contentPiece.ts | 3 +- src/lib/speechSearch.ts | 2 + tests/int/contentPiece.int.spec.ts | 15 ++ tests/int/recording.int.spec.ts | 24 +++ tests/int/speechImport.int.spec.ts | 32 ++++ tests/int/webSpeechAcervo.int.spec.ts | 18 +++ tests/int/webSpeechIngest.int.spec.ts | 69 ++++++++ tests/unit/contentPiece.unit.spec.ts | 13 ++ 15 files changed, 341 insertions(+), 3 deletions(-) create mode 100644 docs/changelog/2026-09-25-c223.md create mode 100644 docs/plans/falas-web-texto-normalizado-limite-impl.md diff --git a/docs/changelog/2026-09-25-c223.md b/docs/changelog/2026-09-25-c223.md new file mode 100644 index 00000000..578d7f1a --- /dev/null +++ b/docs/changelog/2026-09-25-c223.md @@ -0,0 +1 @@ +**C223 — Falas longas entram no acervo sem estourar o limite do texto normalizado (2026-09-25):** a ingestão web passa a aceitar transcrições completas de qualquer duração nos campos brutos e derivados de `Speech`, `Recording` e `ContentPiece`, com `TRANSCRIPT_TEXT_MAX_LENGTH` explícito e o default global de 40 000 preservado para os demais textos. O upsert transacional, a idempotência por `sourceKey`, o relatório e a busca `LIKE`/GIN continuam intactos; regressões cobrem fala longa, marcador no fim da transcrição, retry sem duplicação e persistência dos campos irmãos. Sem migration, mudança de URL, busca/facetas ou UI. diff --git a/docs/plans/falas-web-texto-normalizado-limite-impl.md b/docs/plans/falas-web-texto-normalizado-limite-impl.md new file mode 100644 index 00000000..6a9f7b62 --- /dev/null +++ b/docs/plans/falas-web-texto-normalizado-limite-impl.md @@ -0,0 +1,151 @@ +# Impl: Falas longas entram no acervo sem estourar o limite do texto normalizado + +Status: aprovado +Atualizado em: 2026-09-25 +Issue: #1337 +Intenção: `docs/plans/falas-web-texto-normalizado-limite.md` +Appetite restante: ~0,5 dia; alteração de validação coberta por regressões de persistência, retry e busca +Impeccable: A — N/A +Design UI: N/A + +## Leitura da intenção + +- **Outcome:** uma transcrição longa, inclusive uma fala web de aproximadamente três horas, entra inteira no acervo, continua pesquisável do começo ao fim e pode ser reingerida sem duplicar registros. +- **O que não negociar:** não truncar; não rejeitar por tamanho de transcrição; não esconder falhas físicas; não alterar busca, facetas, paginação, rotas ou contrato público; não alterar access, segurança ou Consent. +- **O que reavaliar:** a hipótese de que bastariam os cinco `searchText`; a segurança de elevar o default global; a extensão dos campos brutos; a ausência de migration SQL. +- **Decisão de escopo:** corrigir os campos de texto que participam da transcrição do acervo. Reutilizar a normalização, o upsert transacional, o relatório e os loaders existentes. + +## Estado técnico confirmado + +- `normalizeForSearch` em `src/lib/speechSearch.ts:8-15` normaliza acentos, caixa e espaços sem cortar ou resumir. +- O Payload usa `defaultMaxTextLength = 40000` para `text`/`textarea` sem `maxLength`; um `maxLength` explícito substitui esse default (`node_modules/payload/dist/config/defaults.js:48` e `node_modules/payload/dist/fields/validations.js:24-35`). +- O upsert de Câmara/web em `src/utilities/speech/speechImport.ts:226-320` deriva o texto normalizado dos segmentos e grava fala e segmentos na mesma transação. +- O caminho de recordings grava `Recording.searchText` e cria `RecordingSegment` em `src/utilities/recordings/recordingJob.ts:294-332`. +- `ContentPiece` deriva `searchText` no hook em `src/collections/ContentPiece.ts:171-237`. +- A busca de Speech usa `searchText` com `LIKE` em `src/utilities/speech/speechListFilters.ts:65-108` e já usa o índice GIN trigram; não precisa mudar. +- As colunas PostgreSQL relevantes são `varchar` sem comprimento declarado, então a correção é de validação da aplicação, não de SQL. +- Os testes existentes de `speechImport` e `speechAcervo` cobrem persistência e busca com textos curtos, mas não atravessam o limite de 40 000. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[ASR ou texto bruto] --> B[normalizeForSearch sem corte] + B --> C[upsertSpeechBundle] + C --> D[(speech + speechSegment)] + C --> E[RecordingSegment ou ContentPiece] + D --> F[searchText completo] + E --> F + F --> G[LIKE + índice GIN existente] + G --> H[Termo do fim encontra a fala] + C --> I[Retry com o mesmo sourceKey] + I --> J[Atualização idempotente] + C --> K[Falha física ou de provider] + K --> L[Falha visível no relatório] +``` + +**Opções consideradas:** A | B | C | D + +**Recomendação:** **C — declarar um `maxLength` explícito e efetivamente ilimitado nos campos brutos de transcrição e nos cinco `searchText`; manter o default global em 40 000.** + +O valor deve ser uma constante compartilhada, `TRANSCRIPT_TEXT_MAX_LENGTH = Number.MAX_SAFE_INTEGER`, no owner existente `src/lib/speechSearch.ts`. O valor funciona como sentinel de validação, não como uma nova política de negócio: não cria uma fronteira artificial para transcrições, continua deixado ao limite físico do runtime/banco/storage, e falhas físicas continuam passando pelo relatório existente. + +**Alternativas rejeitadas:** + +- **A — elevar `defaultMaxTextLength`:** alteraria todos os `text`/`textarea` sem limite explícito, globs e formulários, ampliaria demais o risco de payloads enormes e mascararia limites de outros domínios. +- **B — elevar somente os cinco `searchText`:** deixaria os campos brutos com o teto de 40 000; uma transcrição longa ainda poderia ser rejeitada antes do texto derivado. +- **D — corrigir apenas o import web ou um hook condicional por origem:** os owners são compartilhados por Câmara/web, recordings e Central; a validação do schema mantém o mesmo teto e os caminhos divergem. + +### Matriz de campos + +| Collection | Campo | Tipo | Motivo | +| ------------------ | -------------------- | ---------- | --------------------------------- | +| `speech` | `officialTranscript` | `textarea` | Transcrição bruta do catálogo. | +| `speech` | `searchText` | `textarea` | Concatenação pesquisável da fala. | +| `speechSegment` | `text` | `textarea` | Texto bruto de um segmento. | +| `speechSegment` | `searchText` | `text` | Derivado normalizado do segmento. | +| `recording` | `searchText` | `textarea` | Texto agregado pesquisável. | +| `recordingSegment` | `text` | `textarea` | Texto bruto do segmento. | +| `recordingSegment` | `searchText` | `text` | Derivado normalizado do segmento. | +| `contentPiece` | `transcript` | `textarea` | Texto/transcrição da peça. | +| `contentPiece` | `searchText` | `textarea` | Haystack derivado da peça. | + +Campos de resumo, erro, palavras-chave, Consent, Reel e demais formulários permanecem intocados: não são a transcrição pesquisável do acervo e devem conservar seus limites atuais. + +## Componentes / mudanças + +- **`src/lib/speechSearch.ts`:** adicionar `TRANSCRIPT_TEXT_MAX_LENGTH`; não alterar a normalização. +- **`src/collections/Speech.ts`:** aplicar a constante a `officialTranscript` e `searchText`. +- **`src/collections/SpeechSegment.ts`:** aplicar a constante a `text` e `searchText`. +- **`src/collections/Recording.ts`:** aplicar a constante a `searchText`. +- **`src/collections/RecordingSegment.ts`:** aplicar a constante a `text` e `searchText`. +- **`src/collections/ContentPiece.ts`:** aplicar a constante a `transcript` e `searchText`. +- **`src/lib/schemas/contentPiece.ts`:** usar a mesma constante no schema da ficha, para que a action não mantenha um teto de 200 000 caracteres diferente do campo. +- **`src/utilities/speech/speechImport.ts`, `webSpeechIngest.ts` e `speechListFilters.ts`:** sem mudança de comportamento; o upsert, o relatório e a busca existentes são reutilizados. +- **Migration:** nenhuma alteração SQL esperada; as colunas já são `varchar` sem limite. Não editar migrations, `payload-types.ts` ou `importMap.js`. Se a verificação de migration indicar alteração de banco, parar e revisar antes de prosseguir. +- **Access / Consent:** nenhuma alteração. +- **UI:** Impeccable A — nenhuma tela, componente, rota ou designer. + +## Fases verificáveis + +1. **Contrato e regressão principal:** criar teste de integração no caminho web com três segmentos individuais abaixo de 40 000 cujo agregado ultrapasse 40 000, incluindo um marcador apenas no último segmento. O teste deve provar persistência completa e nova execução com o mesmo `sourceKey` sem duplicar fala, segmentos ou mídia. +2. **Busca e campos irmãos:** provar no loader do acervo que o marcador final encontra a fala; adicionar cobertura de persistência para `officialTranscript`, `SpeechSegment.text`, `RecordingSegment.text`, `Recording.searchText`, `ContentPiece.transcript` e `ContentPiece.searchText` quando o teste existente permitir. Fixar que `defaultMaxTextLength` continua 40 000 e que os campos não relacionados mantêm seus limites. +3. **Configuração mínima:** aplicar a constante somente aos nove campos da matriz e rodar os testes alterados, `pnpm gate:fast` e os demais gates do repositório. +4. **E2E local afetado:** rodar os specs de `campaignSpeechAcervo`/superfície web que o classificador considerar afetados, se houver benefício além dos testes de integração. +5. **Changelog e fechamento:** registrar `docs/changelog/2026-09-25-c223.md`, revisar o diff, usar `pnpm push` e abrir PR Ready com `Closes #1337`; não executar o lote de produção nesta entrega. + +## Rabbit holes / Não escopo de engenharia + +- Subir apenas o default global. +- Truncar em 40 000, mesmo com registro. +- Alterar `speechListFilters`, GIN, facetas, ordenação ou URL. +- Criar collection, campo, relatório, tela ou status `truncated`. +- Reindexar dados históricos; a falha impedia a criação, portanto o retry do lote pendente basta. +- Alterar access, overrideAccess, segurança ou Consent. +- Ampliar campos de resumo/erro ou domínios sem relação com transcrição. +- Adicionar limite concreto sem evidência operacional; se surgir, deve ser issue separada com contagem e motivo no relatório. + +## Riscos e mitigação + +- **Volume de memória, payload e índice:** os textos já são normalizados e persistidos uma vez; manter os processamentos e índices existentes e observar o lote real antes de criar nova estratégia. +- **Limite físico residual:** Node, PostgreSQL, storage ou provider podem falhar; não truncar e deixar o fluxo existente reportar `sourceKey`, estágio e erro. +- **Campo esquecido:** a matriz de nove campos e os testes de persistência impedem uma segunda barreira de 40 000. +- **Retry:** reutilizar a transação e o `sourceKey` atuais; o teste prova identidade e contagem de segmentos. +- **Divergência com escopo:** revisar que busca, facetas, URLs, access, migrations e UI não foram alterados. + +## Aceite de engenharia + +- [ ] A fala de teste tem segmentos individuais abaixo de 40 000 e `searchText` agregado acima de 40 000. +- [ ] A transcrição e o marcador final são persistidos integralmente. +- [ ] A busca pelo marcador final retorna a fala. +- [ ] O retry com o mesmo `sourceKey` não duplica registros nem artefatos. +- [ ] Os nove campos da matriz aceitam o texto completo; campos não relacionados mantêm seus limites. +- [ ] `defaultMaxTextLength` continua 40 000. +- [ ] Nenhum texto é truncado; falhas físicas continuam visíveis no relatório. +- [ ] Nenhuma migration, alteração de tipos/importMap, busca, rota ou UI é introduzida. +- [ ] Testes relevantes, `pnpm gate:fast`, gates completos e build estão verdes. + +## Triagem pós-simplify + +### Já resolvido no simplify (não reabrir) + +- P2: pin explícito do default `40 000` e de campo não relacionado. +- P2: comparação exacta de todos os segmentos, `searchText` e preservação de conteúdo/mídia no retry. +- P2: schema da ficha de `ContentPiece` alinhado à constante dos campos de transcrição. + +### Explicitamente fora + +- P3: assertion de `excerpt` no teste de busca foi removida por ser redundante e acoplada ao view model; o marcador e a presença da fala já provam o contrato. +- Nenhum novo lote, Issue ou follow-up deferido: não há débito caro remanescente. + +## Self-score — decision-quality + +| Critério | Nota | Justificativa | +| -------------------------------- | ---: | ------------------------------------------------------------------------------------------- | +| Decisões caras têm rejeitadas? | 5/5 | Global, cinco campos, escopo por origem e truncamento foram comparados. | +| A abordagem cabe no appetite? | 5/5 | É configuração de nove campos mais regressões focadas, sem pipeline novo. | +| Rabbit holes nomeados? | 5/5 | Busca, migration, relatório, reindexação, UI e domínios não relacionados foram delimitados. | +| Depth check: reusa donos? | 5/5 | Reutiliza a constante de busca, upsert, hooks, loaders, índice e relatório existentes. | +| A intenção permanece satisfeita? | 5/5 | Texto inteiro, busca ponta a ponta, retry e falhas honestas permanecem cobertos. | + +**Self-score total: 25/25 — 5/5. Gate `>= 4/5`: aprovado.** diff --git a/src/collections/ContentPiece.ts b/src/collections/ContentPiece.ts index 9a39e887..2b2cf42f 100644 --- a/src/collections/ContentPiece.ts +++ b/src/collections/ContentPiece.ts @@ -34,6 +34,7 @@ import { import { normalizeContentPiecePublicFigures } from '@/lib/publicFigureCatalog' import { uniqueRelationshipIds } from '@/lib/relationship' import { SPEECH_TOPICS } from '@/lib/speechFacets' +import { TRANSCRIPT_TEXT_MAX_LENGTH } from '@/lib/speechSearch' import { canCreateContentPiece, canDeleteContentPiece, @@ -402,6 +403,7 @@ export const ContentPiece: CollectionConfig = { name: 'transcript', type: 'textarea', label: 'Transcrição / texto', + maxLength: TRANSCRIPT_TEXT_MAX_LENGTH, admin: { description: 'Transcrição do áudio/vídeo ou texto extraído. Editável pela assessoria.', }, @@ -496,6 +498,7 @@ export const ContentPiece: CollectionConfig = { name: 'searchText', type: 'textarea', label: 'Texto normalizado (busca)', + maxLength: TRANSCRIPT_TEXT_MAX_LENGTH, admin: { readOnly: true, description: diff --git a/src/collections/Recording.ts b/src/collections/Recording.ts index d318cd41..ec01eb36 100644 --- a/src/collections/Recording.ts +++ b/src/collections/Recording.ts @@ -16,6 +16,7 @@ import { } from '@/lib/recording' import { speakerNamesFromLabels } from '@/lib/recordingDiarization' import { SPEECH_CLASSIFICATION_SOURCES, SPEECH_SCOPES, SPEECH_TOPICS } from '@/lib/speechFacets' +import { TRANSCRIPT_TEXT_MAX_LENGTH } from '@/lib/speechSearch' import { canDeleteRecording, canReadRecording, @@ -200,6 +201,7 @@ export const Recording: CollectionConfig = { name: 'searchText', type: 'textarea', label: 'Texto normalizado (busca)', + maxLength: TRANSCRIPT_TEXT_MAX_LENGTH, admin: { readOnly: true, description: 'Concatenação normalizada dos segmentos (sem acentos, minúsculas).', diff --git a/src/collections/RecordingSegment.ts b/src/collections/RecordingSegment.ts index d94dbfeb..55339a0b 100644 --- a/src/collections/RecordingSegment.ts +++ b/src/collections/RecordingSegment.ts @@ -1,6 +1,6 @@ import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload' -import { normalizeForSearch } from '@/lib/speechSearch' +import { normalizeForSearch, TRANSCRIPT_TEXT_MAX_LENGTH } from '@/lib/speechSearch' import { canReadRecording, payloadAdminOnly } from '@/utilities/campaignAccess' /** @@ -80,12 +80,14 @@ export const RecordingSegment: CollectionConfig = { type: 'textarea', label: 'Texto', required: true, + maxLength: TRANSCRIPT_TEXT_MAX_LENGTH, }, { name: 'searchText', type: 'text', label: 'Texto normalizado (busca)', required: true, + maxLength: TRANSCRIPT_TEXT_MAX_LENGTH, admin: { readOnly: true, description: 'Derivado do texto (sem acentos, minúsculas) para a busca por palavra.', diff --git a/src/collections/Speech.ts b/src/collections/Speech.ts index d97bf1bb..2196b06a 100644 --- a/src/collections/Speech.ts +++ b/src/collections/Speech.ts @@ -6,6 +6,7 @@ import type { } from 'payload' import { SPEECH_CLASSIFICATION_SOURCES, SPEECH_SCOPES, SPEECH_TOPICS } from '@/lib/speechFacets' +import { TRANSCRIPT_TEXT_MAX_LENGTH } from '@/lib/speechSearch' import { INTERNET_SPEECH_MEDIA_SLUG, WEB_SPEECH_PLATFORMS } from '@/lib/webSpeech' import { canReadSpeech, canUpdateSpeech, payloadAdminOnly } from '@/utilities/campaignAccess' @@ -212,6 +213,7 @@ export const Speech: CollectionConfig = { name: 'officialTranscript', type: 'textarea', label: 'Transcrição oficial', + maxLength: TRANSCRIPT_TEXT_MAX_LENGTH, admin: { readOnly: true }, }, { @@ -238,6 +240,7 @@ export const Speech: CollectionConfig = { name: 'searchText', type: 'textarea', label: 'Texto normalizado (busca)', + maxLength: TRANSCRIPT_TEXT_MAX_LENGTH, // Optional on purpose: a speech without ASR segments legitimately has no // search text, and Payload's `required` rejects the empty string. defaultValue: '', diff --git a/src/collections/SpeechSegment.ts b/src/collections/SpeechSegment.ts index e3900c4c..671386ee 100644 --- a/src/collections/SpeechSegment.ts +++ b/src/collections/SpeechSegment.ts @@ -1,6 +1,6 @@ import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload' -import { normalizeForSearch } from '@/lib/speechSearch' +import { normalizeForSearch, TRANSCRIPT_TEXT_MAX_LENGTH } from '@/lib/speechSearch' import { canReadSpeech, payloadAdminOnly } from '@/utilities/campaignAccess' /** @@ -69,12 +69,14 @@ export const SpeechSegment: CollectionConfig = { type: 'textarea', label: 'Texto', required: true, + maxLength: TRANSCRIPT_TEXT_MAX_LENGTH, }, { name: 'searchText', type: 'text', label: 'Texto normalizado (busca)', required: true, + maxLength: TRANSCRIPT_TEXT_MAX_LENGTH, admin: { readOnly: true, description: 'Derivado do texto (sem acentos, minúsculas) para a busca por palavra.', diff --git a/src/lib/schemas/contentPiece.ts b/src/lib/schemas/contentPiece.ts index 46c6caee..b6355309 100644 --- a/src/lib/schemas/contentPiece.ts +++ b/src/lib/schemas/contentPiece.ts @@ -11,6 +11,7 @@ import { CONTENT_PIECE_TYPES, } from '@/lib/contentPiece' import { trimmedNullableText } from '@/lib/schemas/primitives' +import { TRANSCRIPT_TEXT_MAX_LENGTH } from '@/lib/speechSearch' /** The Central gate refused the actor — one literal shared with the vertical. */ export const CONTENT_PIECE_FORBIDDEN_MESSAGE = 'Seu perfil não tem acesso à Central de Conteúdos.' @@ -92,7 +93,7 @@ export const contentPieceUpdateRequestSchema = z.object({ topics: z.array(z.string()).max(32).optional(), municipalityId: z.number().int().positive().nullable().optional(), institution: trimmedNullableText(CONTENT_PIECE_INSTITUTION_MAX_LENGTH), - transcript: trimmedNullableText(200_000), + transcript: trimmedNullableText(TRANSCRIPT_TEXT_MAX_LENGTH), // S37 — who appears in the piece: the leader relation ids and the curated // public figures. The action canonicalizes the figures against the catalog. leaderIds: z.array(z.number().int().positive()).max(CONTENT_PIECE_LEADERS_MAX).optional(), diff --git a/src/lib/speechSearch.ts b/src/lib/speechSearch.ts index ec573aa0..753aa488 100644 --- a/src/lib/speechSearch.ts +++ b/src/lib/speechSearch.ts @@ -5,6 +5,8 @@ * `contains` query, so accent/case variants match the trigram index. */ +export const TRANSCRIPT_TEXT_MAX_LENGTH = Number.MAX_SAFE_INTEGER + /** NFD-strips accents, lowercases and collapses whitespace. */ export const normalizeForSearch = (value: string): string => value diff --git a/tests/int/contentPiece.int.spec.ts b/tests/int/contentPiece.int.spec.ts index b42e186f..2953e9d4 100644 --- a/tests/int/contentPiece.int.spec.ts +++ b/tests/int/contentPiece.int.spec.ts @@ -445,6 +445,21 @@ describe('content pieces (C211)', () => { expect(scheduled).toEqual([id]) }) + it('preserves a long content-piece transcript and its search text', async () => { + const longText = 'texto da peça '.repeat(3_000) + const { piece } = await createPiece({ transcript: longText }) + + const updated = await payload.findByID({ + collection: 'contentPiece', + id: piece.id, + depth: 0, + overrideAccess: true, + }) + + expect(updated.transcript).toBe(longText) + expect(updated.searchText).toContain(normalizeForSearch(longText)) + }) + it('attaches the original file to a link piece and refuses a second attachment', async () => { getCampaignUserMock.mockResolvedValue(communicator) const piece = await addContentPieceByLinkForActor({ diff --git a/tests/int/recording.int.spec.ts b/tests/int/recording.int.spec.ts index 49909893..fdcf722b 100644 --- a/tests/int/recording.int.spec.ts +++ b/tests/int/recording.int.spec.ts @@ -402,6 +402,30 @@ describe('uploaded recordings (C199)', () => { expect(rows.docs).toHaveLength(0) }) + it('preserves a long recording transcript and its search text', async () => { + const longText = 'texto da gravação '.repeat(3_000) + const { recording } = await createRecording({ + segments: [{ startSeconds: 0, endSeconds: 120, text: longText }], + }) + + const updated = await payload.findByID({ + collection: 'recording', + id: recording.id, + depth: 0, + overrideAccess: true, + }) + const segments = await payload.find({ + collection: 'recordingSegment', + where: { recording: { equals: recording.id } }, + depth: 0, + overrideAccess: true, + }) + + expect(updated.searchText).toBe(normalizeForSearch(longText)) + expect(segments.docs[0]?.text).toBe(longText) + expect(segments.docs[0]?.searchText).toBe(normalizeForSearch(longText)) + }) + it('runs the transcription job end to end with merged timestamps', async () => { const { recording } = await createRecording({ status: 'processing', diff --git a/tests/int/speechImport.int.spec.ts b/tests/int/speechImport.int.spec.ts index 46df52e9..e3a602ed 100644 --- a/tests/int/speechImport.int.spec.ts +++ b/tests/int/speechImport.int.spec.ts @@ -5,6 +5,7 @@ import { getPayload } from 'payload' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { matchMunicipalityMentions } from '@/lib/speechGazetteer' +import { TRANSCRIPT_TEXT_MAX_LENGTH } from '@/lib/speechSearch' import config from '@/payload.config' import { findSpeechImportState, @@ -55,6 +56,15 @@ describe('upsertSpeechBundle (C153)', () => { } }) + it('keeps the global text cap and unrelated fields unchanged', () => { + expect(payload.config.defaultMaxTextLength).toBe(40_000) + expect(TRANSCRIPT_TEXT_MAX_LENGTH).toBe(Number.MAX_SAFE_INTEGER) + const summary = payload.collections.speech.config.fields.find( + (field) => 'name' in field && field.name === 'summary', + ) + expect(summary && 'maxLength' in summary ? summary.maxLength : undefined).toBeUndefined() + }) + it('creates once and replaces segments on re-run without duplicating', async () => { const bundle = baseBundle() @@ -105,6 +115,28 @@ describe('upsertSpeechBundle (C153)', () => { expect(preserved?.searchText).toBe('a saude publica baiana educacao em feira de santana') }) + it('preserves a long raw transcript and its normalized text', async () => { + const officialTranscript = 'transcrição oficial '.repeat(4_000) + const segmentText = 'texto do segmento '.repeat(4_000) + const bundle = baseBundle({ + officialTranscript, + segments: [{ startSeconds: 0, endSeconds: 120, text: segmentText }], + }) + + await upsertSpeechBundle(payload, bundle) + const speech = await findBySourceKey(bundle.sourceKey) + const segments = await payload.find({ + collection: 'speechSegment', + where: { speech: { equals: speech?.id } }, + depth: 0, + overrideAccess: true, + }) + + expect(speech?.officialTranscript).toBe(officialTranscript) + expect(speech?.searchText).toBe(segmentText.trim()) + expect(segments.docs[0]?.text).toBe(segmentText) + }) + it('preserves manual facets and skips facet writes', async () => { const bundle = baseBundle() await upsertSpeechBundle(payload, bundle) diff --git a/tests/int/webSpeechAcervo.int.spec.ts b/tests/int/webSpeechAcervo.int.spec.ts index 051b77ee..f1a44295 100644 --- a/tests/int/webSpeechAcervo.int.spec.ts +++ b/tests/int/webSpeechAcervo.int.spec.ts @@ -181,6 +181,24 @@ describe('web speech acervo (C216)', () => { expect(camara.rows.map((row) => row.id)).toEqual([camaraId]) }) + it('finds a web speech by a term in a transcript over 40,000 characters', async () => { + const marker = `long-final-${randomUUID().slice(0, 8)}` + const longText = 'palavra '.repeat(3_000) + const id = await createWebSpeech({ + segments: [ + { startSeconds: 0, endSeconds: 120, text: `${longText} primeiro` }, + { startSeconds: 120, endSeconds: 240, text: `${longText} segundo` }, + { startSeconds: 240, endSeconds: 360, text: `${longText} ${marker}` }, + ], + }) + + const { communicator } = await createUsers() + const data = await loadWebSpeechAcervoPageData(payload, communicator, { q: marker }) + + expect(data.totalDocs).toBe(1) + expect(data.rows.map((row) => row.id)).toEqual([id]) + }) + it('filters by topic, year, duration and cited municipality over the web catalog', async () => { const id = await createWebSpeech({ speechAt: '2021-04-12T00:00', diff --git a/tests/int/webSpeechIngest.int.spec.ts b/tests/int/webSpeechIngest.int.spec.ts index ca71dcd1..dd4236c4 100644 --- a/tests/int/webSpeechIngest.int.spec.ts +++ b/tests/int/webSpeechIngest.int.spec.ts @@ -7,6 +7,7 @@ import { getPayload } from 'payload' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { matchMunicipalityMentions } from '@/lib/speechGazetteer' +import { normalizeForSearch } from '@/lib/speechSearch' import { INTERNET_SPEECH_MEDIA_SLUG, type WebSpeechFinding } from '@/lib/webSpeech' import type { Speech } from '@/payload-types' import config from '@/payload.config' @@ -45,6 +46,8 @@ const TEST_SOURCE_KEYS = [ 'web:youtube:https://www.youtube.com/watch?v=thumb-keep', 'web:youtube:broken', 'web:youtube:https://www.youtube.com/watch?v=broken', + 'web:youtube:long', + 'web:youtube:https://www.youtube.com/watch?v=long', 'web:youtube:semvoz', 'web:youtube:https://www.youtube.com/watch?v=semvoz', 'web:radio:https://radio.example/entrevista', @@ -190,6 +193,72 @@ describe('ingestWebSpeech (C215)', () => { expect(await countSegments(again?.id ?? 0)).toBe(1) }) + it('ingests and re-runs a long transcript without truncating it', async () => { + acquireCalls.length = 0 + const marker = 'long-final-marker' + const longText = 'a'.repeat(20_000) + const longSegments = [ + { start: 0, end: 120, text: `${longText} primeiro` }, + { start: 120, end: 240, text: `${longText} segundo` }, + { start: 240, end: 360, text: `${longText} ${marker}` }, + ] + const longSearchText = normalizeForSearch(longSegments.map((segment) => segment.text).join(' ')) + const current = finding({ + externalId: 'long', + url: 'https://www.youtube.com/watch?v=long', + }) + const longTranscriber: WebSpeechTranscriber = async () => ({ + ok: true, + segments: longSegments, + durationSeconds: 3_600, + }) + + const first = await ingestWebSpeech(payload, current, { + deps: deps({ transcribe: longTranscriber }), + }) + expect(first).toMatchObject({ status: 'created', stage: null, error: null }) + + const speech = await findBySourceKey('web:youtube:long') + expect(speech?.searchText).toBe(longSearchText) + const mirroredMedia = speech?.mirroredMedia + const mediaId = mirroredMedia && typeof mirroredMedia === 'object' ? mirroredMedia.id : null + expect(mediaId).not.toBeNull() + const segments = await payload.find({ + collection: 'speechSegment', + where: { speech: { equals: speech?.id } }, + depth: 0, + sort: 'order', + overrideAccess: true, + }) + expect(segments.docs.map((segment) => segment.text)).toEqual( + longSegments.map((segment) => segment.text), + ) + expect(await countSegments(speech?.id ?? 0)).toBe(3) + expect(acquireCalls).toHaveLength(1) + + const second = await ingestWebSpeech(payload, current, { + deps: deps({ transcribe: longTranscriber }), + }) + expect(second).toMatchObject({ status: 'skipped', stage: null, error: null }) + const again = await findBySourceKey('web:youtube:long') + expect(again?.id).toBe(speech?.id) + expect(again?.searchText).toBe(longSearchText) + const againMedia = again?.mirroredMedia + expect(againMedia && typeof againMedia === 'object' ? againMedia.id : null).toBe(mediaId) + const againSegments = await payload.find({ + collection: 'speechSegment', + where: { speech: { equals: again?.id } }, + depth: 0, + sort: 'order', + overrideAccess: true, + }) + expect(againSegments.docs.map((segment) => segment.text)).toEqual( + longSegments.map((segment) => segment.text), + ) + expect(await countSegments(speech?.id ?? 0)).toBe(3) + expect(acquireCalls).toHaveLength(1) + }) + it('reprocesses on demand and removes the replaced artifact', async () => { acquireCalls.length = 0 const current = finding({ diff --git a/tests/unit/contentPiece.unit.spec.ts b/tests/unit/contentPiece.unit.spec.ts index eab8c530..e50ad657 100644 --- a/tests/unit/contentPiece.unit.spec.ts +++ b/tests/unit/contentPiece.unit.spec.ts @@ -27,6 +27,7 @@ import { sanitizeContentPieceFilename, toContentPieceViewModel, } from '@/lib/contentPiece' +import { contentPieceUpdateRequestSchema } from '@/lib/schemas/contentPiece' import { buildContentPieceListWhere, parseContentPieceListParams, @@ -55,6 +56,18 @@ describe('content piece vocabulary', () => { expect(isContentPieceTopic('nao-existe')).toBe(false) }) + it('accepts a long transcript in the update schema', () => { + const transcript = 'texto da peça '.repeat(25_000) + expect(() => + contentPieceUpdateRequestSchema.parse({ + contentPieceId: 1, + title: 'Peça longa', + type: 'video', + transcript, + }), + ).not.toThrow() + }) + it('labels the four peça-link reasons verbatim (gate copy)', () => { expect(contentPieceLinkFailureReasonLabels).toEqual({ 'nao-encontrado': 'Link não encontrado entre as mídias recentes do perfil',