mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
fix(C223): aceita falas longas no acervo
This commit is contained in:
parent
4678117b08
commit
cb3b2083cd
15 changed files with 341 additions and 3 deletions
1
docs/changelog/2026-09-25-c223.md
Normal file
1
docs/changelog/2026-09-25-c223.md
Normal 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.
|
||||
151
docs/plans/falas-web-texto-normalizado-limite-impl.md
Normal file
151
docs/plans/falas-web-texto-normalizado-limite-impl.md
Normal 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.**
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue