fix(C223): aceita falas longas no acervo

This commit is contained in:
Francisco Solla 2026-09-25 01:48:17 -03:00
parent 4678117b08
commit cb3b2083cd
15 changed files with 341 additions and 3 deletions

View file

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

View file

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

View file

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

View file

@ -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).',

View file

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

View file

@ -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: '',

View file

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

View file

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

View file

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

View file

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

View file

@ -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',

View file

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

View file

@ -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',

View file

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

View file

@ -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',