mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(C200): separar falas por pessoa nas gravações enviadas (Closes #1167)
This commit is contained in:
parent
78598906de
commit
7e2dd3a4cb
38 changed files with 19479 additions and 97 deletions
1
docs/changelog/2026-09-19-c200.md
Normal file
1
docs/changelog/2026-09-19-c200.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C200 — Separar falas por pessoa nas gravações enviadas (2026-09-19):** as gravações do C199 saíam com a transcrição em bloco único e a assessoria não conseguia responder "o que fulano disse nesta gravação?". Entregou-se a camada de quem-falou-o-quê com agrupamento **acústico** e identificação **humana** (sem voiceprint, sem match voz↔pessoa, sem `Contact`): (1) **diarização** — o job ganhou uma etapa opcional atrás de um seam injetável (`SpeakerDiarizer`); o áudio extraído inteiro (uma passada, para a numeração ser global) vai ao provedor recomendado **AssemblyAI** `universal-2` + `speaker_labels` (US$0,17/h; chave `ASSEMBLYAI_API_KEY` ainda não configurada — sem chave/falha, a transcrição continua em bloco, nunca finge separação), e os turnos são alinhados aos segmentos do Whisper por sobreposição máxima (empate pelo ponto médio, sem sobreposição → turno mais próximo) com numeração `speaker-N` por primeira aparição; (2) **schema/migration** `20260919_032729_add_recording_speakers` — `recording_segment.speaker_key`, `recording.speaker_labels` (`{speakerKey,label}[]`), `recording.speaker_names` (`hasMany` derivado por hook, base da faceta) e `recording.speaker_labels_dropped`; o SQL duplicado de `reel.source_hash` que o snapshot defasado do C199 gerava foi removido à mão e o snapshot desta migration o corrige; (3) **reprocessamento não perde rótulo**: `reconcileSpeakerLabels` (puro) só mantém um rótulo em casamento mútuo 1:1 por sobreposição de tempo — split/merge descartam com aviso `speakerLabelsDropped` renderizado no próprio banner; (4) **faceta "Pessoa"** na fonte enviadas: param repetível `person` no contrato da lista (`contains` case-insensitive sobre `speaker_names`), chips removíveis (design cena 3), chip "aparece nesta gravação" no card e preservação do filtro ao submeter a busca; (5) **ação "Identificar falante"** (`POST .../gravacoes/[id]/falantes`) — rotula um agrupamento validando que a chave ainda existe na gravação, gate fail-closed do acervo (a escrita usa o predicado do dono; `advisor`/`leader` negados) e limpa o aviso de reconciliação; (6) **design C portado classe-a-classe** das cenas 1–5 (blocos por falante com avatar/estado/edição, diálogo de identificação, aviso âmbar de imprecisão sempre visível, mobile compacto); (7) **testes**: 17 unit novos (alinhamento/numeração/reconciliação/URL/where/provedor com fetch mockado), 4 int novos (faceta no join real, matriz de papéis da action, job com diarizador fake preservando rótulo e fallback com aviso) e 5 e2e novos na vertical; migração aplicada só em banco local, nenhuma gravação antiga reprocessada automaticamente.
|
||||
239
docs/plans/acervo-separacao-por-pessoa-impl.md
Normal file
239
docs/plans/acervo-separacao-por-pessoa-impl.md
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
# Impl: C200 — Separar falas por pessoa nas gravações enviadas
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-09-19
|
||||
Issue: #1167
|
||||
Intenção: docs/plans/acervo-separacao-por-pessoa.md
|
||||
Appetite restante: herdado (~2–3 dias eng; um outcome verificável — a assessoria identifica quem fala numa gravação enviada e filtra o acervo pelas falas de uma pessoa)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** gravações novas do acervo (fonte "Gravações enviadas", C199) passam a ter a transcrição **dividida por agrupamento acústico** ("Falante 1", "Falante 2", …); a assessoria ouve e usa "Identificar falante" para nomear um agrupamento, valendo para todas as falas dele naquela gravação; a lista ganha a faceta **"Pessoa"** que recorta as gravações onde aquele rótulo aparece. A busca geral continua cobrindo a transcrição inteira.
|
||||
- **O que NÃO negociar:** agrupamento é acústico e a identificação é **humana** (nada de voiceprint, match voz↔pessoa, inferência ou sugestão automática); rótulo é texto por gravação, **sem segundo cadastro de pessoa** (`Contact` intocado); rótulo não preenchido permanece "Falante N"; sem `Consent` novo (mídia interna de staff); sem score de confiança de "quem falou" na UI; gate fail-closed do acervo (`communicator` + `coordinator`/`candidate`; `advisor`/`leader` negados); nada de backfill automático das gravações antigas; sem UI nova de reprocessar/diarizar fora do design aprovado.
|
||||
- **O que reavaliar** (hipóteses da intenção):
|
||||
- "O import substitui os segmentos em bloco — um rótulo só sobrevive se o reprocessamento carregar/re-aplicar" (intenção): confirmado; a reconciliação é decisão desta impl (D6). Nota de realidade: hoje o lifecycle **não tem** caminho `ready → processing` (retry só `failed`, e falha não grava segmentos), então a reconciliação é um contrato do job testado no int, pronto para a ação futura de reprocessar/diarizar (§D8).
|
||||
- "A fonte enviadas já tem o padrão de chips/omnibox" (achado do explorador): **não tem** — hoje é só `CampaignSearchInput`. A faceta nova reusa o **`CampaignHeaderFilterPopover`** (multi-seleção) com adaptação local, sem importar o omnibox da Câmara.
|
||||
- "Reusar o pipeline de transcrição por chunks para diarizar": não serve — chaves por chunk não se ligam entre chunks (ver D2).
|
||||
- "Provedor de diarização" (questão em aberto da intenção): resolvido em D1/D2 (provedor externo, áudio inteiro numa passada) e registrado como **GATE humano de custo/chave**.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
U[Upload C199] --> J[recordingJob]
|
||||
J -->|"ffmpeg: mp3 mono 16k / chunks 20min"| W["Deep Infra Whisper<br/>deepInfraTranscribeSegments"]
|
||||
W --> M[mergeChunkTranscriptions]
|
||||
J -->|"se há provedor: full.mp3 numa passada ffmpeg"| D["AssemblyAI universal-2<br/>language_code=pt, speaker_labels"]
|
||||
D -->|"utterances speaker/start/end"| A["assignSpeakerKeys<br/>alinhamento por sobreposicao"]
|
||||
M --> A
|
||||
A --> T[["tx: recordingSegment.speakerKey<br/>+ recording.speakerLabels reconciliadas"]]
|
||||
D -.->|"falha, timeout ou sem chave"| F["fallback honesto:<br/>transcricao sem agrupamento"]
|
||||
F --> T
|
||||
T --> R[(recording / recordingSegment)]
|
||||
L["Lista ?source=enviadas&person=..."] -->|"speakerNames contains"| R
|
||||
P["Detalhe /gravacoes/id"] -->|"blocos por falante"| R
|
||||
DLG["Dialogo Identificar falante"] -->|"POST /gravacoes/id/falantes"| AC[labelRecordingSpeakerForActor]
|
||||
AC --> R
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C em cada decisão abaixo.
|
||||
**Recomendação:** manter o Whisper/Deep Infra como dono da transcrição e adicionar uma **etapa opcional de diarização** sobre o áudio inteiro (AssemblyAI, `universal-2`, `speaker_labels`), **alinhando os turnos aos segmentos existentes** por sobreposição de tempo; chave do agrupamento no `recordingSegment`, rótulos na `recording` com a faceta denormalizada em `speakerNames` (`hasMany text` + `contains`, precedente `Speech.keywords`); reconciliação conservadora de rótulos no job + aviso; sem provedor/chave, a transcrição sai **sem agrupamento** (nunca fingir separação).
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
**D1 — Provedor de diarização (pesquisa web, números oficiais).**
|
||||
`Opções: A) AssemblyAI | B) Deepgram | C) Gladia | D) Speechmatics | E) diarização local no homeserver | F) LLM de áudio | G) Deep Infra (atual)`
|
||||
|
||||
`Recomendação: A — AssemblyAI é o único com preço/hora mais baixo entre os verificados, aceita arquivos de até 10 h (cobre plenária inteira numa passada), tem pt com WER ≤10% declarado, diarização explícita no modelo universal-2 e fluxo assíncrono com polling (submit → poll), que é exatamente a forma do job C199 (heartbeat por atualização do updatedAt).`
|
||||
|
||||
| Provedor | Diarização | Preço oficial (pré-gravado) | pt-BR | Limite por arquivo | Forma do fluxo |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| **AssemblyAI** | `speaker_labels: true` (`universal-2`, `universal-3-5-pro`) | **US$0,15/h** (`universal-2`) + aditivo diarização standard **+US$0,02/h = US$0,17/h** ≈ US$0,0028/min | `pt` com "high accuracy (≤10% WER)"; `universal-3-5-pro` cita dialeto pt-BR | **10 h** por arquivo; 5 GB via URL/2,2 GB no `/v2/upload` | `POST /v2/upload` → `POST /v2/transcript` → poll; `utterances[]` com `speaker`/`start`/`end` (ms) |
|
||||
| Deepgram | `diarize_model=latest` | Nova-3 pré-gravado **US$0,0043/min = US$0,258/h** (diarização incluída no pré-gravado) | `pt-BR` na lista do nova-3 | não verificado nesta sessão | `POST /v1/listen` único e síncrono (ou callback) |
|
||||
| Gladia | incluída (pyannoteAI Precision-2) | async **US$0,61/h** | 100+ idiomas | **135 min** / 1 000 MB por request | upload → `/v2/pre-recorded` → poll |
|
||||
| Speechmatics | incluída | Melia 1 batch **a partir de US$0,129/h** (em _production preview_ oficial) | 55+ idiomas | batch, limites no portal | submit → poll |
|
||||
| Deep Infra (atual) | **não diariza** — catálogo ASR verificado ao vivo (6 modelos, nenhum com diarização) | US$0,00045/min no Whisper large-v3 | pt | chunks de 20 min (C199) | já é o ASR do job |
|
||||
| Local (pyannote/sherpa-onnx) | sim (montagem própria) | sem API; custo de ops/CPU/modelos | depende do modelo | — | processo/serviço Python no homeserver |
|
||||
| LLM de áudio (Gemini/OpenAI) | **não é diarização acústica** | por token | sim | — | áudio inteiro ao LLM |
|
||||
|
||||
- **A (recomendada):** `utilities/ai/assemblyAiDiarize.ts` como dono do provedor (forma de `deepInfraTranscribe.ts`), `ASSEMBLYAI_API_KEY` nova. A diarização roda só com chave; a política de delete do transcript remoto (`DELETE /v2/transcript/{id}`) é best-effort pós-leitura (higiene), e **jamais** se liga `speech_understanding.speaker_identification` (nome automático — proibido pelo guardrail).
|
||||
- **Rejeitadas:**
|
||||
- **B** porque custa ~50% mais por hora (US$0,258 vs US$0,17) e é chamada síncrona de horas — sem o polling nativo que dá heartbeat barato ao job; qualidade comparável e pt-BR ok, então fica como **alternativa de segunda escolha no gate**.
|
||||
- **C** porque o async aceita no máximo **135 min/1 000 MB** por request (docs oficiais): uma plenária de horas exigiria fatiar e a numeração global entre fatias volta a ser um palpite (o problema que D2 elimina); além de ~3,6× o custo.
|
||||
- **D** porque o modelo de menor preço (Melia 1) está em _production preview_ oficial e o tier de maior acurácia (Enhanced) não publica um rate card único e estável nas fontes oficiais consultadas; sem vantagem decisiva sobre A.
|
||||
- **E** porque o homeserver não tem GPU e montar pyannote/sherpa-onnx adiciona runtime Python, download de pesos e um domínio de falha de ops sem seam de teste; revisitar só com custo/volume material ou mudança de política de dados.
|
||||
- **F** porque LLM de áudio atribui falante probabilisticamente (alucina atribuição e nome), não entrega turnos com timestamp confiável e colidiria com a regra OPS123 (provider `openai/*` reservado a design no Teqo).
|
||||
- **G** porque capacidades verificadas: nenhum dos 6 modelos ASR do catálogo Deep Infra diariza; uma chave nova no mesmo provedor não resolve.
|
||||
|
||||
**D2 — Escopo da diarização: áudio inteiro vs por chunk de 20 min.**
|
||||
`Opções: A) uma passada no áudio inteiro | B) por chunk de 20 min com numeração global por ordem de primeira aparição | C) por chunk com re-associação por embedding entre chunks`
|
||||
|
||||
`Recomendação: A — o agrupamento é uma propriedade do áudio inteiro; diarizar por chunk gera chaves que não se ligam entre chunks e repetir "Falante 1" em cada chunk refere-se a pessoas diferentes (enganoso, contra o guardrail). O áudio extraído (mono 16 kHz, 32 kbps ≈ 14 MB/h) fica muito abaixo do limite de tamanho do provedor e dentro das 10 h de duração; acima de 10 h o job pula a diarização (fallback honesto).`
|
||||
|
||||
- **Rejeitadas:** **B** porque "Falante N" por chunk com numeração global por ordem de aparição **não** garante a mesma pessoa (sem biometria não há re-associação) e mente para a assessoria; **C** porque re-associação por embedding é voiceprint — proibida pelo produto e pela intenção.
|
||||
|
||||
**D3 — Papel do provedor: diarizar e alinhar vs substituir o ASR.**
|
||||
`Opções: A) provedor só fornece turnos; alinhamento aos segmentos do Whisper | B) provedor passa a ser o ASR da gravação (utterances viram segmentos) | C) provedor só quando há chave; Whisper quando não há`
|
||||
|
||||
`Recomendação: A — o Whisper continua dono do texto, do searchText e dos timestamps por segmento (C199 pinado); o provedor entra apenas como camada de agrupamento. Falha do provedor não afeta a transcrição, e o alinhamento vira uma função pura testável sem rede.`
|
||||
|
||||
- **Rejeitadas:** **B** porque troca o dono do texto, muda o custo do caminho feliz (~10× por minuto), reescreve a busca e faria a falha do provedor derrubar a transcrição inteira; **C** porque duas qualidades de transcrição no mesmo acervo sem o usuário saber, e dois donos do texto quebrando pinos C199.
|
||||
|
||||
**D4 — Modelo de dados dos rótulos.**
|
||||
`Opções: A) estado na recording (array `speakerLabels`) + `speakerNames` denormalizado (hasMany text) + chave no segmento | B) rótulo copiado em cada segmento | C) collection nova de rótulos | D) campo JSON`
|
||||
|
||||
`Recomendação: A — exatamente o que a tarefa fixou: recordingSegment.speakerKey (chave do agrupamento) + estado de rótulos na recording; a faceta "Pessoa" usa speakerNames (text hasMany → text[] no Postgres; precedente Activity.tags/Speech.keywords) com contains (ILIKE), funcionando no Payload 3.82.`
|
||||
|
||||
- A: `speakerLabels: { speakerKey, label }[]` (readOnly no admin; uma linha por agrupamento identificado) e hook `deriveSpeakerNames` preenchendo `speakerNames` (dedupe case-insensitive, ordem de aparição) — espelho de `RecordingSegment.deriveSearchText`. `speakerLabelsDropped: boolean` guarda o aviso de reconciliação (D6). Segmentos guardam só a chave.
|
||||
- **Rejeitadas:** **B** porque renomear um agrupamento tocaria N segmentos e a faceta viraria um join caro; **C** é collection nova para um estado 1:N simples (custo de access/index/migration sem ganho) e foge da direção da tarefa; **D** não tem validação/índice nem precedente de query de faceta.
|
||||
|
||||
**D5 — Semântica da faceta "Pessoa".**
|
||||
`Opções: A) `{ speakerNames: { contains: valor } }`OR entre valores selecionados | B)`{ speakerNames: { in: valores } }` exato | C) normalizar rótulo em slug na gravação`
|
||||
|
||||
`Recomendação: A — o precedente da casa (speechListFilters.ts:70, Speech.keywords contains) é case-insensitive e tolera variação ("Solla" casa "Dep. Jorge Solla"); a URL guarda o texto literal, a curadoria varia o rótulo e o recorte não fragmenta.`
|
||||
|
||||
- **Rejeitadas:** **B** porque a variante livre ("Solla" vs "Dep. Jorge Solla") fragmentaria a faceta (anti-goal de curadoria acidental); **C** porque cria uma segunda representação do rótulo e um merge implícito que a intenção cortou.
|
||||
|
||||
**D6 — Preservação de rótulos ao reprocessar.**
|
||||
`Opções: A) reconciliação por sobreposição de tempo, conservadora 1:1, com flag + aviso | B) casar por igualdade de speakerKey | C) aceitar a perda silenciosa | D) bloquear o reprocessamento quando há rótulo`
|
||||
|
||||
`Recomendação: A — sem biometria não há re-associação entre execuções; carregar um rótulo só quando um agrupamento novo casa 1:1 por tempo com um antigo é o contrato honesto. Quem não casa fica sem nome e o job liga speakerLabelsDropped, que rende o aviso no detalhe.`
|
||||
|
||||
- A: o job lê labels + segmentos antigos **antes** de apagar, computa `reconcileSpeakerLabels` (puro: sobreposição acumulada por par, 1:1, ordenado por primeira aparição) dentro da mesma transação e grava `speakerLabels` reconciliadas + `speakerLabelsDropped`. O aviso vive **dentro** do banner âmbar da Cena 4 (uma linha condicional — ver §UI).
|
||||
- **Rejeitadas:** **B** porque a chave é por execução e a mesma chave pode referir outra pessoa (o aceite manda re-vincular com aviso, não fingir); **C** viola o aceite; **D** porque o reprocessamento de hoje só existe para `failed` (que não tem rótulo) e, na ação futura, bloquear sem alternativa é pior que reconciliar e avisar.
|
||||
|
||||
**D7 — Fallback sem provedor/chave/timeout.**
|
||||
`Opções: A) transcrição sem agrupamento (estado C199, sem "Falante") | B) falhar a gravação | C) criar um "Falante 1" único`
|
||||
|
||||
`Recomendação: A — o acervo continua útil e a ausência de blocos não afirma nada; é exatamente o detalhe C199 já aprovado, sem markup novo.`
|
||||
|
||||
- **Rejeitadas:** **B** porque faz a transcrição (que não depende do provedor) depender de um custo humano ainda não aprovado; **C** porque sugere um falante único onde há vários — fingir separação, proibido.
|
||||
|
||||
**D8 — Reprocessar/diarizar gravações antigas.**
|
||||
`Opções: A) ação explícita por gravação agora | B) backfill automático | C) sem ação agora; follow-up com gatilho | D) UI de reprocessar junto com este item`
|
||||
|
||||
`Recomendação: C — a tarefa veta UI nova de reprocessar fora do design e a intenção veta backfill. A reconciliação (D6) fica pronta no job para quando a ação existir.`
|
||||
|
||||
- **Gatilho do follow-up:** chave do provedor ativa em produção **e** (pedido operacional de diarizar o acervo antigo **ou** passarem ~30 gravações novas sem diarização). Aí o designer estende o artefato **antes** do markup (trigger a). **Débito absorvido pelo D8 (triagem do /simplify):** quando a ação de reprocessar existir, a semântica de limpar `speakerLabelsDropped` no rótulo precisa considerar rótulos ainda perdidos (hoje inalcançável: não há `ready → processing`).
|
||||
- **Débito absorvido pelo C202:** a action de rótulo gateia no action-level com `canReadCommunicationCatalog` (a collection já exige `canUploadRecording`); a troca pelo predicado de escrita do dono foi adicionada ao plano `docs/plans/gates-gravacoes-predicados.md` (#1186).
|
||||
- **Rejeitadas:** **B** porque o custo e o risco de mexer em rótulos não justificam varrer tudo; **A/D** porque contrariam a restrição de superfície do item.
|
||||
|
||||
## Componentes / mudanças
|
||||
|
||||
- **`src/lib/recordingDiarization.ts`** (novo, puro/client-safe): `DiarizedTurn`, `assignSpeakerKeys` (sobreposição máxima; empate pelo ponto médio; sem sobreposição → turno mais próximo; numeração global `speaker-1…` por primeira aparição), `reconcileSpeakerLabels` (D6), `speakerNamesFromLabels`, `recordingSpeakerDefaultLabel(n)` → `Falante ${n}`, `RECORDING_DIARIZATION_MAX_SECONDS = 10h` (limite do provedor citado em comentário). Mesmo padrão de dono dos puros de `lib/recordingTranscription.ts`.
|
||||
- **`src/lib/recordingTranscription.ts`** (editar): `buildRecordingFullAudioFfmpegArgs` (mp3 mono 16k/32k sem segmentação) ao lado do args de chunks; merge/searchText intocados.
|
||||
- **`src/lib/recording.ts`** (editar): `RECORDING_SPEAKER_LABEL_MAX_LENGTH = 120`, mensagens pt-BR do diálogo/action, nada de status novo.
|
||||
- **`src/utilities/ai/assemblyAiDiarize.ts`** (novo, `server-only`): dono do provedor (molde `deepInfraTranscribe.ts:98-137`) — upload → transcript (`language_code: 'pt'`, `speaker_labels: true`, sem identificação automática) → poll com deadline de 60 min e intervalo de 5 s; normalização ms→s; nunca lança (`{ ok, turns }` / `{ ok:false, error, status }`); `DELETE` best-effort do transcript; chave ausente → 503. Exporta `assemblyAiDiarize` e `configuredSpeakerDiarizer()` (null sem chave).
|
||||
- **`src/utilities/recordings/recordingJob.ts`** (editar): `runRecordingJob(payload, id, transcribe = deepInfraTranscribeSegments, diarize = configuredSpeakerDiarizer())`; extrai `full.mp3` só quando `diarize` existe e a duração ≤ 10 h, chama o provedor (heartbeat `step:'transcribing'`), alinha com `assignSpeakerKeys`, e na transação já existente (`recordingJob.ts:191-227`) lê o estado antigo, apaga/recria segmentos com `speakerKey` e grava `speakerLabels`/`speakerLabelsDropped`. Falha/timeout do provedor **não** marca `failed`.
|
||||
- **`src/collections/Recording.ts`** (editar): `speakerLabels` (array `{ speakerKey, label }`, readOnly), `speakerNames` (`text` `hasMany`, readOnly), `speakerLabelsDropped` (`checkbox`, readOnly) + hook `deriveSpeakerNames` (fallback `originalDoc` como `deriveSearchText`).
|
||||
- **`src/collections/RecordingSegment.ts`** (editar): `speakerKey` (`text`, opcional, readOnly).
|
||||
- **`src/utilities/recordings/recordingListUrl.ts`** (editar): param `person` (repetível) no `RecordingListState`, parse com `allParamValues` + trim + cap 120 + dedupe, serialize com `append`, `toggleRecordingPerson`/`removeRecordingPerson` puros (page reset) — o "adapter" da faceta mora aqui em vez de um módulo espelho de `speechOmnibox.ts` (depth check).
|
||||
- **`src/utilities/recordings/recordingListFilters.ts`** (editar): `buildRecordingListWhere` AND-e `searchText like` + `{ or: persons.map(p => ({ speakerNames: { contains: p } })) }`.
|
||||
- **`src/utilities/recordings/recordingViewModels.ts`** (editar): `RecordingSpeakerGroupViewModel` (`key`, `index`, `defaultLabel`, `label`, `isIdentified`, `segments`), `RecordingDetailViewModel.speakerGroups` + `speakerLabelsDropped`; `RecordingListItemViewModel.matchedPersons`; agrupamento por primeira aparição; caminho sem chave renderiza a lista plana C199.
|
||||
- **`src/utilities/recordings/recordingPageData.ts`** (editar): selects (`speakerNames`, `speakerLabels`, `speakerLabelsDropped`, `speakerKey`), `loadRecordingFilterOptions` (select de `speakerNames` com `pagination:false`, dedupe e sort pt-BR — molde `speechPageData.ts:85-121`), `matchedPersons` por linha (puro `contains` case-insensitive espelhando o where).
|
||||
- **`src/app/(campaign)/campanha/actions/recording.ts`** (editar): `labelRecordingSpeakerForActor({ recordingId, speakerKey, label })` — gate pelo **predicado de escrita do dono** (C202, §Dependências), valida que a chave existe em algum segmento da gravação, faz read-modify-write do array `speakerLabels` e limpa `speakerLabelsDropped` (o ato humano reconhece/revisa); 1 collection → sem transação.
|
||||
- **`src/lib/schemas/recording.ts`** (editar): `recordingSpeakerLabelRequestSchema` (id, `speakerKey` `^speaker-\d{1,3}$`, label 1..120 trim) + mensagens seguras.
|
||||
- **Rotas** (`.../acervo/gravacoes/[id]/falantes/route.ts`, novo): `POST` via `campaignJsonMutationRoute` (sem allowlist de conventions — rota wrapper, não crua), molde de `[id]/retry/route.ts`; `types.ts` ganha `RecordingSpeakerLabelResponse`; `lib/campaignPaths.ts` ganha `campaignRecordingSpeakersHref`.
|
||||
- **UI** (`src/components/campaign/recording/`): `RecordingSpeakerTranscript.tsx` (novo, client; blocos + banner âmbar + botões), `RecordingSpeakerDialog.tsx` (novo, client; molde `SpeechCutDeleteDialog` + `postCampaignJson`), `RecordingPersonFilter.tsx` (novo, client; `CampaignHeaderFilterPopover` + `useCampaignListFilterNavigation`), edições em `RecordingDetailPlayer.tsx` (renderiza grupos quando existem; senão o `<ol>` atual), `RecordingResultCard.tsx` (chip de pessoa) e `acervo/page.tsx` (hidden inputs de `person` no form de busca, linha da faceta, vazio com filtro).
|
||||
- **Manifest e2e** (`scripts/lib/e2e-affected-manifest.mjs`): **sem mudanças esperadas** — `src/utilities/ai` (linhas 261-265) já mapeia `campaignSpeechAcervo`, e `src/utilities/recordings`/`src/components/campaign/recording`/`src/lib/recording` (322-336) já cobrem os arquivos novos; confirmar no gate.
|
||||
- **Migration:** `add_recording_speakers` (abaixo).
|
||||
- **Access / Consent:** sem `Consent` novo; `utilities/access/recordings.ts` intocado neste item (C202 é o dono da ampliação de gate); a action de rótulo é a única superfície nova e usa o predicado de escrita do dono.
|
||||
- **UI:** Impeccable **C** — port do artefato + shells (`CampaignHeaderFilterPopover`, `CampaignListPendingBoundary`, `CampaignListEmptyState`, `CampaignListFooter`, `Dialog`, `Alert`, `Spinner`); shape→craft→critique→polish no ciclo.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
- **N/A por decisão da intenção** (`acervo-separacao-por-pessoa.md:59`): filtro por pessoa é recorte de conteúdo, não métrica/agregado; nenhuma confiança numérica é exposta (o produto proíbe score de "quem falou"). Nada a apresentar; nenhuma forma nova.
|
||||
|
||||
## UI e port do design (fonte de verdade: `acervo-separacao-por-pessoa-ui-design.html`)
|
||||
|
||||
**Coberto pelo artefato — port classe-a-classe:**
|
||||
|
||||
1. **Cena 1 (detalhe desktop, agrupado):** grid `grid-cols-[minmax(0,1.05fr)_minmax(430px,.95fr)]`, mídia à esquerda (player + `Baixar` + banner informativo **literal** "Os falantes são agrupados automaticamente pelo áudio. A identificação de cada pessoa é feita pela equipe.") e coluna direita com heading uppercase "Transcrição por falante", hint "Clique para posicionar" e `article.speaker-block` por grupo: head (`size-7` avatar, `Falante N`/rótulo, sub "Agrupamento sem identificação"/"Identificação feita pela equipe", botão outline "Identificar falante"/"Editar identificação") e linhas `seg` (grid `3.25rem 1fr`, `data-start-seconds`, ativo com `bg-muted`, hora em `text-primary` quando ativo, texto com `SpeechHighlightParts`).
|
||||
2. **Cena 2 (diálogo):** `Dialog` com título "Identificar falante", descrição `Dê um nome ao agrupamento “Falante N”.` (sempre o rótulo default), caixa informativa "Ouça um trecho e informe quem é a pessoa. O acervo não sugere nem identifica pessoas automaticamente.", label "Nome do falante", placeholder "Digite o nome do falante", helper "O nome será aplicado a todas as falas deste agrupamento nesta gravação.", botões "Cancelar"/"Salvar identificação"; erro inline (molde retry/delete), `router.refresh()` no sucesso.
|
||||
3. **Cena 3 (lista com faceta):** mantém `RecordingsSearchForm` (`CampaignSearchInput`, C199) **com hidden inputs dos `person` ativos** (submeter a busca não pode perder o filtro) e adiciona a linha de chips (botão outline "Pessoa: {rótulo}" com X) via `CampaignHeaderFilterPopover` multi-seleção; card com o chip `{rótulo}` + "aparece nesta gravação" sob o excerpt.
|
||||
4. **Cena 4 (aviso de imprecisão):** banner âmbar com título "A separação por falante pode estar imprecisa" e corpo "O agrupamento é automático e aproximado. Ruído e falas sobrepostas podem colocar trechos no agrupamento errado. A equipe continua responsável por identificar cada falante." — **sempre visível no detalhe agrupado** (sem score de qualidade, D1/anti-goal; o artefato não define gatilho). A linha condicional de reconciliação (D6) entra **dentro desse mesmo banner** quando `speakerLabelsDropped`: "Esta gravação foi reprocessada e algumas identificações não puderam ser mantidas. Confira os falantes." — uma frase, sem componente/superfície nova.
|
||||
5. **Cena 5 (mobile 390 px):** mesmo detalhe em coluna única, head compacto do bloco (avatar + nome + botão "Identificar"/ícone de edição com `aria-label` "Editar identificação"), banner informativo curto **literal** "Agrupamento automático e aproximado. A identificação é feita pela equipe." e hint "Toque para posicionar".
|
||||
|
||||
**Decisões de port fora do literal (baratas, revisáveis):**
|
||||
|
||||
- **Subtítulo da página na Cena 3** ("Gravações enviadas"): mantém o subtítulo aprovado do C199 ("Busque nas falas da Câmara ou nas gravações da equipe.") + alternador; a Cena 3 é shorthand do estado da fonte e mudar o chrome regride o pin do C199 sem ganho de produto.
|
||||
- **Vazio com filtro de pessoa**: reusa `CampaignListEmptyState` com a copy da casa ("Tente outro termo, remova filtros ou limpe a busca.") — estado não desenhado, shell aprovada.
|
||||
- **Fallback sem agrupamento**: renderiza o detalhe C199 **como está** (sem copy nova de "não diarizado"). A tarefa manda disparar o designer se precisarmos de superfície nova — **não precisamos**; se surgir demanda de explicar a ausência, é trigger a (designer antes do markup), registrado como follow-up (§D8).
|
||||
- Dialog sobre grupo já identificado reusa a copy da Cena 2 com o rótulo default; o trigger é que muda ("Editar identificação").
|
||||
- `role="tablist"` inexistente aqui; o alternador C199 fica intocado.
|
||||
|
||||
## Migration
|
||||
|
||||
- Nome: `pnpm migrate:create add_recording_speakers` → gera coluna `recording_segment.speaker_key` (`text`), tabela `recording_speaker_labels` (`id`, `_order`, `_parent_id` FK `recording` cascade, `speaker_key`, `label`), coluna `recording.speaker_names` (`text[]`) e `recording.speaker_labels_dropped` (`boolean`). **Revisar o SQL**; sem índices novos (a faceta é `contains` sobre `text[]` em tabela pequena e o `Speech.keywords` não tem índice dedicado). Referência de forma: `20260715_163458_initial` (arrays) e `20260918_201009_add_reel`.
|
||||
- **HARD-STOP de aprovação humana explícita, mesmo em sessão `--auto`, e local-only:** criar/aplicar a migração apenas no banco local do worktree (`pnpm migrate`), **nunca** tocar banco remoto/prod e **nunca** editar migração antiga. Parar, apresentar o SQL gerado e aguardar aprovação antes de seguir para o job/UI.
|
||||
|
||||
## Invariantes
|
||||
|
||||
- Local API em contexto de usuário sempre com `overrideAccess: false` + `user` (lista, detalhe, action de rótulo); bypass admin só no pipeline que é dono do row, com comentário (forma `recordingJob.ts:54-56`).
|
||||
- Escrita multi-collection em transação com `req: { transactionID }` (`withPayloadTransaction`): substituição de segmentos + estado de rótulos + `searchText`/status na mesma transação; a action de rótulo é 1 collection.
|
||||
- Sem `Consent` novo; sem `Contact`; rótulo é texto; nenhuma URL pública; `/arquivo` e matriz de papéis intocados.
|
||||
- Gate fail-closed do acervo; a escrita nova usa o predicado de escrita do dono (C202) — nunca `canReadCommunicationCatalog` gateando escrita; `advisor`/`leader` negados (matriz no int/e2e).
|
||||
- Sem biometria/voiceprint/unificação entre gravações; sem score/confiança na UI; sem inferência de identidade.
|
||||
- "Edit the owner, don't twin": puros no `lib` dono da pipeline, provedor no `utilities/ai` dono, where/estado no `recordings/`, sem módulo top-level novo em `src/utilities/`.
|
||||
- Identificadores em inglês; copy/labels pt-BR; valores de URL em pt-BR só os existentes (`source=enviadas`, `falantes`).
|
||||
- `pnpm generate:types` após o schema; `pnpm gate:fast` durante; `pnpm push` como entrega; **este plano e `docs/changelog/2026-09-19-c200.md` entram no commit da entrega**.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Puros + schema + migration (HARD-STOP)** (~1 dia).
|
||||
- `lib/recordingDiarization.ts`, `lib/recordingTranscription.ts` (+full), `recordings/recordingListUrl|Filters|ViewModels|PageData`, `lib/recording.ts`, collections + hook; `pnpm generate:types`.
|
||||
- Unit: alinhamento (sobreposição, empate, sem sobreposição→mais próximo, vazio), numeração global por primeira aparição, reconciliação (1:1 carrega; split/merge/orfão dropa + flag), `speakerNames` derivado, guard de 10 h, ffmpeg args do full, parse/serialize `person` (multi/dedupe/cap), where `contains`+q AND, `matchedPersons`.
|
||||
- Int: matriz de papéis e persistência das três colunas novas; **validação da query da faceta `contains` em `text[]` no Payload 3.82** (precedente `Speech.keywords` e `Activity.tags`).
|
||||
- **Migration `add_recording_speakers` — HARD-STOP humano, local-only.**
|
||||
2. **Provedor + job + ação** (~1 dia).
|
||||
- `assemblyAiDiarize` (seam injetável na assinatura do job), job com reconciliação na transação, action/rota/schema/paths.
|
||||
- Int: job com diarizador fake (feliz multi-chunk → `speakerKey` por segmento + labels preservadas; falha do provedor → `ready` sem agrupamento; reconciliação com fixtures antigas; chave inexistente → fallback), `labelRecordingSpeakerForActor` (papéis; chave desconhecida; label vazio/longo; limpa `speakerLabelsDropped`), sem chamada real de rede.
|
||||
3. **UI** (~1 dia).
|
||||
- Cenas 1–5 + hidden inputs + faceta + cards; ação de rótulo no diálogo.
|
||||
- Unit: view models dos grupos (ordem, `Falante N`, rótulo aplicado, flag), cena/copy constants; e2e cobre o markup.
|
||||
4. **Gates + GATE humano** (~0.5 dia).
|
||||
- Estender `tests/e2e/campaignSpeechAcervo.e2e.spec.ts` (describe C199): fixture `ready` com segmentos com `speakerKey` + `speakerLabels`; detalhe renderiza blocos/"Falante N"/rótulo/aviso; lista `?person=` recorta e o card mostra o chip; fallback sem `speakerKey` mantém `data-start-seconds` do C199; advisor negado na rota nova.
|
||||
- `pnpm gate:fast`; `pnpm test:e2e:affected`; manifest/conventions (confirmar sem mudança); **smoke manual do provedor com 1 gravação real** (chave aprovada); changelog; push.
|
||||
|
||||
Quota: ~3–3,5 dias eng, dentro do appetite herdado; se apertar, o corte é o polish do port (a estrutura das cenas 1/2/3 fica antes do acabamento).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Voiceprint/embedding para re-associar falantes entre execuções/gravações; unificação automática de "mesma pessoa".
|
||||
- Cadastro/diretório de pessoas ou vozes; qualquer uso de `Contact`.
|
||||
- Edição/correção de transcrição, alinhamento por palavra, refino de sobreposição.
|
||||
- Backfill de diarização do acervo antigo e UI de "Diarizar" (follow-up com gatilho, §D8).
|
||||
- Multi-provedor/adapter genérico de diarização (uma interface, um provedor — sem volatilidade que justifique mais).
|
||||
- Webhook/callback do provedor (o poll do job basta; callback criaria superfície pública nova).
|
||||
- Score/confiança de falante na UI (proibido); detecção automática de "qualidade ruim".
|
||||
- Fila/worker, tempo real, streaming, cortes por falante, busca semântica (C192) e merge de fontes.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Custo/chave do provedor não aprovados (GATE).** Sem chave, tudo degrada para a transcrição sem agrupamento (D7); a feature nova só liga após a aprovação — nenhum teste depende de rede.
|
||||
- **`speaker_labels` não suportado para `pt` na conta real.** Os docs oficiais da AssemblyAI listam `pt` com alta acurácia no `universal-2` e diarização nos modelos `universal-2`/`universal-3-5-pro`, mas a matriz feature×idioma não é pública: **smoke manual na fase 4** antes do rollout; falha honesta = fallback.
|
||||
- **Qualidade do agrupamento em plenária ruidosa/sobreposta.** Banner da Cena 4 sempre visível + identificação humana + nenhum claim automático; a copy do provedor recomenda ≥30 s de fala por pessoa (citada nos docs).
|
||||
- **Diarização > 10 h ou timeout de poll.** Guard de duração + deadline de 60 min com heartbeat; resultado = fallback sem agrupamento, nunca `failed`.
|
||||
- **Alinhamento errado na fronteira de turnos.** Atribuição por sobreposição máxima com desempate pelo ponto médio; unidade cobre; aviso de imprecisão cobre o resto.
|
||||
- **Reconciliação conservadora demais (rótulo perdido).** Só perde quando não há mapeamento 1:1; `speakerLabelsDropped` dispara o aviso; teste int garante carregar no caso trivial (mesmos tempos).
|
||||
- **Corrida no read-modify-write de `speakerLabels`.** Aceita para ferramenta interna de equipe única (uma gravação editada por vez na prática); se doer, collection dedicada de rótulos é o follow-up.
|
||||
- **Faceta cara conforme o catálogo cresce.** Select de `speakerNames` + dedupe é O(n) com n pequeno; gatilho: acervo > ~2 000 gravações ou opções > ~200 (pré-computar opções/`distinct`).
|
||||
- **Regressão do C199.** O caminho sem `speakerKey` mantém o markup exato do `<ol>` atual; os testes C199 (unit/int/e2e) continuam a rede.
|
||||
- **Vazamento de dados ao provedor externo.** Áudio de eventos públicos da equipe, sem PII de terceiros; delete best-effort do transcript remoto; política de retenção validada no gate.
|
||||
|
||||
## GATE humano — provedor, custo e chave (assumido; validar antes do rollout)
|
||||
|
||||
- **Assumido:** provedor **AssemblyAI** (`universal-2` + `speaker_labels`), custo **US$0,17/h** de áudio (US$0,0028/min) — fonte oficial: pricing AssemblyAI (Universal-2 US$0,15/h; diarização async standard +US$0,02/h) e docs de Speaker Diarization/limites (10 h, 5 GB; upload 2,2 GB).
|
||||
- **Custo projetado:** 10 h/mês ≈ US$1,70; 50 h/mês ≈ US$8,50; 200 h/mês ≈ US$34,00.
|
||||
- **Pendente de aprovação humana (mesmo com `--auto`):** criar conta/chave na AssemblyAI, definir `ASSEMBLYAI_API_KEY` no env de produção (e staging se desejado), aceitar o envio do áudio a terceiro e o orçamento acima; rodar o smoke real. **Nada disso bloqueia a implementação** (seam injetável + testes sem rede); bloqueia só o rollout.
|
||||
- **Alternativa registrada no gate:** Deepgram (US$0,0043/min = US$0,258/h, diarização incluída, pt-BR) — se a aprovação preferir chamada síncrona única; a troca exige só um novo dono em `utilities/ai/` (a interface do seam não muda).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto: transcrição dividida por "Falante N" nas gravações novas com provedor; "Identificar falante" vale para todo o agrupamento; faceta "Pessoa" recorta a fonte enviadas; busca geral intocada; rótulo vazio permanece "Falante N"; nunca sugere/infere identidade; reprocessar não perde rótulo sem re-vinculação avisada; antigas não são reprocessadas automaticamente.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: `overrideAccess:false` + `user`; transação multi-collection; sem `Consent`/`Contact`; `advisor`/`leader` fail-closed; sem biometria/score; sem módulo top-level novo em `utilities/`; migração nova sem editar antigas; plano + changelog no commit; `pnpm push`.
|
||||
- [ ] Testes de domínio onde access/write paths mudam: puros da diarização/reconciliação/URL/where/view models no unit; job com seam injetado + action de rótulo com matriz de papéis + query da faceta no int; markup do detalhe/liste no e2e existente.
|
||||
|
||||
## Self-score de decision-quality
|
||||
|
||||
**4,5/5.** As decisões caras têm alternativas honestas e rejeições ancoradas: provedor com número oficial de quatro candidatos + limitações reais (135 min da Gladia, preview da Melia, 10 h/5 GB da AssemblyAI), escopo da diarização (áudio inteiro vs chunk — o modo chunk mente e foi rejeitado), papel do provedor (não substitui o ASR dono), modelo dos rótulos com precedente de query validado (`hasMany` + `contains` no Payload 3.82), semântica da faceta, reconciliação conservadora com aviso e fallback honesto. Cabe no appetite herdado (fases somam ~3–3,5 dias, tracer nos puros+schema cedo) e reusa shells/donos existentes (`CampaignHeaderFilterPopover`, `withPayloadTransaction`, `campaignJsonMutationRoute`, moldes de `deepInfraTranscribe`, `SpeechCutDeleteDialog`, `speechListFilters`). Perde 0,5 porque duas coisas ficam fora do código e viram parada dura/validação: a **migração** (HARD-STOP explícito, local-only) e o **custo/chave do provedor** (assumido; gate humano com smoke real), além de duas escolhas de port que dependem de ratificação no gate (banner da Cena 4 sempre visível sem score; subtítulo do C199 mantido na Cena 3).
|
||||
|
|
@ -18,6 +18,7 @@ Expor no dono (`utilities/access/recordings.ts`) um predicado de papel compartil
|
|||
|
||||
- `POST .../gravacoes/enviar` (`route.ts` — gate cru de sessão e origem).
|
||||
- `retryRecordingForActor`, `deleteRecordingForActor` (`actions/recording.ts`).
|
||||
- `labelRecordingSpeakerForActor` (`actions/recording.ts`; absorvido do C200 #1167 — a action gateia no action-level com `canReadCommunicationCatalog` enquanto a collection usa `canUploadRecording`).
|
||||
- `getRecordingStatusesForActor` é leitura: mantém `canReadCommunicationCatalog`.
|
||||
|
||||
Rejeitadas: duplicar o corpo dos predicados nas rotas (gêmeo); criar um quarto predicado (o dono já tem os três).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
import { labelRecordingSpeakerForActor } from '@/app/(campaign)/campanha/actions/recording'
|
||||
import {
|
||||
RECORDING_FORBIDDEN_MESSAGE,
|
||||
RECORDING_GENERIC_ERROR_MESSAGE,
|
||||
RECORDING_NOT_FOUND_MESSAGE,
|
||||
RECORDING_SPEAKER_UNKNOWN_MESSAGE,
|
||||
recordingSpeakerLabelRequestSchema,
|
||||
} from '@/lib/schemas/recording'
|
||||
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
|
||||
|
||||
import type { RecordingSpeakerLabelResponse } from '../../types'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/** C200 — the team identifies one speaker cluster of the recording (human). */
|
||||
export const POST = campaignJsonMutationRoute(
|
||||
{
|
||||
bodySchema: recordingSpeakerLabelRequestSchema,
|
||||
safeMessages: [
|
||||
RECORDING_FORBIDDEN_MESSAGE,
|
||||
RECORDING_NOT_FOUND_MESSAGE,
|
||||
RECORDING_SPEAKER_UNKNOWN_MESSAGE,
|
||||
],
|
||||
genericMessage: RECORDING_GENERIC_ERROR_MESSAGE,
|
||||
},
|
||||
async (body) => {
|
||||
const result = await labelRecordingSpeakerForActor(body)
|
||||
return NextResponse.json<RecordingSpeakerLabelResponse>({ status: 'success', ...result })
|
||||
},
|
||||
)
|
||||
|
|
@ -116,6 +116,8 @@ export default async function RecordingDetailPage({
|
|||
downloadHref={recording.downloadHref}
|
||||
failureMessage={recording.failureMessage}
|
||||
segments={recording.segments}
|
||||
speakerGroups={recording.speakerGroups}
|
||||
speakerLabelsDropped={recording.speakerLabelsDropped}
|
||||
initialSeconds={initialSeconds}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -19,3 +19,8 @@ export type RecordingDeleteResponse =
|
|||
export type RecordingRetryResponse =
|
||||
| { status: 'success'; recording: RecordingViewModel }
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
/** Wire contract of `POST .../gravacoes/[id]/falantes` (C200) — label a cluster. */
|
||||
export type RecordingSpeakerLabelResponse =
|
||||
| { status: 'success'; labeled: true }
|
||||
| { status: 'error'; message: string }
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { redirect } from 'next/navigation'
|
|||
import { getPayload } from 'payload'
|
||||
|
||||
import { AcervoSourceToggle } from '@/components/campaign/recording/AcervoSourceToggle'
|
||||
import { RecordingPersonFilter } from '@/components/campaign/recording/RecordingPersonFilter'
|
||||
import { RecordingResultList } from '@/components/campaign/recording/RecordingResultList'
|
||||
import { RecordingStatusRefresher } from '@/components/campaign/recording/RecordingStatusRefresher'
|
||||
import { RecordingUploadDialog } from '@/components/campaign/recording/RecordingUploadDialog'
|
||||
|
|
@ -77,9 +78,19 @@ const AcervoHeader = ({ source }: { source: 'camara' | 'enviadas' }) => (
|
|||
</div>
|
||||
)
|
||||
|
||||
const RecordingsSearchForm = ({ query }: { query?: string }) => (
|
||||
const RecordingsSearchForm = ({
|
||||
query,
|
||||
people,
|
||||
}: {
|
||||
query?: string
|
||||
people?: readonly string[]
|
||||
}) => (
|
||||
<form method="get" action={CAMPAIGN_COMMUNICATION_ACERVO} className="flex flex-col gap-1.5">
|
||||
<input type="hidden" name="source" value="enviadas" />
|
||||
{/* The "Pessoa" facet survives a new textual search. */}
|
||||
{(people ?? []).map((person) => (
|
||||
<input key={person} type="hidden" name="person" value={person} />
|
||||
))}
|
||||
{/* The shared input owns the accessible label; this is its visible twin. */}
|
||||
<p className="text-sm font-medium">Buscar nas gravações enviadas</p>
|
||||
<CampaignSearchInput
|
||||
|
|
@ -101,10 +112,14 @@ const RecordingsSource = ({
|
|||
data: Awaited<ReturnType<typeof loadRecordingsPageData>>
|
||||
}) => {
|
||||
const hasSearch = Boolean(data.state.q)
|
||||
const hasPeople = (data.state.people?.length ?? 0) > 0
|
||||
const hasFilters = hasSearch || hasPeople
|
||||
|
||||
return (
|
||||
<CampaignListPendingBoundary>
|
||||
<RecordingsSearchForm query={data.state.q} />
|
||||
<RecordingsSearchForm query={data.state.q} people={data.state.people} />
|
||||
|
||||
<RecordingPersonFilter state={data.state} options={data.filterOptions.people} />
|
||||
|
||||
<RecordingStatusRefresher
|
||||
recordings={data.rows.map((row) => ({ id: row.id, status: row.status }))}
|
||||
|
|
@ -116,21 +131,27 @@ const RecordingsSource = ({
|
|||
) : (
|
||||
<CampaignListEmptyState
|
||||
className="border-solid"
|
||||
icon={hasSearch ? SearchIcon : VideoIcon}
|
||||
icon={hasFilters ? SearchIcon : VideoIcon}
|
||||
title={
|
||||
hasSearch
|
||||
? `Nenhuma gravação encontrada para "${data.state.q}"`
|
||||
: 'Nenhuma gravação enviada ainda'
|
||||
: hasPeople
|
||||
? 'Nenhuma gravação encontrada com esse filtro'
|
||||
: 'Nenhuma gravação enviada ainda'
|
||||
}
|
||||
description={
|
||||
hasSearch
|
||||
? 'Tente outro termo ou limpe a busca para ver todas as gravações enviadas.'
|
||||
: 'Envie um arquivo de vídeo para acompanhar a transcrição e pesquisar o conteúdo no acervo.'
|
||||
: hasPeople
|
||||
? 'Tente outro termo, remova filtros ou limpe a busca.'
|
||||
: 'Envie um arquivo de vídeo para acompanhar a transcrição e pesquisar o conteúdo no acervo.'
|
||||
}
|
||||
>
|
||||
{hasSearch ? (
|
||||
{hasFilters ? (
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={buildAcervoSourceHref('enviadas')}>Limpar busca</Link>
|
||||
<Link href={buildAcervoSourceHref('enviadas')}>
|
||||
{hasPeople ? 'Limpar filtros' : 'Limpar busca'}
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<RecordingUploadDialog triggerLabel="Enviar gravação" />
|
||||
|
|
|
|||
|
|
@ -12,14 +12,17 @@ import {
|
|||
RECORDING_FORBIDDEN_MESSAGE,
|
||||
RECORDING_NOT_FOUND_MESSAGE,
|
||||
RECORDING_RETRY_NOT_FAILED_MESSAGE,
|
||||
RECORDING_SPEAKER_UNKNOWN_MESSAGE,
|
||||
recordingDeleteRequestSchema,
|
||||
recordingRetryRequestSchema,
|
||||
recordingSpeakerLabelRequestSchema,
|
||||
recordingStatusRequestSchema,
|
||||
type RecordingStatusRequest,
|
||||
} from '@/lib/schemas/recording'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
|
||||
import { onPayloadTransactionCommit, withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
|
||||
import { reapStaleRecording } from '@/utilities/recordings/recordingJob'
|
||||
import { startRecordingJobInBackground } from '@/utilities/recordings/recordingScheduler'
|
||||
|
||||
|
|
@ -39,6 +42,7 @@ const recordingSelect = {
|
|||
durationSeconds: true,
|
||||
media: true,
|
||||
updatedAt: true,
|
||||
speakerLabels: true,
|
||||
} as const
|
||||
|
||||
const loadRecordingForActor = async (
|
||||
|
|
@ -138,6 +142,87 @@ export const getRecordingStatusesForActor = async (
|
|||
return result.docs.map(toRecordingViewModel)
|
||||
}
|
||||
|
||||
/**
|
||||
* C200 — names one acoustic cluster of a recording. Human curation only: the
|
||||
* key must belong to a segment of this recording (a stale dialog cannot invent
|
||||
* a group), and the label is text on the recording — never a `Contact`. The
|
||||
* write triggers the collection's `deriveSpeakerNames` hook, which is what the
|
||||
* "Pessoa" facet filters on.
|
||||
*
|
||||
* Runs inside a transaction under an advisory lock of the recording and
|
||||
* re-reads the labels after acquiring it: two curators labeling different
|
||||
* clusters at the same time would otherwise each write back a stale array and
|
||||
* silently lose one label. The lock closes that read-modify-write race.
|
||||
*/
|
||||
export const labelRecordingSpeakerForActor = async (input: {
|
||||
recordingId: number
|
||||
speakerKey: string
|
||||
label: string
|
||||
}): Promise<{ labeled: true }> => {
|
||||
const parsed = recordingSpeakerLabelRequestSchema.parse(input)
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
|
||||
if (!canReadCommunicationCatalog(actor.role)) throw new Error(RECORDING_FORBIDDEN_MESSAGE)
|
||||
|
||||
const current = await loadRecordingForActor(payload, actor, parsed.recordingId)
|
||||
if (!current) throw new Error(RECORDING_NOT_FOUND_MESSAGE)
|
||||
|
||||
await withPayloadTransaction(payload, async ({ req }) => {
|
||||
await acquireTextAdvisoryLocks(payload, req, [`recording-speakers:${parsed.recordingId}`])
|
||||
|
||||
const fresh = await payload.find({
|
||||
collection: 'recording',
|
||||
where: { id: { equals: parsed.recordingId } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
select: { speakerLabels: true },
|
||||
req,
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
const recording = fresh.docs[0]
|
||||
if (!recording) throw new Error(RECORDING_NOT_FOUND_MESSAGE)
|
||||
|
||||
const segment = await payload.find({
|
||||
collection: 'recordingSegment',
|
||||
where: {
|
||||
and: [
|
||||
{ recording: { equals: parsed.recordingId } },
|
||||
{ speakerKey: { equals: parsed.speakerKey } },
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
select: { speakerKey: true },
|
||||
req,
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
if (!segment.docs[0]) throw new Error(RECORDING_SPEAKER_UNKNOWN_MESSAGE)
|
||||
|
||||
const labels = [...(recording.speakerLabels ?? [])]
|
||||
const index = labels.findIndex((entry) => entry.speakerKey === parsed.speakerKey)
|
||||
const entry = { speakerKey: parsed.speakerKey, label: parsed.label }
|
||||
if (index >= 0) labels[index] = entry
|
||||
else labels.push(entry)
|
||||
|
||||
await payload.update({
|
||||
collection: 'recording',
|
||||
id: parsed.recordingId,
|
||||
data: { speakerLabels: labels, speakerLabelsDropped: false },
|
||||
depth: 0,
|
||||
select: recordingSelect,
|
||||
req,
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
})
|
||||
|
||||
return { labeled: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-deletes one recording: the row and its transcript go in one transaction
|
||||
* (the collection's `beforeDelete` cascade owns the segments); the private media
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import type { CollectionBeforeDeleteHook, CollectionConfig } from 'payload'
|
||||
import type {
|
||||
CollectionBeforeDeleteHook,
|
||||
CollectionBeforeValidateHook,
|
||||
CollectionConfig,
|
||||
} from 'payload'
|
||||
|
||||
import {
|
||||
RECORDING_MEDIA_SLUG,
|
||||
RECORDING_SPEAKER_LABEL_MAX_LENGTH,
|
||||
RECORDING_STATUSES,
|
||||
RECORDING_STEPS,
|
||||
RECORDING_TITLE_MAX_LENGTH,
|
||||
recordingStatusLabels,
|
||||
recordingStepLabels,
|
||||
} from '@/lib/recording'
|
||||
import { speakerNamesFromLabels } from '@/lib/recordingDiarization'
|
||||
import {
|
||||
canDeleteRecording,
|
||||
canReadRecording,
|
||||
|
|
@ -46,6 +52,21 @@ const deleteRecordingSegments: CollectionBeforeDeleteHook = async ({ id, req })
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* C200 — `speakerNames` is derived from the human labels so the "Pessoa" facet
|
||||
* filters one denormalized array instead of joining segments. Mirrors
|
||||
* `RecordingSegment.deriveSearchText`: `originalDoc` covers every update that
|
||||
* does not touch the labels (status, searchText, step).
|
||||
*/
|
||||
const deriveSpeakerNames: CollectionBeforeValidateHook = ({ data, originalDoc }) => {
|
||||
if (!data) return data
|
||||
// An explicit array (even empty) is the new truth; only an untouched field
|
||||
// falls back to the stored document.
|
||||
const labels = data.speakerLabels === undefined ? originalDoc?.speakerLabels : data.speakerLabels
|
||||
data.speakerNames = speakerNamesFromLabels(labels)
|
||||
return data
|
||||
}
|
||||
|
||||
export const Recording: CollectionConfig = {
|
||||
slug: 'recording',
|
||||
labels: {
|
||||
|
|
@ -66,6 +87,7 @@ export const Recording: CollectionConfig = {
|
|||
delete: canDeleteRecording,
|
||||
},
|
||||
hooks: {
|
||||
beforeValidate: [deriveSpeakerNames],
|
||||
beforeChange: [stampCampaignCreatedBy],
|
||||
beforeDelete: [deleteRecordingSegments],
|
||||
},
|
||||
|
|
@ -133,6 +155,52 @@ export const Recording: CollectionConfig = {
|
|||
description: 'Concatenação normalizada dos segmentos (sem acentos, minúsculas).',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'speakerLabels',
|
||||
type: 'array',
|
||||
label: 'Falantes identificados',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description:
|
||||
'Rótulos humanos por agrupamento acústico (chave → nome). Identificação sempre humana; o acervo nunca sugere ou infere pessoas.',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'speakerKey',
|
||||
type: 'text',
|
||||
label: 'Agrupamento',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
label: 'Nome do falante',
|
||||
required: true,
|
||||
maxLength: RECORDING_SPEAKER_LABEL_MAX_LENGTH,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'speakerNames',
|
||||
type: 'text',
|
||||
label: 'Pessoas (busca)',
|
||||
hasMany: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description:
|
||||
'Derivado dos rótulos (sem duplicatas, sem diferenciar maiúsculas) para a faceta "Pessoa".',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'speakerLabelsDropped',
|
||||
type: 'checkbox',
|
||||
label: 'Identificações perdidas no reprocessamento',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description:
|
||||
'Ligado quando um reprocessamento reordenou os agrupamentos e algum rótulo não pôde ser re-vinculado.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'error',
|
||||
type: 'textarea',
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import { canReadRecording, payloadAdminOnly } from '@/utilities/campaignAccess'
|
|||
* C199 — searchable ASR segment of an uploaded recording: one timestamped block
|
||||
* of the transcript with the trigram-search index (`recording_segment_search_text_trgm_idx`,
|
||||
* hand-written migration). Admin-hidden: the product surface is the recording
|
||||
* detail with its clickable transcript.
|
||||
* detail with its clickable transcript. C200 adds `speakerKey`: the anonymous
|
||||
* acoustic cluster ("Falante N") the segment was assigned to; null when the
|
||||
* recording has no diarization (the detail renders the plain transcript then).
|
||||
*/
|
||||
|
||||
const deriveSearchText: CollectionBeforeValidateHook = ({ data, originalDoc }) => {
|
||||
|
|
@ -52,6 +54,15 @@ export const RecordingSegment: CollectionConfig = {
|
|||
label: 'Ordem',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'speakerKey',
|
||||
type: 'text',
|
||||
label: 'Agrupamento de falante',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'Chave do agrupamento acústico ("Falante N") do trecho; vazio sem diarização.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'startSeconds',
|
||||
type: 'number',
|
||||
|
|
|
|||
|
|
@ -1,14 +1,23 @@
|
|||
'use client'
|
||||
|
||||
import { CircleAlertIcon, DownloadIcon, PlayIcon, UploadIcon } from 'lucide-react'
|
||||
import { CircleAlertIcon, DownloadIcon, InfoIcon, PlayIcon, UploadIcon } from 'lucide-react'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
import { RecordingRetryButton } from '@/components/campaign/recording/RecordingRetryButton'
|
||||
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
|
||||
import { RecordingSpeakerTranscript } from '@/components/campaign/recording/RecordingSpeakerTranscript'
|
||||
import { RecordingTranscriptSegmentButton } from '@/components/campaign/recording/RecordingTranscriptSegmentButton'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { canRetryRecording, type RecordingStatus } from '@/lib/recording'
|
||||
import {
|
||||
canRetryRecording,
|
||||
RECORDING_SPEAKER_INFO_BANNER,
|
||||
RECORDING_SPEAKER_INFO_BANNER_MOBILE,
|
||||
type RecordingStatus,
|
||||
} from '@/lib/recording'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { RecordingDetailSegmentViewModel } from '@/utilities/recordings/recordingViewModels'
|
||||
import type {
|
||||
RecordingDetailSegmentViewModel,
|
||||
RecordingSpeakerGroupViewModel,
|
||||
} from '@/utilities/recordings/recordingViewModels'
|
||||
|
||||
type RecordingDetailPlayerProps = {
|
||||
recordingId: number
|
||||
|
|
@ -17,6 +26,10 @@ type RecordingDetailPlayerProps = {
|
|||
downloadHref: string
|
||||
failureMessage: string | null
|
||||
segments: readonly RecordingDetailSegmentViewModel[]
|
||||
/** C200 — groups when the whole transcript is keyed; empty otherwise. */
|
||||
speakerGroups: readonly RecordingSpeakerGroupViewModel[]
|
||||
/** C200 — true when a reprocessing dropped some identification. */
|
||||
speakerLabelsDropped: boolean
|
||||
/** `?t=` of the search hit; null when the detail was opened without a term. */
|
||||
initialSeconds: number | null
|
||||
}
|
||||
|
|
@ -35,6 +48,8 @@ export const RecordingDetailPlayer = ({
|
|||
downloadHref,
|
||||
failureMessage,
|
||||
segments,
|
||||
speakerGroups,
|
||||
speakerLabelsDropped,
|
||||
initialSeconds,
|
||||
}: RecordingDetailPlayerProps) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
|
|
@ -48,6 +63,7 @@ export const RecordingDetailPlayer = ({
|
|||
const [retrying, setRetrying] = useState(false)
|
||||
const hasFile = status !== 'uploading'
|
||||
const showTranscript = status === 'ready' && segments.length > 0
|
||||
const grouped = speakerGroups.length > 0
|
||||
|
||||
const seekTo = (seconds: number) => {
|
||||
const video = videoRef.current
|
||||
|
|
@ -157,6 +173,16 @@ export const RecordingDetailPlayer = ({
|
|||
</p>
|
||||
) : null}
|
||||
|
||||
{grouped ? (
|
||||
<div className="mt-4 flex gap-2 rounded-lg border border-border bg-muted/50 p-3">
|
||||
<InfoIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
<span className="md:hidden">{RECORDING_SPEAKER_INFO_BANNER_MOBILE}</span>
|
||||
<span className="max-md:hidden">{RECORDING_SPEAKER_INFO_BANNER}</span>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status === 'ready' && segments.length === 0 ? (
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
A transcrição desta gravação está vazia — o arquivo continua disponível para download.
|
||||
|
|
@ -178,48 +204,44 @@ export const RecordingDetailPlayer = ({
|
|||
return (
|
||||
<div
|
||||
data-slot="recording-player"
|
||||
className="grid gap-6 lg:grid-cols-[minmax(0,1.35fr)_minmax(340px,.65fr)]"
|
||||
className={cn(
|
||||
'grid gap-6',
|
||||
grouped
|
||||
? 'lg:grid-cols-[minmax(0,1.05fr)_minmax(430px,.95fr)]'
|
||||
: 'lg:grid-cols-[minmax(0,1.35fr)_minmax(340px,.65fr)]',
|
||||
)}
|
||||
>
|
||||
{mediaColumn}
|
||||
|
||||
<section aria-label="Transcrição" className="min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
Transcrição
|
||||
</p>
|
||||
<span className="text-[11px] text-muted-foreground">Clique para posicionar</span>
|
||||
</div>
|
||||
<ol className="mt-2 max-h-[28rem] space-y-0.5 overflow-y-auto">
|
||||
{segments.map((segment) => {
|
||||
const active = activeStart === segment.startSeconds
|
||||
return (
|
||||
{grouped ? (
|
||||
<RecordingSpeakerTranscript
|
||||
recordingId={recordingId}
|
||||
groups={speakerGroups}
|
||||
labelsDropped={speakerLabelsDropped}
|
||||
activeStart={activeStart}
|
||||
onSeek={seekTo}
|
||||
/>
|
||||
) : (
|
||||
<section aria-label="Transcrição" className="min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
Transcrição
|
||||
</p>
|
||||
<span className="text-[11px] text-muted-foreground">Clique para posicionar</span>
|
||||
</div>
|
||||
<ol className="mt-2 max-h-[28rem] space-y-0.5 overflow-y-auto">
|
||||
{segments.map((segment) => (
|
||||
<li key={`${segment.startSeconds}-${segment.startLabel}`}>
|
||||
<button
|
||||
type="button"
|
||||
data-start-seconds={segment.startSeconds}
|
||||
onClick={() => seekTo(segment.startSeconds)}
|
||||
className={cn(
|
||||
'grid w-full grid-cols-[3.25rem_1fr] gap-3 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
active && 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'pt-0.5 text-xs tabular-nums',
|
||||
active ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{segment.startLabel}
|
||||
</span>
|
||||
<span className="text-sm leading-relaxed text-foreground/90">
|
||||
<SpeechHighlightParts parts={segment.parts} />
|
||||
</span>
|
||||
</button>
|
||||
<RecordingTranscriptSegmentButton
|
||||
segment={segment}
|
||||
active={activeStart === segment.startSeconds}
|
||||
onSeek={seekTo}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
87
src/components/campaign/recording/RecordingPersonFilter.tsx
Normal file
87
src/components/campaign/recording/RecordingPersonFilter.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
'use client'
|
||||
|
||||
import { FunnelIcon, XIcon } from 'lucide-react'
|
||||
import { useOptimistic } from 'react'
|
||||
|
||||
import {
|
||||
CampaignHeaderFilterPopover,
|
||||
type CampaignHeaderFilterRow,
|
||||
} from '@/components/campaign/shared/CampaignHeaderFilterPopover'
|
||||
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import { foldSpeakerName } from '@/lib/recordingDiarization'
|
||||
import {
|
||||
buildRecordingListHref,
|
||||
removeRecordingPerson,
|
||||
toggleRecordingPerson,
|
||||
type RecordingListState,
|
||||
} from '@/utilities/recordings/recordingListUrl'
|
||||
|
||||
const PERSON_FILTER_ID = 'recording-filter-person'
|
||||
|
||||
/**
|
||||
* C200 — the "Pessoa" facet of the uploaded recordings (design scene 3): one
|
||||
* removable chip per selected label plus the multi-select popover backed by the
|
||||
* labels actually present. Options come from the server (`speakerNames`), never
|
||||
* from a free-form directory — the label is text, not a `Contact`.
|
||||
*/
|
||||
export const RecordingPersonFilter = ({
|
||||
state,
|
||||
options,
|
||||
}: {
|
||||
state: RecordingListState
|
||||
options: readonly string[]
|
||||
}) => {
|
||||
const { navigate } = useCampaignListFilterNavigation({
|
||||
state,
|
||||
toHref: (next) => buildRecordingListHref(next, 1),
|
||||
})
|
||||
const [viewState, setOptimisticState] = useOptimistic(state)
|
||||
const selected = viewState.people ?? []
|
||||
|
||||
const optionRows: CampaignHeaderFilterRow[] = options.map((option) => {
|
||||
const next = toggleRecordingPerson(viewState, option)
|
||||
return {
|
||||
value: option,
|
||||
label: option,
|
||||
href: buildRecordingListHref(next, 1),
|
||||
selected: selected.some((person) => foldSpeakerName(person) === foldSpeakerName(option)),
|
||||
checkbox: true,
|
||||
onChoose: () => setOptimisticState(next),
|
||||
}
|
||||
})
|
||||
|
||||
const clear = {
|
||||
href: buildRecordingListHref({ ...viewState, page: 1, people: undefined }, 1),
|
||||
onChoose: () => setOptimisticState({ ...viewState, page: 1, people: undefined }),
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="group" aria-label="Filtrar por pessoa" className="flex flex-wrap items-center gap-2">
|
||||
<CampaignHeaderFilterPopover
|
||||
id={PERSON_FILTER_ID}
|
||||
label="Pessoa"
|
||||
triggerVariant="chip"
|
||||
triggerLabel={selected.length > 1 ? `Pessoa: ${selected.length}` : 'Pessoa'}
|
||||
active={selected.length > 0}
|
||||
closeOnChoose={false}
|
||||
emptyLabel="Nenhuma pessoa identificada nas gravações."
|
||||
optionRows={optionRows}
|
||||
clear={selected.length ? clear : undefined}
|
||||
/>
|
||||
|
||||
{selected.map((person) => (
|
||||
<button
|
||||
key={person}
|
||||
type="button"
|
||||
className="inline-flex h-8 min-h-8 items-center gap-1.5 rounded-lg border border-input bg-white px-3 text-xs font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => navigate(removeRecordingPerson(viewState, person))}
|
||||
aria-label={`Remover filtro de ${person}`}
|
||||
>
|
||||
<FunnelIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="max-w-56 truncate">Pessoa: {person}</span>
|
||||
<XIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { ChevronRightIcon, VideoIcon } from 'lucide-react'
|
||||
import { ChevronRightIcon, UserIcon, VideoIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { RecordingStatusBadge } from '@/components/campaign/recording/RecordingStatusBadge'
|
||||
|
|
@ -65,6 +65,21 @@ export const RecordingResultCard = ({ recording }: { recording: RecordingListIte
|
|||
{STATE_COPY[recording.status]}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{recording.matchedPersons.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||
{recording.matchedPersons.map((person) => (
|
||||
<span
|
||||
key={person}
|
||||
className="inline-flex min-h-7 items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs font-semibold"
|
||||
>
|
||||
<UserIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{person}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-muted-foreground">aparece nesta gravação</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
|
|
|||
174
src/components/campaign/recording/RecordingSpeakerDialog.tsx
Normal file
174
src/components/campaign/recording/RecordingSpeakerDialog.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
'use client'
|
||||
|
||||
import { InfoIcon, PencilIcon } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
|
||||
import type { RecordingSpeakerLabelResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/types'
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { postCampaignJson } from '@/lib/campaignJsonRequest'
|
||||
import { campaignRecordingSpeakersHref } from '@/lib/campaignPaths'
|
||||
import {
|
||||
RECORDING_SPEAKER_DIALOG_HELP,
|
||||
RECORDING_SPEAKER_DIALOG_INTRO,
|
||||
RECORDING_SPEAKER_DIALOG_TITLE,
|
||||
RECORDING_SPEAKER_EDIT_ACTION,
|
||||
RECORDING_SPEAKER_IDENTIFY_ACTION,
|
||||
RECORDING_SPEAKER_LABEL_FIELD_LABEL,
|
||||
RECORDING_SPEAKER_LABEL_LONG_MESSAGE,
|
||||
RECORDING_SPEAKER_LABEL_MAX_LENGTH,
|
||||
RECORDING_SPEAKER_LABEL_PLACEHOLDER,
|
||||
RECORDING_SPEAKER_LABEL_REQUIRED_MESSAGE,
|
||||
RECORDING_SPEAKER_SAVE_LABEL,
|
||||
} from '@/lib/recording'
|
||||
import type { RecordingSpeakerGroupViewModel } from '@/utilities/recordings/recordingViewModels'
|
||||
|
||||
type RecordingSpeakerDialogProps = {
|
||||
recordingId: number
|
||||
group: RecordingSpeakerGroupViewModel
|
||||
}
|
||||
|
||||
const SAVE_ERROR_MESSAGE = 'Não foi possível salvar a identificação. Tente novamente.'
|
||||
|
||||
/**
|
||||
* C200 — "Identificar falante" (design scene 2): the team names the anonymous
|
||||
* cluster after listening. The dialog never suggests a person; the label is
|
||||
* text applied to every segment of the group and the server rejects a key that
|
||||
* no longer exists in the recording.
|
||||
*/
|
||||
export const RecordingSpeakerDialog = ({ recordingId, group }: RecordingSpeakerDialogProps) => {
|
||||
const router = useRouter()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [label, setLabel] = useState(group.label ?? '')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next)
|
||||
if (next) {
|
||||
setLabel(group.label ?? '')
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
const trimmed = label.trim()
|
||||
if (!trimmed) {
|
||||
setError(RECORDING_SPEAKER_LABEL_REQUIRED_MESSAGE)
|
||||
return
|
||||
}
|
||||
if (trimmed.length > RECORDING_SPEAKER_LABEL_MAX_LENGTH) {
|
||||
setError(RECORDING_SPEAKER_LABEL_LONG_MESSAGE)
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { ok, payload } = await postCampaignJson<RecordingSpeakerLabelResponse>(
|
||||
campaignRecordingSpeakersHref(recordingId),
|
||||
{ recordingId, speakerKey: group.key, label: trimmed },
|
||||
)
|
||||
if (!ok || payload.status !== 'success') {
|
||||
setError(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
|
||||
return
|
||||
}
|
||||
setOpen(false)
|
||||
router.refresh()
|
||||
} catch {
|
||||
setError(SAVE_ERROR_MESSAGE)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-9 shrink-0 px-2 md:min-h-10 md:px-3"
|
||||
aria-label={group.isIdentified ? RECORDING_SPEAKER_EDIT_ACTION : undefined}
|
||||
>
|
||||
<PencilIcon data-icon="inline-start" aria-hidden="true" />
|
||||
<span className="hidden md:inline">
|
||||
{group.isIdentified ? RECORDING_SPEAKER_EDIT_ACTION : RECORDING_SPEAKER_IDENTIFY_ACTION}
|
||||
</span>
|
||||
{/* Mobile keeps the short verb; an identified group is icon-only. */}
|
||||
{group.isIdentified ? null : <span className="md:hidden">Identificar</span>}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{RECORDING_SPEAKER_DIALOG_TITLE}</DialogTitle>
|
||||
<DialogDescription>Dê um nome ao agrupamento “{group.defaultLabel}”.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-2 rounded-lg border border-border bg-muted/50 p-3">
|
||||
<InfoIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{RECORDING_SPEAKER_DIALOG_INTRO}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-1.5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
}}
|
||||
>
|
||||
<label htmlFor={`speaker-label-${group.key}`} className="text-sm font-medium">
|
||||
{RECORDING_SPEAKER_LABEL_FIELD_LABEL}
|
||||
</label>
|
||||
<Input
|
||||
id={`speaker-label-${group.key}`}
|
||||
value={label}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
maxLength={RECORDING_SPEAKER_LABEL_MAX_LENGTH}
|
||||
placeholder={RECORDING_SPEAKER_LABEL_PLACEHOLDER}
|
||||
className="min-h-11 text-sm"
|
||||
autoFocus
|
||||
disabled={submitting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{RECORDING_SPEAKER_DIALOG_HELP}</p>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive" className="mt-2 py-2">
|
||||
<AlertDescription className="text-xs">{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" className="min-h-11" disabled={submitting}>
|
||||
{submitting ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
|
||||
{RECORDING_SPEAKER_SAVE_LABEL}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
103
src/components/campaign/recording/RecordingSpeakerTranscript.tsx
Normal file
103
src/components/campaign/recording/RecordingSpeakerTranscript.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
'use client'
|
||||
|
||||
import { InfoIcon, UserIcon } from 'lucide-react'
|
||||
|
||||
import { RecordingSpeakerDialog } from '@/components/campaign/recording/RecordingSpeakerDialog'
|
||||
import { RecordingTranscriptSegmentButton } from '@/components/campaign/recording/RecordingTranscriptSegmentButton'
|
||||
import {
|
||||
RECORDING_SPEAKER_BANNER_BODY,
|
||||
RECORDING_SPEAKER_BANNER_DROPPED,
|
||||
RECORDING_SPEAKER_BANNER_TITLE,
|
||||
RECORDING_SPEAKER_GROUP_IDENTIFIED,
|
||||
RECORDING_SPEAKER_GROUP_UNIDENTIFIED,
|
||||
} from '@/lib/recording'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { RecordingSpeakerGroupViewModel } from '@/utilities/recordings/recordingViewModels'
|
||||
|
||||
type RecordingSpeakerTranscriptProps = {
|
||||
recordingId: number
|
||||
groups: readonly RecordingSpeakerGroupViewModel[]
|
||||
labelsDropped: boolean
|
||||
/** `?t=` search hit or the segment under the player's current time. */
|
||||
activeStart: number | null
|
||||
onSeek: (seconds: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* C200 — the grouped transcript of the recording detail (design scenes 1, 4 and
|
||||
* 5): anonymous clusters in first-appearance order, the always-visible
|
||||
* imprecision warning (the grouping is automatic; the team identifies) and the
|
||||
* "Identificar falante" affordance per group.
|
||||
*/
|
||||
export const RecordingSpeakerTranscript = ({
|
||||
recordingId,
|
||||
groups,
|
||||
labelsDropped,
|
||||
activeStart,
|
||||
onSeek,
|
||||
}: RecordingSpeakerTranscriptProps) => (
|
||||
<section aria-label="Transcrição por falante" className="min-w-0">
|
||||
<div className="flex gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<InfoIcon className="mt-0.5 size-4 shrink-0 text-amber-800" aria-hidden="true" />
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-amber-950">{RECORDING_SPEAKER_BANNER_TITLE}</h4>
|
||||
<p className="mt-1 text-sm leading-5 text-amber-900">{RECORDING_SPEAKER_BANNER_BODY}</p>
|
||||
{labelsDropped ? (
|
||||
<p className="mt-1 text-sm leading-5 text-amber-900">
|
||||
{RECORDING_SPEAKER_BANNER_DROPPED}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<h4 className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
Transcrição por falante
|
||||
</h4>
|
||||
<span className="text-[11px] text-muted-foreground md:text-xs">
|
||||
<span className="md:hidden">Toque para posicionar</span>
|
||||
<span className="max-md:hidden">Clique para posicionar</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 max-h-[28rem] space-y-3 overflow-y-auto">
|
||||
{groups.map((group) => (
|
||||
<article key={group.key} className="overflow-hidden rounded-xl border bg-card">
|
||||
<header className="flex items-center justify-between gap-3 border-b border-border bg-stone-50 px-3 py-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'grid size-7 shrink-0 place-items-center rounded-full',
|
||||
group.isIdentified ? 'bg-primary/10 text-primary' : 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<UserIcon className="size-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h5 className="truncate text-sm font-semibold">
|
||||
{group.label ?? group.defaultLabel}
|
||||
</h5>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{group.isIdentified
|
||||
? RECORDING_SPEAKER_GROUP_IDENTIFIED
|
||||
: RECORDING_SPEAKER_GROUP_UNIDENTIFIED}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<RecordingSpeakerDialog recordingId={recordingId} group={group} />
|
||||
</header>
|
||||
|
||||
{group.segments.map((segment) => (
|
||||
<RecordingTranscriptSegmentButton
|
||||
key={`${segment.startSeconds}-${segment.startLabel}`}
|
||||
segment={segment}
|
||||
active={activeStart === segment.startSeconds}
|
||||
variant="grouped"
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
))}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
'use client'
|
||||
|
||||
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { RecordingDetailSegmentViewModel } from '@/utilities/recordings/recordingViewModels'
|
||||
|
||||
/**
|
||||
* C200 — the clickable transcript line shared by the plain C199 list and the
|
||||
* grouped C200 blocks: seeks the player to the segment and renders the search
|
||||
* highlight. `variant` keeps the two approved paddings/sizes without a twin.
|
||||
*/
|
||||
export const RecordingTranscriptSegmentButton = ({
|
||||
segment,
|
||||
active,
|
||||
variant = 'plain',
|
||||
onSeek,
|
||||
}: {
|
||||
segment: RecordingDetailSegmentViewModel
|
||||
active: boolean
|
||||
variant?: 'plain' | 'grouped'
|
||||
onSeek: (seconds: number) => void
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-start-seconds={segment.startSeconds}
|
||||
onClick={() => onSeek(segment.startSeconds)}
|
||||
className={cn(
|
||||
'grid w-full grid-cols-[3.25rem_1fr] gap-3 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
variant === 'grouped' ? 'px-3 py-2.5' : 'rounded-lg px-2 py-1.5',
|
||||
active && 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs tabular-nums',
|
||||
variant === 'plain' && 'pt-0.5',
|
||||
active ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{segment.startLabel}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm text-foreground/90',
|
||||
variant === 'grouped' ? 'leading-5' : 'leading-relaxed',
|
||||
)}
|
||||
>
|
||||
<SpeechHighlightParts parts={segment.parts} />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
|
@ -73,4 +73,8 @@ export const campaignRecordingRetryHref = (id: number): string =>
|
|||
export const campaignRecordingDeleteHref = (id: number): string =>
|
||||
`${campaignRecordingDetailHref(id)}/apagar`
|
||||
|
||||
/** C200 — human identification of one speaker cluster of a recording. */
|
||||
export const campaignRecordingSpeakersHref = (id: number): string =>
|
||||
`${campaignRecordingDetailHref(id)}/falantes`
|
||||
|
||||
export const CAMPAIGN_UPDATES_HREF = '/campanha/atualizacoes' as const
|
||||
|
|
|
|||
|
|
@ -30,6 +30,11 @@ export const isRecordingStatus = (value: unknown): value is RecordingStatus =>
|
|||
/** One owner for the persisted field limits (collection, schema and dialog). */
|
||||
export const RECORDING_TITLE_MAX_LENGTH = 200
|
||||
|
||||
/** C200 — the human label the team types for one speaker cluster. */
|
||||
export const RECORDING_SPEAKER_LABEL_MAX_LENGTH = 120
|
||||
export const RECORDING_SPEAKER_LABEL_REQUIRED_MESSAGE = 'Informe o nome do falante.'
|
||||
export const RECORDING_SPEAKER_LABEL_LONG_MESSAGE = `O nome do falante deve ter até ${RECORDING_SPEAKER_LABEL_MAX_LENGTH} caracteres.`
|
||||
|
||||
/** Upload ceiling: hours of plenary are the real case, 4 GiB is the guard. */
|
||||
export const RECORDING_MAX_BYTES = 4 * 1024 * 1024 * 1024
|
||||
export const RECORDING_MAX_SIZE_LABEL = '4 GB'
|
||||
|
|
@ -90,6 +95,31 @@ export const canServeRecordingMedia = (status: RecordingStatus): boolean => stat
|
|||
/** The retry button only applies to a failed transcription. */
|
||||
export const canRetryRecording = (status: RecordingStatus): boolean => status === 'failed'
|
||||
|
||||
/** C200 — dialog copy of the human identification step (design scenes 2/5). */
|
||||
export const RECORDING_SPEAKER_DIALOG_TITLE = 'Identificar falante'
|
||||
export const RECORDING_SPEAKER_DIALOG_INTRO =
|
||||
'Ouça um trecho e informe quem é a pessoa. O acervo não sugere nem identifica pessoas automaticamente.'
|
||||
export const RECORDING_SPEAKER_DIALOG_HELP =
|
||||
'O nome será aplicado a todas as falas deste agrupamento nesta gravação.'
|
||||
export const RECORDING_SPEAKER_LABEL_FIELD_LABEL = 'Nome do falante'
|
||||
export const RECORDING_SPEAKER_LABEL_PLACEHOLDER = 'Digite o nome do falante'
|
||||
export const RECORDING_SPEAKER_SAVE_LABEL = 'Salvar identificação'
|
||||
|
||||
/** C200 — the transcript banner of the grouped detail (design scenes 1/4/5). */
|
||||
export const RECORDING_SPEAKER_INFO_BANNER =
|
||||
'Os falantes são agrupados automaticamente pelo áudio. A identificação de cada pessoa é feita pela equipe.'
|
||||
export const RECORDING_SPEAKER_INFO_BANNER_MOBILE =
|
||||
'Agrupamento automático e aproximado. A identificação é feita pela equipe.'
|
||||
export const RECORDING_SPEAKER_BANNER_TITLE = 'A separação por falante pode estar imprecisa'
|
||||
export const RECORDING_SPEAKER_BANNER_BODY =
|
||||
'O agrupamento é automático e aproximado. Ruído e falas sobrepostas podem colocar trechos no agrupamento errado. A equipe continua responsável por identificar cada falante.'
|
||||
export const RECORDING_SPEAKER_BANNER_DROPPED =
|
||||
'Esta gravação foi reprocessada e algumas identificações não puderam ser mantidas. Confira os falantes.'
|
||||
export const RECORDING_SPEAKER_GROUP_UNIDENTIFIED = 'Agrupamento sem identificação'
|
||||
export const RECORDING_SPEAKER_GROUP_IDENTIFIED = 'Identificação feita pela equipe'
|
||||
export const RECORDING_SPEAKER_IDENTIFY_ACTION = 'Identificar falante'
|
||||
export const RECORDING_SPEAKER_EDIT_ACTION = 'Editar identificação'
|
||||
|
||||
/** Errors of the upload form (client validation). Wire messages live in schemas. */
|
||||
export const RECORDING_FILE_REQUIRED_MESSAGE = 'Escolha um arquivo de vídeo para enviar.'
|
||||
export const RECORDING_TITLE_REQUIRED_MESSAGE = 'Informe um título para a gravação.'
|
||||
|
|
|
|||
241
src/lib/recordingDiarization.ts
Normal file
241
src/lib/recordingDiarization.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
/**
|
||||
* C200 — pure speaker-grouping rules of an uploaded recording. The diarization
|
||||
* provider returns anonymous turns (acoustic clusters); this module assigns
|
||||
* each transcript segment to the turn it overlaps and numbers the clusters
|
||||
* globally by first appearance. Identification is human: nothing here matches a
|
||||
* voice to a person — the labels are text the team types.
|
||||
*
|
||||
* No I/O and no `server-only`: the job, the view models and the unit tests
|
||||
* share this module. `reconcileSpeakerLabels` is the reprocessing contract: a
|
||||
* new run cannot re-associate a voice (no biometrics), so a label survives only
|
||||
* when an old cluster maps 1:1 to a new one by time overlap; the rest is
|
||||
* reported as dropped so the detail can warn instead of silently forgetting.
|
||||
*/
|
||||
|
||||
/** One anonymous speaker turn as the provider returns it, in absolute seconds. */
|
||||
export type DiarizedTurn = {
|
||||
speaker: string
|
||||
startSeconds: number
|
||||
endSeconds: number
|
||||
}
|
||||
|
||||
/** A segment with the cluster key it was assigned to; null if no turn covers it. */
|
||||
export type SpeakerKeyed = {
|
||||
speakerKey: string | null
|
||||
}
|
||||
|
||||
/** A label the team typed for one cluster key of one recording. */
|
||||
export type RecordingSpeakerLabel = {
|
||||
speakerKey: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** A span used by the alignment/reconciliation math (segment or cluster). */
|
||||
export type TimedSpan = {
|
||||
startSeconds: number
|
||||
endSeconds: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlap in seconds between two spans; zero when they do not touch. A
|
||||
* degenerate span (end before start) is clamped to its start, never negative.
|
||||
*/
|
||||
const overlapSeconds = (a: TimedSpan, b: TimedSpan): number =>
|
||||
Math.max(0, Math.min(a.endSeconds, b.endSeconds) - Math.max(a.startSeconds, b.startSeconds))
|
||||
|
||||
/** Gap in seconds between two spans; zero when they overlap or touch. */
|
||||
const gapSeconds = (a: TimedSpan, b: TimedSpan): number =>
|
||||
Math.max(0, Math.max(a.startSeconds, b.startSeconds) - Math.min(a.endSeconds, b.endSeconds))
|
||||
|
||||
/** Closest boundary distance used to break overlap ties by midpoint proximity. */
|
||||
const midpointDistance = (a: TimedSpan, b: TimedSpan): number =>
|
||||
Math.abs((a.startSeconds + a.endSeconds) / 2 - (b.startSeconds + b.endSeconds) / 2)
|
||||
|
||||
/**
|
||||
* Picks the turn of one segment: the largest overlap wins, ties go to the turn
|
||||
* whose midpoint sits closest to the segment's, and a segment that overlaps no
|
||||
* turn at all falls back to the nearest turn by gap (the audio is contiguous,
|
||||
* so an isolated segment belongs to the closest voice). Invalid turns are
|
||||
* ignored by the caller.
|
||||
*/
|
||||
const pickTurn = (segment: TimedSpan, turns: readonly DiarizedTurn[]): number => {
|
||||
let best = -1
|
||||
let bestOverlap = 0
|
||||
let bestDistance = Number.POSITIVE_INFINITY
|
||||
for (const [turnIndex, turn] of turns.entries()) {
|
||||
const overlap = overlapSeconds(segment, turn)
|
||||
const distance = midpointDistance(segment, turn)
|
||||
const wins =
|
||||
overlap > bestOverlap || (overlap > 0 && overlap === bestOverlap && distance < bestDistance)
|
||||
if (wins) {
|
||||
best = turnIndex
|
||||
bestOverlap = overlap
|
||||
bestDistance = distance
|
||||
}
|
||||
}
|
||||
if (bestOverlap > 0) return best
|
||||
|
||||
let nearest = 0
|
||||
let nearestGap = Number.POSITIVE_INFINITY
|
||||
for (const [turnIndex, turn] of turns.entries()) {
|
||||
const gap = gapSeconds(segment, turn)
|
||||
if (gap < nearestGap) {
|
||||
nearest = turnIndex
|
||||
nearestGap = gap
|
||||
}
|
||||
}
|
||||
return nearest
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns each segment a `speaker-N` key following the acoustic turns. The
|
||||
* numbering is global to the recording and follows the first appearance of the
|
||||
* cluster in segment order — two runs over the same audio produce the same
|
||||
* keys, and a segment with no usable turn keeps a null key (the detail renders
|
||||
* the plain transcript then). Pure: tests do not touch the provider.
|
||||
*/
|
||||
export const assignSpeakerKeys = <Segment extends TimedSpan>(
|
||||
segments: readonly Segment[],
|
||||
turns: readonly DiarizedTurn[],
|
||||
): (Segment & SpeakerKeyed)[] => {
|
||||
const validTurns = turns.filter(
|
||||
(turn) =>
|
||||
typeof turn.speaker === 'string' &&
|
||||
turn.speaker.length > 0 &&
|
||||
Number.isFinite(turn.startSeconds) &&
|
||||
Number.isFinite(turn.endSeconds),
|
||||
)
|
||||
if (validTurns.length === 0) {
|
||||
return segments.map((segment) => ({ ...segment, speakerKey: null }))
|
||||
}
|
||||
|
||||
const clusterKeys = new Map<string, string>()
|
||||
const keyOfTurn = (turn: DiarizedTurn): string => {
|
||||
const existing = clusterKeys.get(turn.speaker)
|
||||
if (existing) return existing
|
||||
const key = `speaker-${clusterKeys.size + 1}`
|
||||
clusterKeys.set(turn.speaker, key)
|
||||
return key
|
||||
}
|
||||
|
||||
return segments.map((segment) => {
|
||||
const turn = validTurns[pickTurn(segment, validTurns)]
|
||||
return { ...segment, speakerKey: turn ? keyOfTurn(turn) : null }
|
||||
})
|
||||
}
|
||||
|
||||
/** First-appearance order of the keys in a keyed segment list. */
|
||||
export const speakerKeysInOrder = (segments: readonly SpeakerKeyed[]): string[] => {
|
||||
const keys: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const segment of segments) {
|
||||
if (!segment.speakerKey || seen.has(segment.speakerKey)) continue
|
||||
seen.add(segment.speakerKey)
|
||||
keys.push(segment.speakerKey)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
/**
|
||||
* Reprocessing contract (D6): a previous label is kept only on a mutual 1:1
|
||||
* time-overlap match — one old cluster maps to exactly one new cluster and that
|
||||
* new cluster receives exactly one old label. A split (one old cluster became
|
||||
* two) or a merge (two old clusters became one) is uncertainty, so the affected
|
||||
* labels are dropped and reported instead of naming the wrong voice. Output
|
||||
* labels follow the new segment order.
|
||||
*/
|
||||
export const reconcileSpeakerLabels = ({
|
||||
previousLabels,
|
||||
previousSegments,
|
||||
nextSegments,
|
||||
}: {
|
||||
previousLabels: readonly RecordingSpeakerLabel[]
|
||||
previousSegments: readonly (TimedSpan & SpeakerKeyed)[]
|
||||
nextSegments: readonly (TimedSpan & SpeakerKeyed)[]
|
||||
}): { labels: RecordingSpeakerLabel[]; dropped: boolean } => {
|
||||
if (previousLabels.length === 0) return { labels: [], dropped: false }
|
||||
|
||||
const nextKeys = speakerKeysInOrder(nextSegments)
|
||||
if (nextKeys.length === 0) return { labels: [], dropped: true }
|
||||
|
||||
const previousKeys = speakerKeysInOrder(previousSegments)
|
||||
const overlapByPair = new Map<string, number>()
|
||||
for (const previous of previousSegments) {
|
||||
if (!previous.speakerKey) continue
|
||||
for (const next of nextSegments) {
|
||||
if (!next.speakerKey) continue
|
||||
const pair = `${previous.speakerKey}\u0000${next.speakerKey}`
|
||||
overlapByPair.set(pair, (overlapByPair.get(pair) ?? 0) + overlapSeconds(previous, next))
|
||||
}
|
||||
}
|
||||
|
||||
const labelByPreviousKey = new Map(previousLabels.map((entry) => [entry.speakerKey, entry.label]))
|
||||
const claimantsOfNextKey = (nextKey: string): string[] =>
|
||||
previousKeys.filter(
|
||||
(previousKey) => (overlapByPair.get(`${previousKey}\u0000${nextKey}`) ?? 0) > 0,
|
||||
)
|
||||
|
||||
const kept = new Map<string, string>()
|
||||
for (const nextKey of nextKeys) {
|
||||
const claimants = claimantsOfNextKey(nextKey)
|
||||
const [previousKey] = claimants
|
||||
if (claimants.length !== 1 || !previousKey) continue
|
||||
const label = labelByPreviousKey.get(previousKey)
|
||||
if (!label) continue
|
||||
// Mutual uniqueness: the same old cluster must not also map elsewhere.
|
||||
const matchesOfPrevious = nextKeys.filter(
|
||||
(candidate) => (overlapByPair.get(`${previousKey}\u0000${candidate}`) ?? 0) > 0,
|
||||
)
|
||||
if (matchesOfPrevious.length !== 1) continue
|
||||
kept.set(nextKey, label)
|
||||
}
|
||||
|
||||
const labels = [...kept.entries()].map(([speakerKey, label]) => ({ speakerKey, label }))
|
||||
return { labels, dropped: previousLabels.length > labels.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct labels of a recording, case-insensitive and trimmed — the
|
||||
* denormalized `speakerNames` used by the "Pessoa" facet. Order of first
|
||||
* appearance; empty labels never count.
|
||||
*/
|
||||
export const speakerNamesFromLabels = (
|
||||
labels: readonly RecordingSpeakerLabel[] | null | undefined,
|
||||
): string[] => {
|
||||
const names: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const entry of labels ?? []) {
|
||||
const name = typeof entry?.label === 'string' ? entry.label.trim() : ''
|
||||
if (!name) continue
|
||||
const folded = foldSpeakerName(name)
|
||||
if (seen.has(folded)) continue
|
||||
seen.add(folded)
|
||||
names.push(name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/** The default public name of one cluster: always numbered, never a person. */
|
||||
export const recordingSpeakerDefaultLabel = (index: number): string => `Falante ${index}`
|
||||
|
||||
/**
|
||||
* Case-fold used by every speaker-name comparison: the facet query is
|
||||
* case-insensitive (ILIKE), so parsing, dedupe and chip matching share ONE
|
||||
* fold to never disagree.
|
||||
*/
|
||||
export const foldSpeakerName = (value: string): string => value.toLocaleLowerCase('pt-BR')
|
||||
|
||||
/**
|
||||
* Case-insensitive containment of one selected "Pessoa" term in one label —
|
||||
* the same match the facet query runs (`contains` → ILIKE), so the card chip
|
||||
* and the result set never disagree.
|
||||
*/
|
||||
export const speakerNameMatches = (name: string, filter: string): boolean =>
|
||||
foldSpeakerName(name).includes(foldSpeakerName(filter))
|
||||
|
||||
/**
|
||||
* Provider ceiling for one diarization call (AssemblyAI accepts up to 10 h per
|
||||
* file). Longer recordings skip the step and keep the honest single-block
|
||||
* transcript instead of guessing speakers.
|
||||
*/
|
||||
export const RECORDING_DIARIZATION_MAX_SECONDS = 10 * 60 * 60
|
||||
|
|
@ -34,17 +34,11 @@ export type RecordingTranscriptSegment = {
|
|||
}
|
||||
|
||||
/**
|
||||
* Extracts a mono 16 kHz MP3 and splits it into fixed chunks in one ffmpeg
|
||||
* pass. Args are returned as an array — never a shell string; `outputPattern`
|
||||
* carries the `%03d` sequence ffmpeg replaces per chunk.
|
||||
* The shared extraction prefix of both ffmpeg passes: mono 16 kHz MP3, at the
|
||||
* bitrate the ASR provider and the diarization provider both accept. Args are
|
||||
* returned as an array — never a shell string.
|
||||
*/
|
||||
export const buildRecordingAudioFfmpegArgs = ({
|
||||
inputPath,
|
||||
outputPattern,
|
||||
}: {
|
||||
inputPath: string
|
||||
outputPattern: string
|
||||
}): string[] => [
|
||||
const recordingAudioArgs = (inputPath: string): string[] => [
|
||||
'-nostdin',
|
||||
'-hide_banner',
|
||||
'-y',
|
||||
|
|
@ -59,6 +53,20 @@ export const buildRecordingAudioFfmpegArgs = ({
|
|||
'libmp3lame',
|
||||
'-b:a',
|
||||
RECORDING_AUDIO_BITRATE,
|
||||
]
|
||||
|
||||
/**
|
||||
* Extracts a mono 16 kHz MP3 and splits it into fixed chunks in one ffmpeg
|
||||
* pass. `outputPattern` carries the `%03d` sequence ffmpeg replaces per chunk.
|
||||
*/
|
||||
export const buildRecordingAudioFfmpegArgs = ({
|
||||
inputPath,
|
||||
outputPattern,
|
||||
}: {
|
||||
inputPath: string
|
||||
outputPattern: string
|
||||
}): string[] => [
|
||||
...recordingAudioArgs(inputPath),
|
||||
'-f',
|
||||
'segment',
|
||||
'-segment_time',
|
||||
|
|
@ -68,6 +76,20 @@ export const buildRecordingAudioFfmpegArgs = ({
|
|||
outputPattern,
|
||||
]
|
||||
|
||||
/**
|
||||
* Extracts the whole recording as one mono 16 kHz MP3 (C200): the diarization
|
||||
* provider needs the entire audio in a single call so the speaker numbering is
|
||||
* global — a per-chunk run would renumber speakers that never relate across
|
||||
* chunks. Same audio parameters as the chunked pass.
|
||||
*/
|
||||
export const buildRecordingFullAudioFfmpegArgs = ({
|
||||
inputPath,
|
||||
outputPath,
|
||||
}: {
|
||||
inputPath: string
|
||||
outputPath: string
|
||||
}): string[] => [...recordingAudioArgs(inputPath), outputPath]
|
||||
|
||||
/**
|
||||
* Merges every chunk into one ordered transcript: each segment is shifted by
|
||||
* its chunk offset, empty text is dropped and a degenerate span (end before
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { RECORDING_TITLE_MAX_LENGTH, RECORDING_TITLE_REQUIRED_MESSAGE } from '@/lib/recording'
|
||||
import {
|
||||
RECORDING_SPEAKER_LABEL_LONG_MESSAGE,
|
||||
RECORDING_SPEAKER_LABEL_MAX_LENGTH,
|
||||
RECORDING_SPEAKER_LABEL_REQUIRED_MESSAGE,
|
||||
RECORDING_TITLE_MAX_LENGTH,
|
||||
RECORDING_TITLE_REQUIRED_MESSAGE,
|
||||
} from '@/lib/recording'
|
||||
import { trimmedOptionalText } from '@/lib/schemas/primitives'
|
||||
|
||||
/** The acervo gate refused the actor — one literal shared with the vertical. */
|
||||
|
|
@ -49,6 +55,23 @@ export const recordingRetryRequestSchema = z.object({
|
|||
recordingId: z.number().int().positive(),
|
||||
})
|
||||
|
||||
/** C200 — the team names one acoustic cluster of a recording. */
|
||||
export const RECORDING_SPEAKER_UNKNOWN_MESSAGE =
|
||||
'Este agrupamento de falantes não existe mais nesta gravação.'
|
||||
|
||||
export const recordingSpeakerLabelRequestSchema = z.object({
|
||||
recordingId: z.number().int().positive(),
|
||||
speakerKey: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^speaker-\d{1,3}$/, RECORDING_SPEAKER_UNKNOWN_MESSAGE),
|
||||
label: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, RECORDING_SPEAKER_LABEL_REQUIRED_MESSAGE)
|
||||
.max(RECORDING_SPEAKER_LABEL_MAX_LENGTH, RECORDING_SPEAKER_LABEL_LONG_MESSAGE),
|
||||
})
|
||||
|
||||
/** Delete one recording (row + segments + private media). */
|
||||
export const recordingDeleteRequestSchema = z.object({
|
||||
recordingId: z.number().int().positive(),
|
||||
|
|
|
|||
16760
src/migrations/20260919_032729_add_recording_speakers.json
Normal file
16760
src/migrations/20260919_032729_add_recording_speakers.json
Normal file
File diff suppressed because it is too large
Load diff
51
src/migrations/20260919_032729_add_recording_speakers.ts
Normal file
51
src/migrations/20260919_032729_add_recording_speakers.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
/**
|
||||
* C200 — speaker grouping of the uploaded recordings: the anonymous cluster key
|
||||
* on each segment, the human labels on the recording (one row per identified
|
||||
* cluster) and the flag a reprocessing sets when a label could not be
|
||||
* re-associated. `recording_texts` is Payload's join table for the derived
|
||||
* `speakerNames` (`hasMany text`).
|
||||
*
|
||||
* The generated diff also carried `reel.source_hash` because the C199 snapshot
|
||||
* (20260919_003440) was taken before the sibling `20260919_002605` landed — the
|
||||
* column already exists in every environment, so that SQL was removed here by
|
||||
* hand. The snapshot JSON of THIS migration is current, so the phantom diff
|
||||
* does not come back.
|
||||
*/
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "recording_speaker_labels" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"speaker_key" varchar NOT NULL,
|
||||
"label" varchar NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "recording_texts" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer NOT NULL,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"text" varchar
|
||||
);
|
||||
|
||||
ALTER TABLE "recording" ADD COLUMN "speaker_labels_dropped" boolean;
|
||||
ALTER TABLE "recording_segment" ADD COLUMN "speaker_key" varchar;
|
||||
ALTER TABLE "recording_speaker_labels" ADD CONSTRAINT "recording_speaker_labels_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."recording"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "recording_texts" ADD CONSTRAINT "recording_texts_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."recording"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "recording_speaker_labels_order_idx" ON "recording_speaker_labels" USING btree ("_order");
|
||||
CREATE INDEX "recording_speaker_labels_parent_id_idx" ON "recording_speaker_labels" USING btree ("_parent_id");
|
||||
CREATE INDEX "recording_texts_order_parent" ON "recording_texts" USING btree ("order","parent_id");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "recording_speaker_labels" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "recording_texts" DISABLE ROW LEVEL SECURITY;
|
||||
DROP TABLE "recording_speaker_labels" CASCADE;
|
||||
DROP TABLE "recording_texts" CASCADE;
|
||||
ALTER TABLE "recording" DROP COLUMN "speaker_labels_dropped";
|
||||
ALTER TABLE "recording_segment" DROP COLUMN "speaker_key";`)
|
||||
}
|
||||
|
|
@ -75,6 +75,7 @@ import * as migration_20260915_142058_activity_google_import_link from './202609
|
|||
import * as migration_20260918_201009_add_reel from './20260918_201009_add_reel';
|
||||
import * as migration_20260919_002605_add_reel_source_hash from './20260919_002605_add_reel_source_hash';
|
||||
import * as migration_20260919_003440_add_recording from './20260919_003440_add_recording';
|
||||
import * as migration_20260919_032729_add_recording_speakers from './20260919_032729_add_recording_speakers';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -455,11 +456,16 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260919_002605_add_reel_source_hash.up,
|
||||
down: migration_20260919_002605_add_reel_source_hash.down,
|
||||
name: '20260919_002605_add_reel_source_hash'
|
||||
name: '20260919_002605_add_reel_source_hash',
|
||||
},
|
||||
{
|
||||
up: migration_20260919_003440_add_recording.up,
|
||||
down: migration_20260919_003440_add_recording.down,
|
||||
name: '20260919_003440_add_recording'
|
||||
name: '20260919_003440_add_recording',
|
||||
},
|
||||
{
|
||||
up: migration_20260919_032729_add_recording_speakers.up,
|
||||
down: migration_20260919_032729_add_recording_speakers.down,
|
||||
name: '20260919_032729_add_recording_speakers'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1175,6 +1175,24 @@ export interface Recording {
|
|||
* Concatenação normalizada dos segmentos (sem acentos, minúsculas).
|
||||
*/
|
||||
searchText?: string | null;
|
||||
/**
|
||||
* Rótulos humanos por agrupamento acústico (chave → nome). Identificação sempre humana; o acervo nunca sugere ou infere pessoas.
|
||||
*/
|
||||
speakerLabels?:
|
||||
| {
|
||||
speakerKey: string;
|
||||
label: string;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
/**
|
||||
* Derivado dos rótulos (sem duplicatas, sem diferenciar maiúsculas) para a faceta "Pessoa".
|
||||
*/
|
||||
speakerNames?: string[] | null;
|
||||
/**
|
||||
* Ligado quando um reprocessamento reordenou os agrupamentos e algum rótulo não pôde ser re-vinculado.
|
||||
*/
|
||||
speakerLabelsDropped?: boolean | null;
|
||||
/**
|
||||
* Motivo interno da falha; nunca vai à pessoa com o detalhe cru.
|
||||
*/
|
||||
|
|
@ -1215,6 +1233,10 @@ export interface RecordingSegment {
|
|||
id: number;
|
||||
recording: number | Recording;
|
||||
order: number;
|
||||
/**
|
||||
* Chave do agrupamento acústico ("Falante N") do trecho; vazio sem diarização.
|
||||
*/
|
||||
speakerKey?: string | null;
|
||||
startSeconds: number;
|
||||
endSeconds: number;
|
||||
text: string;
|
||||
|
|
@ -2425,6 +2447,15 @@ export interface RecordingSelect<T extends boolean = true> {
|
|||
media?: T;
|
||||
durationSeconds?: T;
|
||||
searchText?: T;
|
||||
speakerLabels?:
|
||||
| T
|
||||
| {
|
||||
speakerKey?: T;
|
||||
label?: T;
|
||||
id?: T;
|
||||
};
|
||||
speakerNames?: T;
|
||||
speakerLabelsDropped?: T;
|
||||
error?: T;
|
||||
createdBy?: T;
|
||||
updatedAt?: T;
|
||||
|
|
@ -2455,6 +2486,7 @@ export interface RecordingMediaSelect<T extends boolean = true> {
|
|||
export interface RecordingSegmentSelect<T extends boolean = true> {
|
||||
recording?: T;
|
||||
order?: T;
|
||||
speakerKey?: T;
|
||||
startSeconds?: T;
|
||||
endSeconds?: T;
|
||||
text?: T;
|
||||
|
|
|
|||
178
src/utilities/ai/assemblyAiDiarize.ts
Normal file
178
src/utilities/ai/assemblyAiDiarize.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import 'server-only'
|
||||
|
||||
import { openAsBlob } from 'node:fs'
|
||||
|
||||
import type { DiarizedTurn } from '@/lib/recordingDiarization'
|
||||
|
||||
/**
|
||||
* C200 — the diarization provider: AssemblyAI's async pre-recorded API with
|
||||
* `speaker_labels` (acoustic clustering only). The recording is sent as ONE
|
||||
* file so the speaker numbering is global; the voice is never matched to a
|
||||
* person and the account-level speaker identification feature is deliberately
|
||||
* never enabled — naming is human (design scenes 1/2).
|
||||
*
|
||||
* Never throws: every failure becomes `{ ok: false }` and the transcription job
|
||||
* degrades to the honest single-block transcript (no speaker grouping) instead
|
||||
* of failing the recording. The remote transcript/audio is deleted best-effort
|
||||
* after the read.
|
||||
*
|
||||
* Provider notes (verified 2026-09-19): Universal-2 at US$0,15/h + async
|
||||
* diarization add-on US$0,02/h; up to 10 h / 5 GB per file; `speech_models` is
|
||||
* the plural request field (the singular is deprecated).
|
||||
*/
|
||||
|
||||
export const ASSEMBLYAI_UPLOAD_URL = 'https://api.assemblyai.com/v2/upload'
|
||||
export const ASSEMBLYAI_TRANSCRIPT_URL = 'https://api.assemblyai.com/v2/transcript'
|
||||
const ASSEMBLYAI_MODEL = 'universal-2'
|
||||
const ASSEMBLYAI_LANGUAGE = 'pt'
|
||||
|
||||
const UPLOAD_TIMEOUT_MS = 10 * 60_000
|
||||
const POLL_REQUEST_TIMEOUT_MS = 30_000
|
||||
const DIARIZATION_DEADLINE_MS = 60 * 60_000
|
||||
const POLL_INTERVAL_MS = 5_000
|
||||
|
||||
const DIARIZE_ERROR = 'Não foi possível separar os falantes da gravação.'
|
||||
|
||||
type DiarizeRequestOptions = {
|
||||
/** Called after each poll — lets the job keep its heartbeat alive. */
|
||||
onProgress?: () => Promise<void> | void
|
||||
/** Poll cadence; only tests change it (default 5 s). */
|
||||
pollIntervalMs?: number
|
||||
}
|
||||
|
||||
type DiarizeResult =
|
||||
| { ok: true; turns: DiarizedTurn[] }
|
||||
| { ok: false; error: string; status: number }
|
||||
|
||||
/** The seam the transcription job consumes; tests inject a fake. */
|
||||
export type SpeakerDiarizer = (
|
||||
file: Blob,
|
||||
options?: DiarizeRequestOptions,
|
||||
) => Promise<DiarizeResult>
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
const authHeaders = (apiKey: string, json = false): Record<string, string> => ({
|
||||
authorization: apiKey,
|
||||
...(json ? { 'content-type': 'application/json' } : {}),
|
||||
})
|
||||
|
||||
/** `A`→`speaker-A`… provider cluster ids stay opaque; the pure module numbers them. */
|
||||
const normalizeUtterances = (json: unknown): DiarizedTurn[] => {
|
||||
const data = json && typeof json === 'object' ? (json as Record<string, unknown>) : {}
|
||||
const utterances = Array.isArray(data.utterances) ? data.utterances : []
|
||||
const turns: DiarizedTurn[] = []
|
||||
for (const raw of utterances) {
|
||||
const utterance = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
|
||||
const speaker = typeof utterance.speaker === 'string' ? utterance.speaker.trim() : ''
|
||||
const startMs = Number(utterance.start)
|
||||
const endMs = Number(utterance.end)
|
||||
if (!speaker || !Number.isFinite(startMs) || !Number.isFinite(endMs)) continue
|
||||
turns.push({
|
||||
speaker,
|
||||
startSeconds: Math.max(0, startMs / 1000),
|
||||
endSeconds: Math.max(0, endMs / 1000),
|
||||
})
|
||||
}
|
||||
return turns
|
||||
}
|
||||
|
||||
/** Best-effort delete of the remote transcript (privacy hygiene, never fatal). */
|
||||
const deleteRemoteTranscript = async (apiKey: string, transcriptId: string): Promise<void> => {
|
||||
try {
|
||||
await fetch(`${ASSEMBLYAI_TRANSCRIPT_URL}/${transcriptId}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(apiKey),
|
||||
signal: AbortSignal.timeout(POLL_REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
} catch {
|
||||
// Best-effort: a failed delete never affects the recording.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the audio, submits the diarization transcript and polls until it is
|
||||
* ready. `file` is a Blob opened from the extracted MP3 (the job owns the temp
|
||||
* file); the provider call itself keeps no copy after the best-effort delete.
|
||||
*/
|
||||
export const assemblyAiDiarize: SpeakerDiarizer = async (
|
||||
file,
|
||||
{ onProgress, pollIntervalMs = POLL_INTERVAL_MS } = {},
|
||||
) => {
|
||||
const apiKey = process.env.ASSEMBLYAI_API_KEY
|
||||
if (!apiKey) {
|
||||
return { ok: false, error: 'Separação de falantes está indisponível no momento.', status: 503 }
|
||||
}
|
||||
|
||||
let transcriptId: string | null = null
|
||||
try {
|
||||
const uploadResponse = await fetch(ASSEMBLYAI_UPLOAD_URL, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(apiKey),
|
||||
body: file,
|
||||
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
||||
})
|
||||
if (!uploadResponse.ok) return { ok: false, error: DIARIZE_ERROR, status: 502 }
|
||||
|
||||
const uploaded = (await uploadResponse.json()) as { upload_url?: unknown }
|
||||
const uploadUrl = typeof uploaded.upload_url === 'string' ? uploaded.upload_url : ''
|
||||
if (!uploadUrl) return { ok: false, error: DIARIZE_ERROR, status: 502 }
|
||||
|
||||
const createResponse = await fetch(ASSEMBLYAI_TRANSCRIPT_URL, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(apiKey, true),
|
||||
body: JSON.stringify({
|
||||
audio_url: uploadUrl,
|
||||
language_code: ASSEMBLYAI_LANGUAGE,
|
||||
speaker_labels: true,
|
||||
speech_models: [ASSEMBLYAI_MODEL],
|
||||
}),
|
||||
signal: AbortSignal.timeout(POLL_REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
if (!createResponse.ok) return { ok: false, error: DIARIZE_ERROR, status: 502 }
|
||||
|
||||
const created = (await createResponse.json()) as { id?: unknown; status?: unknown }
|
||||
transcriptId = typeof created.id === 'string' ? created.id : null
|
||||
if (!transcriptId) return { ok: false, error: DIARIZE_ERROR, status: 502 }
|
||||
|
||||
const deadline = Date.now() + DIARIZATION_DEADLINE_MS
|
||||
for (;;) {
|
||||
await sleep(pollIntervalMs)
|
||||
await onProgress?.()
|
||||
|
||||
const pollResponse = await fetch(`${ASSEMBLYAI_TRANSCRIPT_URL}/${transcriptId}`, {
|
||||
headers: authHeaders(apiKey),
|
||||
signal: AbortSignal.timeout(POLL_REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
if (!pollResponse.ok) return { ok: false, error: DIARIZE_ERROR, status: 502 }
|
||||
|
||||
const transcript = (await pollResponse.json()) as { status?: unknown }
|
||||
if (transcript.status === 'completed') {
|
||||
await deleteRemoteTranscript(apiKey, transcriptId)
|
||||
return { ok: true, turns: normalizeUtterances(transcript) }
|
||||
}
|
||||
if (transcript.status === 'error') {
|
||||
await deleteRemoteTranscript(apiKey, transcriptId)
|
||||
return { ok: false, error: DIARIZE_ERROR, status: 502 }
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
await deleteRemoteTranscript(apiKey, transcriptId)
|
||||
return { ok: false, error: DIARIZE_ERROR, status: 504 }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (transcriptId) await deleteRemoteTranscript(apiKey, transcriptId)
|
||||
return { ok: false, error: DIARIZE_ERROR, status: 502 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The configured provider, or null when the key is absent. The job treats null
|
||||
* as "no diarization step": the transcript is still saved, without groups.
|
||||
*/
|
||||
export const configuredSpeakerDiarizer = (): SpeakerDiarizer | null =>
|
||||
process.env.ASSEMBLYAI_API_KEY ? assemblyAiDiarize : null
|
||||
|
||||
/** Opens the extracted MP3 as a Blob without loading hours of audio in memory. */
|
||||
export const diarizeInputBlob = (path: string): Promise<Blob> =>
|
||||
openAsBlob(path, { type: 'audio/mpeg' })
|
||||
|
|
@ -10,13 +10,24 @@ import {
|
|||
RECORDING_MEDIA_SLUG,
|
||||
type RecordingStep,
|
||||
} from '@/lib/recording'
|
||||
import {
|
||||
assignSpeakerKeys,
|
||||
reconcileSpeakerLabels,
|
||||
RECORDING_DIARIZATION_MAX_SECONDS,
|
||||
} from '@/lib/recordingDiarization'
|
||||
import {
|
||||
buildRecordingAudioFfmpegArgs,
|
||||
buildRecordingFullAudioFfmpegArgs,
|
||||
mergeChunkTranscriptions,
|
||||
RECORDING_AUDIO_CHUNK_SECONDS,
|
||||
recordingSearchText,
|
||||
} from '@/lib/recordingTranscription'
|
||||
import type { Recording } from '@/payload-types'
|
||||
import {
|
||||
configuredSpeakerDiarizer,
|
||||
diarizeInputBlob,
|
||||
type SpeakerDiarizer,
|
||||
} from '@/utilities/ai/assemblyAiDiarize'
|
||||
import {
|
||||
deepInfraTranscribeSegments,
|
||||
type TranscribeSegmentsResult,
|
||||
|
|
@ -107,12 +118,16 @@ const chunkFilesIn = async (tempDir: string): Promise<string[]> => {
|
|||
/**
|
||||
* The whole pipeline of one recording. Never throws: an orphan job (server
|
||||
* restart) is covered by the lazy reaper, and any error marks the row `failed`
|
||||
* so the retry reuses it.
|
||||
* so the retry reuses it. C200 adds the optional diarization pass: with a
|
||||
* configured provider the whole extracted audio is sent once, the turns are
|
||||
* aligned to the whisper segments and the human labels are reconciled by time
|
||||
* overlap; a provider failure degrades to the single-block transcript.
|
||||
*/
|
||||
export const runRecordingJob = async (
|
||||
payload: Payload,
|
||||
recordingId: number,
|
||||
transcribe: ChunkTranscriber = deepInfraTranscribeSegments,
|
||||
diarize: SpeakerDiarizer | null = configuredSpeakerDiarizer(),
|
||||
): Promise<void> => {
|
||||
let tempDir: string | null = null
|
||||
let currentStep: RecordingStep = 'extracting'
|
||||
|
|
@ -155,6 +170,22 @@ export const runRecordingJob = async (
|
|||
return
|
||||
}
|
||||
|
||||
// C200: the provider accepts up to 10 h per file; a longer recording skips
|
||||
// the step honestly (a rough upper bound is enough to refuse, and the
|
||||
// chunk count overestimates the real duration by at most one chunk).
|
||||
const diarizationEligible =
|
||||
diarize !== null &&
|
||||
chunkFiles.length * RECORDING_AUDIO_CHUNK_SECONDS <= RECORDING_DIARIZATION_MAX_SECONDS
|
||||
const fullAudioPath = join(tempDir, 'full.mp3')
|
||||
if (diarizationEligible) {
|
||||
await runFfmpeg(
|
||||
buildRecordingFullAudioFfmpegArgs({ inputPath, outputPath: fullAudioPath }),
|
||||
RECORDING_AUDIO_CHUNK_SECONDS,
|
||||
NO_AUDIO_MESSAGE,
|
||||
RECORDING_FFMPEG_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
await markStep('transcribing')
|
||||
const chunks: {
|
||||
offsetSeconds: number
|
||||
|
|
@ -187,8 +218,63 @@ export const runRecordingJob = async (
|
|||
return
|
||||
}
|
||||
|
||||
// C200 — optional speaker grouping. The provider seam never fails the
|
||||
// recording: every failure (or a missing key) keeps the plain transcript.
|
||||
let keyedSegments: ((typeof segments)[number] & { speakerKey: string | null })[] = segments.map(
|
||||
(segment) => ({ ...segment, speakerKey: null }),
|
||||
)
|
||||
if (diarizationEligible && diarize) {
|
||||
try {
|
||||
const result = await diarize(await diarizeInputBlob(fullAudioPath), {
|
||||
onProgress: async () => {
|
||||
await updateRecordingSystem(payload, recordingId, { step: 'transcribing' })
|
||||
},
|
||||
})
|
||||
if (result.ok) keyedSegments = assignSpeakerKeys(segments, result.turns)
|
||||
} catch {
|
||||
// Defensive: the provider contract is "never throws"; if it does, the
|
||||
// transcript still gets saved without groups.
|
||||
}
|
||||
}
|
||||
|
||||
await markStep('saving')
|
||||
await withPayloadTransaction(payload, async ({ req }) => {
|
||||
// Fresh reads inside the transaction: the labels a curator wrote while
|
||||
// the transcription ran must be the ones reconciled, not the snapshot
|
||||
// from the top of the job.
|
||||
const freshRecording = await payload.find({
|
||||
collection: 'recording',
|
||||
where: { id: { equals: recordingId } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
select: { speakerLabels: true },
|
||||
req,
|
||||
// Intentional admin bypass: the pipeline owns the row state.
|
||||
overrideAccess: true,
|
||||
})
|
||||
const previous = await payload.find({
|
||||
collection: 'recordingSegment',
|
||||
where: { recording: { equals: recordingId } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
sort: 'order',
|
||||
select: { speakerKey: true, startSeconds: true, endSeconds: true },
|
||||
req,
|
||||
// Intentional admin bypass: reconciling the transcript the job owns.
|
||||
overrideAccess: true,
|
||||
})
|
||||
const { labels, dropped } = reconcileSpeakerLabels({
|
||||
previousLabels: freshRecording.docs[0]?.speakerLabels ?? [],
|
||||
previousSegments: previous.docs.map((segment) => ({
|
||||
speakerKey: segment.speakerKey ?? null,
|
||||
startSeconds: segment.startSeconds,
|
||||
endSeconds: segment.endSeconds,
|
||||
})),
|
||||
nextSegments: keyedSegments,
|
||||
})
|
||||
|
||||
await payload.delete({
|
||||
collection: 'recordingSegment',
|
||||
where: { recording: { equals: recordingId } },
|
||||
|
|
@ -196,7 +282,7 @@ export const runRecordingJob = async (
|
|||
// Intentional admin bypass: replacing segments owned by the recording.
|
||||
overrideAccess: true,
|
||||
})
|
||||
for (const segment of segments) {
|
||||
for (const segment of keyedSegments) {
|
||||
await payload.create({
|
||||
collection: 'recordingSegment',
|
||||
// `searchText` is filled by the collection's own beforeValidate hook.
|
||||
|
|
@ -206,6 +292,7 @@ export const runRecordingJob = async (
|
|||
startSeconds: segment.startSeconds,
|
||||
endSeconds: segment.endSeconds,
|
||||
text: segment.text,
|
||||
speakerKey: segment.speakerKey,
|
||||
}),
|
||||
req,
|
||||
// Intentional admin bypass: the transcript belongs to the recording row.
|
||||
|
|
@ -221,6 +308,8 @@ export const runRecordingJob = async (
|
|||
error: null,
|
||||
durationSeconds: Math.round(durationSeconds),
|
||||
searchText: recordingSearchText(segments),
|
||||
speakerLabels: labels,
|
||||
speakerLabelsDropped: dropped,
|
||||
},
|
||||
req,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,29 @@
|
|||
/**
|
||||
* C199 — Payload `where` of the uploaded-recordings list: one textual branch
|
||||
* over the normalized `recording.searchText` (the concatenation of the
|
||||
* transcript segments). No facets: the Câmara facets do not apply to a
|
||||
* recording. A recording still processing has no `searchText` and is honestly
|
||||
* out of the search until the transcript is saved.
|
||||
* transcript segments). C200 adds the "Pessoa" facet: any selected label that
|
||||
* appears in the recording's derived `speakerNames`. A recording still
|
||||
* processing has no `searchText` and is honestly out of the search until the
|
||||
* transcript is saved.
|
||||
*/
|
||||
import type { Where } from 'payload'
|
||||
|
||||
import { normalizeForSearch } from '@/lib/speechSearch'
|
||||
import { collapseListWhereOrBranches } from '@/utilities/campaignListUrl'
|
||||
import type { RecordingListState } from '@/utilities/recordings/recordingListUrl'
|
||||
|
||||
export const buildRecordingListWhere = (state: RecordingListState): Where => {
|
||||
const branches: Where[] = []
|
||||
|
||||
const normalized = normalizeForSearch(state.q ?? '')
|
||||
return normalized ? { searchText: { like: normalized } } : {}
|
||||
if (normalized) branches.push({ searchText: { like: normalized } })
|
||||
|
||||
const peopleBranch = collapseListWhereOrBranches(
|
||||
(state.people ?? []).map((person) => ({ speakerNames: { contains: person } })),
|
||||
)
|
||||
if (peopleBranch) branches.push(peopleBranch)
|
||||
|
||||
if (branches.length === 0) return {}
|
||||
if (branches.length === 1) return branches[0] ?? {}
|
||||
return { and: branches }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@
|
|||
*/
|
||||
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
|
||||
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
|
||||
import { RECORDING_SPEAKER_LABEL_MAX_LENGTH } from '@/lib/recording'
|
||||
import { foldSpeakerName } from '@/lib/recordingDiarization'
|
||||
import {
|
||||
allParamValues,
|
||||
buildListHref,
|
||||
firstValue,
|
||||
normalizedText,
|
||||
|
|
@ -22,29 +25,52 @@ export type AcervoSource = 'camara' | 'enviadas'
|
|||
|
||||
const ACERVO_SOURCE_PARAM = 'source'
|
||||
const ACERVO_SOURCE_ENVIADAS = 'enviadas'
|
||||
const ACERVO_PERSON_PARAM = 'person'
|
||||
|
||||
const recordingListParamNames = [ACERVO_SOURCE_PARAM, 'q', 'page'] as const
|
||||
const recordingListParamNames = [ACERVO_SOURCE_PARAM, 'q', 'page', ACERVO_PERSON_PARAM] as const
|
||||
const recordingListParamNameSet = new Set<string>(recordingListParamNames)
|
||||
|
||||
export type RecordingListState = {
|
||||
source: 'enviadas'
|
||||
page: number
|
||||
q?: string
|
||||
/** Selected "Pessoa" labels; repeated `person` params, literal text. */
|
||||
people?: string[]
|
||||
}
|
||||
|
||||
/** `enviadas` only for the explicit value; anything else is the Câmara default. */
|
||||
export const parseAcervoSource = (params: RawSearchParams): AcervoSource =>
|
||||
firstValue(params[ACERVO_SOURCE_PARAM]) === ACERVO_SOURCE_ENVIADAS ? 'enviadas' : 'camara'
|
||||
|
||||
/**
|
||||
* Parses the repeated `person` filter: trims, drops empty and oversized values
|
||||
* (a label never exceeds the field limit, so an oversized token can only be a
|
||||
* hand-typed miss) and dedupes case-insensitively keeping the first spelling.
|
||||
*/
|
||||
export const parseRecordingPeople = (raw: string | string[] | undefined): string[] => {
|
||||
const people: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const value of allParamValues(raw)) {
|
||||
if (value.length > RECORDING_SPEAKER_LABEL_MAX_LENGTH) continue
|
||||
const folded = foldSpeakerName(value)
|
||||
if (seen.has(folded)) continue
|
||||
seen.add(folded)
|
||||
people.push(value)
|
||||
}
|
||||
return people
|
||||
}
|
||||
|
||||
export const parseRecordingListParams = (params: RawSearchParams): RecordingListState => {
|
||||
const rawPage = strictDecimalInteger(firstValue(params.page))
|
||||
const rawQ = normalizedText(firstValue(params.q))
|
||||
const q = rawQ && isContactSearchQueryReady(rawQ) ? rawQ : undefined
|
||||
const people = parseRecordingPeople(params[ACERVO_PERSON_PARAM])
|
||||
|
||||
return {
|
||||
source: 'enviadas',
|
||||
page: rawPage ?? 1,
|
||||
...(q ? { q } : {}),
|
||||
...(people.length > 0 ? { people } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -56,10 +82,35 @@ const recordingListSearchParams = (
|
|||
// `source=enviadas` is always serialized — it is what selects the source.
|
||||
params.set(ACERVO_SOURCE_PARAM, ACERVO_SOURCE_ENVIADAS)
|
||||
if (state.q) params.set('q', state.q)
|
||||
for (const person of state.people ?? []) params.append(ACERVO_PERSON_PARAM, person)
|
||||
if (page > 1) params.set('page', String(page))
|
||||
return params
|
||||
}
|
||||
|
||||
/** The URL of one "Pessoa" facet option with `person` toggled (page resets). */
|
||||
export const toggleRecordingPerson = (
|
||||
state: RecordingListState,
|
||||
value: string,
|
||||
): RecordingListState => {
|
||||
const current = state.people ?? []
|
||||
const folded = foldSpeakerName(value)
|
||||
const selected = current.some((person) => foldSpeakerName(person) === folded)
|
||||
const people = selected
|
||||
? current.filter((person) => foldSpeakerName(person) !== folded)
|
||||
: [...current, value]
|
||||
return { ...state, page: 1, ...(people.length > 0 ? { people } : { people: undefined }) }
|
||||
}
|
||||
|
||||
/** The URL that removes one selected "Pessoa" (chips with an X; page resets). */
|
||||
export const removeRecordingPerson = (
|
||||
state: RecordingListState,
|
||||
value: string,
|
||||
): RecordingListState => {
|
||||
const folded = foldSpeakerName(value)
|
||||
const people = (state.people ?? []).filter((person) => foldSpeakerName(person) !== folded)
|
||||
return { ...state, page: 1, ...(people.length > 0 ? { people } : { people: undefined }) }
|
||||
}
|
||||
|
||||
export const buildRecordingListHref = (state: RecordingListState, page: number): string =>
|
||||
buildListHref(state, recordingListSearchParams, CAMPAIGN_COMMUNICATION_ACERVO, page)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'server-only'
|
|||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { foldSpeakerName } from '@/lib/recordingDiarization'
|
||||
import { RECORDING_NOT_FOUND_MESSAGE } from '@/lib/schemas/recording'
|
||||
import { normalizeForSearch } from '@/lib/speechSearch'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
|
|
@ -33,29 +34,76 @@ const recordingListSelect = {
|
|||
step: true,
|
||||
recordedAt: true,
|
||||
durationSeconds: true,
|
||||
speakerNames: true,
|
||||
} as const
|
||||
|
||||
const recordingDetailSelect = {
|
||||
...recordingListSelect,
|
||||
error: true,
|
||||
speakerLabels: true,
|
||||
speakerLabelsDropped: true,
|
||||
} as const
|
||||
|
||||
const segmentSelect = {
|
||||
startSeconds: true,
|
||||
endSeconds: true,
|
||||
text: true,
|
||||
speakerKey: true,
|
||||
} as const
|
||||
|
||||
const toSegmentRecord = (segment: {
|
||||
startSeconds: number
|
||||
endSeconds: number
|
||||
text: string
|
||||
speakerKey?: string | null
|
||||
}): RecordingSegmentRecord => ({
|
||||
startSeconds: segment.startSeconds,
|
||||
endSeconds: segment.endSeconds,
|
||||
text: segment.text,
|
||||
speakerKey: segment.speakerKey ?? null,
|
||||
})
|
||||
|
||||
/** C200 — the options the "Pessoa" facet offers. */
|
||||
export type RecordingFilterOptions = {
|
||||
people: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* C200 — distinct labels across the recordings the actor can read: the facet
|
||||
* only ever lists what is reachable, and the derived `speakerNames` column is
|
||||
* the single source. Small acervo now; if the list grows past a few thousand
|
||||
* rows this becomes a `SELECT DISTINCT` (registered as a revisit trigger in the
|
||||
* impl plan).
|
||||
*/
|
||||
export const loadRecordingFilterOptions = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
): Promise<RecordingFilterOptions> => {
|
||||
const result = await payload.find({
|
||||
collection: 'recording',
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { speakerNames: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
const people: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const recording of result.docs) {
|
||||
for (const name of recording.speakerNames ?? []) {
|
||||
const trimmed = name.trim()
|
||||
const folded = foldSpeakerName(trimmed)
|
||||
if (!trimmed || seen.has(folded)) continue
|
||||
seen.add(folded)
|
||||
people.push(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
return { people: people.sort((left, right) => left.localeCompare(right, 'pt-BR')) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The excerpt segment of each listed recording: ONE matching segment per row
|
||||
* (the search runs `LIKE` on the joined `recording.searchText`, so a page of
|
||||
|
|
@ -120,6 +168,7 @@ const loadMatchedSegments = async (
|
|||
export type RecordingsPageData = {
|
||||
rows: RecordingListItemViewModel[]
|
||||
state: RecordingListState
|
||||
filterOptions: RecordingFilterOptions
|
||||
redirectHref?: string
|
||||
totalDocs: number
|
||||
totalPages: number
|
||||
|
|
@ -134,17 +183,20 @@ export const loadRecordingsPageData = async (
|
|||
const canonicalUrl = resolveRecordingListUrl(rawSearchParams)
|
||||
const state = canonicalUrl.state
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'recording',
|
||||
depth: 0,
|
||||
limit: recordingPageSize,
|
||||
page: state.page,
|
||||
sort: '-createdAt',
|
||||
where: buildRecordingListWhere(state),
|
||||
select: recordingListSelect,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
const [result, filterOptions] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'recording',
|
||||
depth: 0,
|
||||
limit: recordingPageSize,
|
||||
page: state.page,
|
||||
sort: '-createdAt',
|
||||
where: buildRecordingListWhere(state),
|
||||
select: recordingListSelect,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
loadRecordingFilterOptions(payload, user),
|
||||
])
|
||||
|
||||
const resolvedUrl = resolveRecordingListUrl(rawSearchParams, result.totalPages)
|
||||
const recordings = result.docs
|
||||
|
|
@ -163,9 +215,11 @@ export const loadRecordingsPageData = async (
|
|||
recording,
|
||||
matchedSegments: segmentsByRecording.get(recording.id) ?? [],
|
||||
query: state.q,
|
||||
people: state.people,
|
||||
}),
|
||||
),
|
||||
state: resolvedUrl.state,
|
||||
filterOptions,
|
||||
redirectHref: resolvedUrl.redirectHref ?? canonicalUrl.redirectHref,
|
||||
totalDocs: result.totalDocs,
|
||||
totalPages: result.totalPages,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
/**
|
||||
* C199 — view models of the uploaded recordings. Pure: the loader hands raw
|
||||
* Payload rows and the view model decides labels, the matching excerpt and the
|
||||
* links the list/detail render.
|
||||
* Payload rows and the view model decides labels, the matching excerpt, the
|
||||
* speaker groups and the links the list/detail render. C200 adds the anonymous
|
||||
* cluster grouping ("Falante N") and the human labels the team typed.
|
||||
*/
|
||||
import {
|
||||
recordingFailureMessage,
|
||||
toRecordingViewModel,
|
||||
type RecordingViewModel,
|
||||
} from '@/lib/recording'
|
||||
import { recordingSpeakerDefaultLabel, speakerNameMatches } from '@/lib/recordingDiarization'
|
||||
import { formatSpeechClock } from '@/lib/speechClock'
|
||||
import {
|
||||
buildHighlightedExcerpt,
|
||||
|
|
@ -22,6 +24,8 @@ export type RecordingSegmentRecord = {
|
|||
startSeconds: number
|
||||
endSeconds: number
|
||||
text: string
|
||||
/** C200 — anonymous cluster key; null/absent on recordings without diarization. */
|
||||
speakerKey?: string | null
|
||||
}
|
||||
|
||||
type RecordingBaseRecord = {
|
||||
|
|
@ -33,15 +37,24 @@ type RecordingBaseRecord = {
|
|||
durationSeconds?: number | null
|
||||
}
|
||||
|
||||
export type RecordingListRecord = RecordingBaseRecord
|
||||
export type RecordingListRecord = RecordingBaseRecord & {
|
||||
/** C200 — derived distinct labels; drives the card chip under a "Pessoa" filter. */
|
||||
speakerNames?: string[] | null
|
||||
}
|
||||
|
||||
export type RecordingDetailRecord = RecordingBaseRecord & {
|
||||
/** Raw internal cause of a failure; never handed to the client as-is. */
|
||||
error?: string | null
|
||||
/** C200 — human labels by cluster key. */
|
||||
speakerLabels?: { speakerKey: string; label: string }[] | null
|
||||
/** C200 — true when a reprocessing could not keep some label. */
|
||||
speakerLabelsDropped?: boolean | null
|
||||
}
|
||||
|
||||
export type RecordingListItemViewModel = RecordingViewModel & {
|
||||
excerpt: SpeechHighlightedExcerpt | null
|
||||
/** Selected "Pessoa" labels that this recording indeed contains. */
|
||||
matchedPersons: string[]
|
||||
/**
|
||||
* Detail link that seeks the player to the matching segment when the search
|
||||
* found one (`?t=`), carrying the term for transcript highlighting (`?q=`).
|
||||
|
|
@ -56,10 +69,74 @@ export type RecordingDetailSegmentViewModel = {
|
|||
parts: SpeechHighlightPart[]
|
||||
}
|
||||
|
||||
/**
|
||||
* C200 — one anonymous speaker cluster of the detail transcript: the keyed
|
||||
* segments in order, the numbered default label and the human label when the
|
||||
* team identified the group.
|
||||
*/
|
||||
export type RecordingSpeakerGroupViewModel = {
|
||||
key: string
|
||||
index: number
|
||||
defaultLabel: string
|
||||
label: string | null
|
||||
isIdentified: boolean
|
||||
segments: RecordingDetailSegmentViewModel[]
|
||||
}
|
||||
|
||||
export type RecordingDetailViewModel = RecordingViewModel & {
|
||||
/** Honest cause of a failure; null unless `status === 'failed'`. */
|
||||
failureMessage: string | null
|
||||
segments: RecordingDetailSegmentViewModel[]
|
||||
/** Empty when the recording has no (or a partial) speaker grouping. */
|
||||
speakerGroups: RecordingSpeakerGroupViewModel[]
|
||||
/** C200 — surfaced by the imprecision banner when a label was dropped. */
|
||||
speakerLabelsDropped: boolean
|
||||
}
|
||||
|
||||
const toDetailSegment = (
|
||||
segment: RecordingSegmentRecord,
|
||||
query?: string,
|
||||
): RecordingDetailSegmentViewModel => ({
|
||||
startSeconds: segment.startSeconds,
|
||||
endSeconds: segment.endSeconds,
|
||||
startLabel: formatSpeechClock(segment.startSeconds),
|
||||
parts: splitHighlightedParts(segment.text, query ?? ''),
|
||||
})
|
||||
|
||||
/**
|
||||
* Groups the transcript by cluster key. A recording only renders groups when
|
||||
* EVERY segment is keyed: a half-diarized transcript would put an invented
|
||||
* boundary next to a real one, so the partial case falls back to the plain
|
||||
* list. Labels are looked up by key; an unlabeled group keeps "Falante N".
|
||||
*/
|
||||
const buildSpeakerGroups = (
|
||||
segments: readonly RecordingSegmentRecord[],
|
||||
labelsByKey: ReadonlyMap<string, string>,
|
||||
query?: string,
|
||||
): RecordingSpeakerGroupViewModel[] => {
|
||||
if (segments.length === 0 || segments.some((segment) => !segment.speakerKey)) return []
|
||||
|
||||
const groups = new Map<string, RecordingDetailSegmentViewModel[]>()
|
||||
for (const segment of segments) {
|
||||
const key = segment.speakerKey
|
||||
if (!key) continue
|
||||
const group = groups.get(key)
|
||||
const view = toDetailSegment(segment, query)
|
||||
if (group) group.push(view)
|
||||
else groups.set(key, [view])
|
||||
}
|
||||
|
||||
return [...groups.entries()].map(([key, groupSegments], index) => {
|
||||
const label = labelsByKey.get(key)?.trim() || null
|
||||
return {
|
||||
key,
|
||||
index,
|
||||
defaultLabel: recordingSpeakerDefaultLabel(index + 1),
|
||||
label,
|
||||
isIdentified: label !== null,
|
||||
segments: groupSegments,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const withSeek = (href: string, segment: RecordingSegmentRecord | undefined, query?: string) => {
|
||||
|
|
@ -80,28 +157,36 @@ export const toRecordingListItemViewModel = ({
|
|||
recording,
|
||||
matchedSegments,
|
||||
query,
|
||||
people,
|
||||
}: {
|
||||
recording: RecordingListRecord
|
||||
/** Segments that matched the query (or the row's first one as fallback). */
|
||||
matchedSegments: readonly RecordingSegmentRecord[]
|
||||
query?: string
|
||||
/** C200 — the selected "Pessoa" labels of the list state. */
|
||||
people?: readonly string[]
|
||||
}): RecordingListItemViewModel => {
|
||||
const base = toRecordingViewModel(recording)
|
||||
const isReady = base.status === 'ready'
|
||||
const q = query?.trim()
|
||||
const segment = isReady && q ? pickMatchingSegment(matchedSegments, q) : undefined
|
||||
const excerpt = segment ? buildHighlightedExcerpt(segment.text, q ?? '') : null
|
||||
const speakerNames = recording.speakerNames ?? []
|
||||
|
||||
return {
|
||||
...base,
|
||||
excerpt,
|
||||
matchedPersons: (people ?? []).filter((person) =>
|
||||
speakerNames.some((name) => speakerNameMatches(name, person)),
|
||||
),
|
||||
watchHref: withSeek(base.detailHref, segment, q),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail view of one recording: the base view plus the failure message and the
|
||||
* clickable transcript segments (highlighted by the `?q=` of the search).
|
||||
* Detail view of one recording: the base view plus the failure message, the
|
||||
* clickable transcript segments (highlighted by the `?q=` of the search) and
|
||||
* the speaker groups when the whole transcript is keyed.
|
||||
*/
|
||||
export const toRecordingDetailViewModel = ({
|
||||
recording,
|
||||
|
|
@ -114,6 +199,9 @@ export const toRecordingDetailViewModel = ({
|
|||
}): RecordingDetailViewModel => {
|
||||
const base = toRecordingViewModel(recording)
|
||||
const isFailed = base.status === 'failed'
|
||||
const labelsByKey = new Map(
|
||||
(recording.speakerLabels ?? []).map((entry) => [entry.speakerKey, entry.label]),
|
||||
)
|
||||
|
||||
return {
|
||||
...base,
|
||||
|
|
@ -123,11 +211,8 @@ export const toRecordingDetailViewModel = ({
|
|||
step: base.step,
|
||||
})
|
||||
: null,
|
||||
segments: segments.map((segment) => ({
|
||||
startSeconds: segment.startSeconds,
|
||||
endSeconds: segment.endSeconds,
|
||||
startLabel: formatSpeechClock(segment.startSeconds),
|
||||
parts: splitHighlightedParts(segment.text, query ?? ''),
|
||||
})),
|
||||
segments: segments.map((segment) => toDetailSegment(segment, query)),
|
||||
speakerGroups: buildSpeakerGroups(segments, labelsByKey, query),
|
||||
speakerLabelsDropped: recording.speakerLabelsDropped === true,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -521,7 +521,18 @@ test.describe('communication vertical (C154/C162)', () => {
|
|||
test.describe('uploaded recordings (C199)', () => {
|
||||
const createRecording = async (
|
||||
campaign: { fixtures: CampaignE2EOwnership },
|
||||
input: { marker: string; status?: 'processing' | 'ready' | 'failed'; withSegments?: boolean },
|
||||
input: {
|
||||
marker: string
|
||||
status?: 'processing' | 'ready' | 'failed'
|
||||
withSegments?: boolean
|
||||
segments?: {
|
||||
startSeconds: number
|
||||
endSeconds: number
|
||||
text: string
|
||||
speakerKey?: string | null
|
||||
}[]
|
||||
speakerLabels?: { speakerKey: string; label: string }[]
|
||||
},
|
||||
) => {
|
||||
const bytes = Buffer.from(`recording-${input.marker}`)
|
||||
const media = await campaign.fixtures.payload.create({
|
||||
|
|
@ -541,11 +552,35 @@ test.describe('communication vertical (C154/C162)', () => {
|
|||
status: input.status ?? 'ready',
|
||||
recordedAt: '2026-09-01T00:00:00.000Z',
|
||||
media: media.id,
|
||||
...(input.speakerLabels ? { speakerLabels: input.speakerLabels } : {}),
|
||||
},
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
if (input.withSegments ?? true) {
|
||||
if (input.segments) {
|
||||
for (const [index, segment] of input.segments.entries()) {
|
||||
await campaign.fixtures.payload.create({
|
||||
collection: 'recordingSegment',
|
||||
data: hookFilledCreateData<'recordingSegment'>({
|
||||
recording: recording.id,
|
||||
order: index + 1,
|
||||
startSeconds: segment.startSeconds,
|
||||
endSeconds: segment.endSeconds,
|
||||
text: segment.text,
|
||||
speakerKey: segment.speakerKey ?? null,
|
||||
}),
|
||||
depth: 0,
|
||||
})
|
||||
}
|
||||
await campaign.fixtures.payload.update({
|
||||
collection: 'recording',
|
||||
id: recording.id,
|
||||
data: {
|
||||
searchText: normalizeForSearch(input.segments.map((segment) => segment.text).join(' ')),
|
||||
},
|
||||
depth: 0,
|
||||
})
|
||||
} else if (input.withSegments ?? true) {
|
||||
await campaign.fixtures.payload.create({
|
||||
collection: 'recordingSegment',
|
||||
data: hookFilledCreateData<'recordingSegment'>({
|
||||
|
|
@ -685,5 +720,155 @@ test.describe('communication vertical (C154/C162)', () => {
|
|||
const denied = await advisorRequest.post(uploadUrl)
|
||||
expect(denied.status()).toBe(403)
|
||||
})
|
||||
|
||||
test('the grouped detail renders the speaker blocks, the warning and the identify action', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const marker = campaign.fixtures.value('gravacaofalantes')
|
||||
const { recording } = await createRecording(campaign, {
|
||||
marker,
|
||||
segments: [
|
||||
{
|
||||
startSeconds: 12,
|
||||
endSeconds: 20,
|
||||
text: `A plenária discutiu a merenda ${marker}.`,
|
||||
speakerKey: 'speaker-1',
|
||||
},
|
||||
{
|
||||
startSeconds: 21,
|
||||
endSeconds: 30,
|
||||
text: 'O relator respondeu em seguida.',
|
||||
speakerKey: 'speaker-2',
|
||||
},
|
||||
],
|
||||
speakerLabels: [{ speakerKey: 'speaker-1', label: 'Dep. Jorge Solla' }],
|
||||
})
|
||||
|
||||
const user = await campaign.fixtures.createCampaignUser('communicator')
|
||||
const request = await campaignRequest(user, user.password)
|
||||
const response = await request.get(
|
||||
`/campanha/comunicacao/acervo/gravacoes/${recording.id}?t=12&q=${marker}`,
|
||||
)
|
||||
expect(response.status()).toBe(200)
|
||||
const html = rendered(await response.text())
|
||||
|
||||
expect(html).toContain('Transcrição por falante')
|
||||
expect(html).toContain('Dep. Jorge Solla')
|
||||
expect(html).toContain('Identificação feita pela equipe')
|
||||
expect(html).toContain('Falante 2')
|
||||
expect(html).toContain('Agrupamento sem identificação')
|
||||
expect(html).toContain('Editar identificação')
|
||||
expect(html).toContain('Identificar falante')
|
||||
expect(html).toContain('A separação por falante pode estar imprecisa')
|
||||
expect(html).toContain('data-start-seconds="12"')
|
||||
expect(html).toContain('data-start-seconds="21"')
|
||||
expect(html).toContain('<mark')
|
||||
})
|
||||
|
||||
test('the "Pessoa" facet filters the list and the card shows the chip', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const marker = campaign.fixtures.value('gravacaopessoa')
|
||||
const { recording } = await createRecording(campaign, {
|
||||
marker,
|
||||
segments: [
|
||||
{
|
||||
startSeconds: 12,
|
||||
endSeconds: 20,
|
||||
text: `A plenária discutiu a merenda ${marker}.`,
|
||||
speakerKey: 'speaker-1',
|
||||
},
|
||||
],
|
||||
speakerLabels: [{ speakerKey: 'speaker-1', label: `Dep. ${marker}` }],
|
||||
})
|
||||
const { recording: other } = await createRecording(campaign, {
|
||||
marker: `${marker}outra`,
|
||||
withSegments: false,
|
||||
})
|
||||
|
||||
const user = await campaign.fixtures.createCampaignUser('communicator')
|
||||
const request = await campaignRequest(user, user.password)
|
||||
const response = await request.get(
|
||||
`/campanha/comunicacao/acervo?source=enviadas&person=${encodeURIComponent(`dep. ${marker}`)}`,
|
||||
)
|
||||
expect(response.status()).toBe(200)
|
||||
const html = rendered(await response.text())
|
||||
|
||||
expect(html).toContain(`Gravação ${marker}`)
|
||||
expect(html).not.toContain(`Gravação ${marker}outra`)
|
||||
expect(html).toContain('Pessoa:')
|
||||
expect(html).toContain('aparece nesta gravação')
|
||||
expect(html).toContain(`/campanha/comunicacao/acervo/gravacoes/${recording.id}`)
|
||||
expect(html).not.toContain(`/campanha/comunicacao/acervo/gravacoes/${other.id}`)
|
||||
})
|
||||
|
||||
test('labels a cluster through the JSON route and denies the non-acervo roles', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const marker = campaign.fixtures.value('gravacaorotular')
|
||||
const { recording } = await createRecording(campaign, {
|
||||
marker,
|
||||
segments: [
|
||||
{
|
||||
startSeconds: 12,
|
||||
endSeconds: 20,
|
||||
text: 'Fala única do agrupamento.',
|
||||
speakerKey: 'speaker-1',
|
||||
},
|
||||
],
|
||||
})
|
||||
const endpoint = `/campanha/comunicacao/acervo/gravacoes/${recording.id}/falantes`
|
||||
|
||||
const user = await campaign.fixtures.createCampaignUser('communicator')
|
||||
const request = await campaignRequest(user, user.password)
|
||||
const response = await request.post(endpoint, {
|
||||
data: { recordingId: recording.id, speakerKey: 'speaker-1', label: `Dep. ${marker}` },
|
||||
})
|
||||
expect(response.status()).toBe(200)
|
||||
expect((await response.json()).status).toBe('success')
|
||||
|
||||
const updated = await campaign.fixtures.payload.findByID({
|
||||
collection: 'recording',
|
||||
id: recording.id,
|
||||
depth: 0,
|
||||
})
|
||||
expect(updated.speakerNames).toEqual([`Dep. ${marker}`])
|
||||
|
||||
const unknown = await request.post(endpoint, {
|
||||
data: { recordingId: recording.id, speakerKey: 'speaker-9', label: 'Fantasma' },
|
||||
})
|
||||
expect(unknown.status()).toBe(400)
|
||||
|
||||
const advisor = await campaign.fixtures.createCampaignUser('advisor')
|
||||
const advisorRequest = await campaignRequest(advisor, advisor.password)
|
||||
const denied = await advisorRequest.post(endpoint, {
|
||||
data: { recordingId: recording.id, speakerKey: 'speaker-1', label: 'Negado' },
|
||||
})
|
||||
// The JSON mutation wrapper maps a safe domain message to 400 (same
|
||||
// envelope as the other `/campanha/**` mutation routes).
|
||||
expect(denied.status()).toBe(400)
|
||||
})
|
||||
|
||||
test('a recording without diarization keeps the plain transcript', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const marker = campaign.fixtures.value('gravacaoSemFalantes')
|
||||
const { recording } = await createRecording(campaign, { marker })
|
||||
|
||||
const user = await campaign.fixtures.createCampaignUser('communicator')
|
||||
const request = await campaignRequest(user, user.password)
|
||||
const response = await request.get(
|
||||
`/campanha/comunicacao/acervo/gravacoes/${recording.id}?t=12&q=${marker}`,
|
||||
)
|
||||
const html = rendered(await response.text())
|
||||
|
||||
expect(html).toContain('data-start-seconds="12"')
|
||||
expect(html).not.toContain('Transcrição por falante')
|
||||
expect(html).not.toContain('A separação por falante pode estar imprecisa')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -29,9 +29,14 @@ import { GET } from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoe
|
|||
import {
|
||||
deleteRecordingForActor,
|
||||
getRecordingStatusesForActor,
|
||||
labelRecordingSpeakerForActor,
|
||||
retryRecordingForActor,
|
||||
} from '@/app/(campaign)/campanha/actions/recording'
|
||||
import { RECORDING_MAX_BYTES } from '@/lib/recording'
|
||||
import {
|
||||
RECORDING_FORBIDDEN_MESSAGE,
|
||||
RECORDING_SPEAKER_UNKNOWN_MESSAGE,
|
||||
} from '@/lib/schemas/recording'
|
||||
import { normalizeForSearch } from '@/lib/speechSearch'
|
||||
import type { CampaignUser, RecordingMedia } from '@/payload-types'
|
||||
import config from '@/payload.config'
|
||||
|
|
@ -41,7 +46,10 @@ import {
|
|||
RECORDING_STALE_MS,
|
||||
runRecordingJob,
|
||||
} from '@/utilities/recordings/recordingJob'
|
||||
import { loadRecordingsPageData } from '@/utilities/recordings/recordingPageData'
|
||||
import {
|
||||
loadRecordingFilterOptions,
|
||||
loadRecordingsPageData,
|
||||
} from '@/utilities/recordings/recordingPageData'
|
||||
import { receiveRecordingUpload } from '@/utilities/recordings/recordingUpload'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
|
@ -90,11 +98,18 @@ const createRecording = async ({
|
|||
status = 'ready',
|
||||
title = 'Plenária da Comissão',
|
||||
segments = [],
|
||||
speakerLabels,
|
||||
withMedia = true,
|
||||
}: {
|
||||
status?: 'uploading' | 'processing' | 'ready' | 'failed'
|
||||
title?: string
|
||||
segments?: { startSeconds: number; endSeconds: number; text: string }[]
|
||||
segments?: {
|
||||
startSeconds: number
|
||||
endSeconds: number
|
||||
text: string
|
||||
speakerKey?: string | null
|
||||
}[]
|
||||
speakerLabels?: { speakerKey: string; label: string }[]
|
||||
withMedia?: boolean
|
||||
} = {}) => {
|
||||
const media = withMedia ? await createMedia(`${title}.mp4`) : null
|
||||
|
|
@ -104,6 +119,7 @@ const createRecording = async ({
|
|||
title,
|
||||
status,
|
||||
...(media ? { media: media.id } : {}),
|
||||
...(speakerLabels ? { speakerLabels } : {}),
|
||||
},
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
|
@ -118,6 +134,7 @@ const createRecording = async ({
|
|||
startSeconds: segment.startSeconds,
|
||||
endSeconds: segment.endSeconds,
|
||||
text: segment.text,
|
||||
speakerKey: segment.speakerKey ?? null,
|
||||
}),
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
|
@ -555,4 +572,197 @@ describe('uploaded recordings (C199)', () => {
|
|||
expect(unfiltered.rows.length).toBeGreaterThanOrEqual(2)
|
||||
expect(unfiltered.rows.every((row) => row.excerpt === null)).toBe(true)
|
||||
})
|
||||
|
||||
it('filters recordings by a human label and lists the facet options', async () => {
|
||||
const marker = `solla${Date.now().toString(36)}`
|
||||
const { recording } = await createRecording({
|
||||
title: `Plenária com vozes ${marker}`,
|
||||
segments: [
|
||||
{ startSeconds: 0, endSeconds: 5, text: 'Primeira fala.', speakerKey: 'speaker-1' },
|
||||
{ startSeconds: 5, endSeconds: 10, text: 'Segunda fala.', speakerKey: 'speaker-2' },
|
||||
],
|
||||
speakerLabels: [{ speakerKey: 'speaker-1', label: `Dep. ${marker}` }],
|
||||
})
|
||||
const { recording: other } = await createRecording({
|
||||
title: `Sem falantes ${marker}`,
|
||||
segments: [],
|
||||
})
|
||||
|
||||
// The facet matches the same way the dialog chip does: case-insensitive
|
||||
// containment over the derived names, against the real join table.
|
||||
const filtered = await loadRecordingsPageData(payload, communicator, {
|
||||
source: 'enviadas',
|
||||
person: [`dep. ${marker}`],
|
||||
})
|
||||
expect(filtered.rows.map((row) => row.id)).toContain(recording.id)
|
||||
expect(filtered.rows.map((row) => row.id)).not.toContain(other.id)
|
||||
expect(filtered.rows.find((row) => row.id === recording.id)?.matchedPersons).toEqual([
|
||||
`dep. ${marker}`,
|
||||
])
|
||||
|
||||
const options = await loadRecordingFilterOptions(payload, communicator)
|
||||
expect(options.people).toContain(`Dep. ${marker}`)
|
||||
})
|
||||
|
||||
it('labels one cluster through the action and rebuilds the facet names', async () => {
|
||||
const marker = `falante${Date.now().toString(36)}`
|
||||
const { recording } = await createRecording({
|
||||
title: `Debate ${marker}`,
|
||||
segments: [{ startSeconds: 0, endSeconds: 5, text: 'Fala única.', speakerKey: 'speaker-1' }],
|
||||
})
|
||||
|
||||
getCampaignUserMock.mockResolvedValue(communicator)
|
||||
await labelRecordingSpeakerForActor({
|
||||
recordingId: recording.id,
|
||||
speakerKey: 'speaker-1',
|
||||
label: `Dep. ${marker}`,
|
||||
})
|
||||
|
||||
const updated = await payload.findByID({
|
||||
collection: 'recording',
|
||||
id: recording.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(updated.speakerLabels).toEqual([
|
||||
expect.objectContaining({ speakerKey: 'speaker-1', label: `Dep. ${marker}` }),
|
||||
])
|
||||
expect(updated.speakerNames).toEqual([`Dep. ${marker}`])
|
||||
|
||||
for (const denied of [advisor, leader]) {
|
||||
getCampaignUserMock.mockResolvedValue(denied)
|
||||
await expect(
|
||||
labelRecordingSpeakerForActor({
|
||||
recordingId: recording.id,
|
||||
speakerKey: 'speaker-1',
|
||||
label: 'Quem quer que seja',
|
||||
}),
|
||||
).rejects.toThrow(RECORDING_FORBIDDEN_MESSAGE)
|
||||
}
|
||||
|
||||
getCampaignUserMock.mockResolvedValue(communicator)
|
||||
await expect(
|
||||
labelRecordingSpeakerForActor({
|
||||
recordingId: recording.id,
|
||||
speakerKey: 'speaker-9',
|
||||
label: 'Agrupamento fantasma',
|
||||
}),
|
||||
).rejects.toThrow(RECORDING_SPEAKER_UNKNOWN_MESSAGE)
|
||||
})
|
||||
|
||||
it('groups the transcript with the injected diarizer and keeps a reconcilable label', async () => {
|
||||
const { recording } = await createRecording({
|
||||
status: 'processing',
|
||||
title: 'Plenária com falantes',
|
||||
segments: [
|
||||
{ startSeconds: 1, endSeconds: 4, text: 'Fala anterior A', speakerKey: 'speaker-1' },
|
||||
{ startSeconds: 5, endSeconds: 9, text: 'Fala anterior B', speakerKey: 'speaker-2' },
|
||||
],
|
||||
speakerLabels: [{ speakerKey: 'speaker-1', label: 'Dep. Jorge Solla' }],
|
||||
})
|
||||
|
||||
const restore = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
|
||||
try {
|
||||
await runRecordingJob(
|
||||
payload,
|
||||
recording.id,
|
||||
async () => ({
|
||||
ok: true,
|
||||
segments: [
|
||||
{ start: 1, end: 4, text: 'Bom dia a todas' },
|
||||
{ start: 5, end: 9, text: 'e a todos' },
|
||||
],
|
||||
durationSeconds: 1_200,
|
||||
}),
|
||||
async (file, options) => {
|
||||
expect(file).toBeInstanceOf(Blob)
|
||||
await options?.onProgress?.()
|
||||
return {
|
||||
ok: true,
|
||||
turns: [
|
||||
{ speaker: 'A', startSeconds: 0, endSeconds: 4.5 },
|
||||
{ speaker: 'B', startSeconds: 4.5, endSeconds: 10 },
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
|
||||
const updated = await payload.findByID({
|
||||
collection: 'recording',
|
||||
id: recording.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(updated.status).toBe('ready')
|
||||
expect(updated.speakerLabels).toEqual([
|
||||
expect.objectContaining({ speakerKey: 'speaker-1', label: 'Dep. Jorge Solla' }),
|
||||
])
|
||||
expect(updated.speakerLabelsDropped).toBe(false)
|
||||
|
||||
const segments = await payload.find({
|
||||
collection: 'recordingSegment',
|
||||
where: { recording: { equals: recording.id } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
sort: 'order',
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(segments.docs).toHaveLength(4)
|
||||
expect(segments.docs.map((segment) => segment.speakerKey)).toEqual([
|
||||
'speaker-1',
|
||||
'speaker-2',
|
||||
'speaker-2',
|
||||
'speaker-2',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the plain transcript and warns when the diarizer fails with labels present', async () => {
|
||||
const { recording } = await createRecording({
|
||||
status: 'processing',
|
||||
title: 'Debate sem provedor',
|
||||
segments: [{ startSeconds: 1, endSeconds: 4, text: 'Fala', speakerKey: 'speaker-1' }],
|
||||
speakerLabels: [{ speakerKey: 'speaker-1', label: 'Dep. Jorge Solla' }],
|
||||
})
|
||||
|
||||
const restore = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
|
||||
try {
|
||||
await runRecordingJob(
|
||||
payload,
|
||||
recording.id,
|
||||
async () => ({
|
||||
ok: true,
|
||||
segments: [{ start: 1, end: 4, text: 'Fala' }],
|
||||
durationSeconds: 600,
|
||||
}),
|
||||
async () => ({ ok: false, error: 'provider down', status: 502 }),
|
||||
)
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
|
||||
const updated = await payload.findByID({
|
||||
collection: 'recording',
|
||||
id: recording.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(updated.status).toBe('ready')
|
||||
expect(updated.speakerLabels ?? []).toEqual([])
|
||||
expect(updated.speakerLabelsDropped).toBe(true)
|
||||
|
||||
const segments = await payload.find({
|
||||
collection: 'recordingSegment',
|
||||
where: { recording: { equals: recording.id } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
sort: 'order',
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(segments.docs.every((segment) => !segment.speakerKey)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
134
tests/unit/assemblyAiDiarize.unit.spec.ts
Normal file
134
tests/unit/assemblyAiDiarize.unit.spec.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
ASSEMBLYAI_TRANSCRIPT_URL,
|
||||
ASSEMBLYAI_UPLOAD_URL,
|
||||
assemblyAiDiarize,
|
||||
} from '@/utilities/ai/assemblyAiDiarize'
|
||||
|
||||
type FetchCall = { url: string; init: RequestInit | undefined }
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200): Response =>
|
||||
new Response(JSON.stringify(body), { status })
|
||||
|
||||
const audioBlob = (): Blob => new Blob([new Uint8Array([1, 2, 3])], { type: 'audio/mpeg' })
|
||||
|
||||
const installFetch = (
|
||||
handler: (url: string, init: RequestInit | undefined, call: number) => Response,
|
||||
): FetchCall[] => {
|
||||
const calls: FetchCall[] = []
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
calls.push({ url, init })
|
||||
return handler(url, init, calls.length)
|
||||
}),
|
||||
)
|
||||
return calls
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('AssemblyAI diarization provider (C200)', () => {
|
||||
it('uploads, submits with speaker labels and normalizes ms to seconds', async () => {
|
||||
vi.stubEnv('ASSEMBLYAI_API_KEY', 'test-key')
|
||||
const calls = installFetch((url, init) => {
|
||||
if (url === ASSEMBLYAI_UPLOAD_URL)
|
||||
return jsonResponse({ upload_url: 'https://cdn/audio.mp3' })
|
||||
if (url === ASSEMBLYAI_TRANSCRIPT_URL && init?.method === 'POST') {
|
||||
return jsonResponse({ id: 'tr_1', status: 'queued' })
|
||||
}
|
||||
return jsonResponse({
|
||||
status: 'completed',
|
||||
utterances: [
|
||||
{ speaker: 'A', start: 1000, end: 2500 },
|
||||
{ speaker: 'B', start: 2500, end: 4000 },
|
||||
{ speaker: 'C', start: 'x', end: 10 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
const progress = vi.fn()
|
||||
const result = await assemblyAiDiarize(audioBlob(), {
|
||||
pollIntervalMs: 1,
|
||||
onProgress: progress,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
turns: [
|
||||
{ speaker: 'A', startSeconds: 1, endSeconds: 2.5 },
|
||||
{ speaker: 'B', startSeconds: 2.5, endSeconds: 4 },
|
||||
],
|
||||
})
|
||||
const create = calls.find(
|
||||
(call) => call.url === ASSEMBLYAI_TRANSCRIPT_URL && call.init?.method === 'POST',
|
||||
)
|
||||
expect(create?.init?.body).toContain('"speaker_labels":true')
|
||||
expect(create?.init?.body).toContain('"speech_models":["universal-2"]')
|
||||
expect(create?.init?.body).toContain('"language_code":"pt"')
|
||||
expect(new Headers(calls[0]?.init?.headers).get('authorization')).toBe('test-key')
|
||||
expect(calls.some((call) => call.init?.method === 'DELETE')).toBe(true)
|
||||
expect(progress).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers 503 without a key and never calls the provider', async () => {
|
||||
vi.stubEnv('ASSEMBLYAI_API_KEY', '')
|
||||
const calls = installFetch(() => jsonResponse({}))
|
||||
|
||||
expect(await assemblyAiDiarize(audioBlob())).toEqual({
|
||||
ok: false,
|
||||
error: 'Separação de falantes está indisponível no momento.',
|
||||
status: 503,
|
||||
})
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('degrades to an error when the upload fails', async () => {
|
||||
vi.stubEnv('ASSEMBLYAI_API_KEY', 'test-key')
|
||||
const calls = installFetch(() => jsonResponse({}, 500))
|
||||
|
||||
const result = await assemblyAiDiarize(audioBlob())
|
||||
expect(result.ok).toBe(false)
|
||||
expect(calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('degrades to an error and deletes the remote transcript on provider error', async () => {
|
||||
vi.stubEnv('ASSEMBLYAI_API_KEY', 'test-key')
|
||||
const calls = installFetch((url, init) => {
|
||||
if (url === ASSEMBLYAI_UPLOAD_URL)
|
||||
return jsonResponse({ upload_url: 'https://cdn/audio.mp3' })
|
||||
if (url === ASSEMBLYAI_TRANSCRIPT_URL && init?.method === 'POST') {
|
||||
return jsonResponse({ id: 'tr_9', status: 'queued' })
|
||||
}
|
||||
if (init?.method === 'DELETE') return jsonResponse({}, 200)
|
||||
return jsonResponse({ status: 'error', error: 'provider exploded' })
|
||||
})
|
||||
|
||||
expect(await assemblyAiDiarize(audioBlob(), { pollIntervalMs: 1 })).toMatchObject({ ok: false })
|
||||
expect(calls.some((call) => call.init?.method === 'DELETE')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a completed transcript without utterances (honest empty turns)', async () => {
|
||||
vi.stubEnv('ASSEMBLYAI_API_KEY', 'test-key')
|
||||
installFetch((url, init) => {
|
||||
if (url === ASSEMBLYAI_UPLOAD_URL)
|
||||
return jsonResponse({ upload_url: 'https://cdn/audio.mp3' })
|
||||
if (url === ASSEMBLYAI_TRANSCRIPT_URL && init?.method === 'POST') {
|
||||
return jsonResponse({ id: 'tr_2', status: 'queued' })
|
||||
}
|
||||
return jsonResponse({ status: 'completed', utterances: [] })
|
||||
})
|
||||
|
||||
expect(await assemblyAiDiarize(audioBlob(), { pollIntervalMs: 1 })).toEqual({
|
||||
ok: true,
|
||||
turns: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
} from '@/lib/recording'
|
||||
import {
|
||||
buildRecordingAudioFfmpegArgs,
|
||||
buildRecordingFullAudioFfmpegArgs,
|
||||
mergeChunkTranscriptions,
|
||||
RECORDING_AUDIO_CHUNK_SECONDS,
|
||||
recordingSearchText,
|
||||
|
|
@ -180,4 +181,17 @@ describe('recording transcription (C199)', () => {
|
|||
expect(mergeChunkTranscriptions([])).toEqual([])
|
||||
expect(recordingSearchText([])).toBe('')
|
||||
})
|
||||
|
||||
it('builds the single-file extraction for diarization (C200)', () => {
|
||||
const args = buildRecordingFullAudioFfmpegArgs({
|
||||
inputPath: '/tmp/in.mkv',
|
||||
outputPath: '/tmp/full.mp3',
|
||||
})
|
||||
expect(args).toContain('-vn')
|
||||
expect(args).toContain('libmp3lame')
|
||||
// One output, no segmentation: speaker numbering needs the whole audio.
|
||||
expect(args).not.toContain('-f')
|
||||
expect(args).not.toContain('-segment_time')
|
||||
expect(args.at(-1)).toBe('/tmp/full.mp3')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
177
tests/unit/recordingDiarization.unit.spec.ts
Normal file
177
tests/unit/recordingDiarization.unit.spec.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
assignSpeakerKeys,
|
||||
reconcileSpeakerLabels,
|
||||
RECORDING_DIARIZATION_MAX_SECONDS,
|
||||
recordingSpeakerDefaultLabel,
|
||||
speakerKeysInOrder,
|
||||
speakerNameMatches,
|
||||
speakerNamesFromLabels,
|
||||
type DiarizedTurn,
|
||||
} from '@/lib/recordingDiarization'
|
||||
|
||||
const segment = (startSeconds: number, endSeconds: number) => ({
|
||||
startSeconds,
|
||||
endSeconds,
|
||||
text: `trecho ${startSeconds}`,
|
||||
})
|
||||
|
||||
const turn = (speaker: string, startSeconds: number, endSeconds: number): DiarizedTurn => ({
|
||||
speaker,
|
||||
startSeconds,
|
||||
endSeconds,
|
||||
})
|
||||
|
||||
describe('recording speaker grouping (C200)', () => {
|
||||
it('assigns each segment to the turn with the largest overlap', () => {
|
||||
const segments = [segment(0, 10), segment(10, 20), segment(20, 30)]
|
||||
const turns = [turn('A', 0, 11), turn('B', 12, 30)]
|
||||
|
||||
const keyed = assignSpeakerKeys(segments, turns)
|
||||
expect(keyed.map((entry) => entry.speakerKey)).toEqual(['speaker-1', 'speaker-2', 'speaker-2'])
|
||||
// The original fields survive the assignment.
|
||||
expect(keyed[1]).toMatchObject({ startSeconds: 10, endSeconds: 20, text: 'trecho 10' })
|
||||
})
|
||||
|
||||
it('breaks an overlap tie by the closest midpoint', () => {
|
||||
const segments = [segment(0, 10)]
|
||||
// Both turns overlap the same 5 s; B's midpoint is closer to the segment's.
|
||||
const turns = [turn('B', 5, 20), turn('A', 0, 5)]
|
||||
|
||||
expect(assignSpeakerKeys(segments, turns)[0]?.speakerKey).toBe('speaker-1')
|
||||
expect(
|
||||
assignSpeakerKeys([segment(0, 10)], [turn('A', 0, 5), turn('B', 5, 20)])[0],
|
||||
).toMatchObject({
|
||||
speakerKey: 'speaker-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the nearest turn when there is no overlap at all', () => {
|
||||
const segments = [segment(0, 10), segment(30, 40)]
|
||||
const turns = [turn('A', 12, 20), turn('B', 45, 60)]
|
||||
|
||||
expect(assignSpeakerKeys(segments, turns).map((entry) => entry.speakerKey)).toEqual([
|
||||
'speaker-1',
|
||||
'speaker-2',
|
||||
])
|
||||
})
|
||||
|
||||
it('numbers clusters globally by first appearance, not by provider name', () => {
|
||||
const segments = [segment(0, 5), segment(5, 10), segment(10, 15)]
|
||||
const turns = [turn('spk_9', 10, 15), turn('spk_2', 0, 5), turn('spk_9', 0, 10)]
|
||||
|
||||
expect(assignSpeakerKeys(segments, turns).map((entry) => entry.speakerKey)).toEqual([
|
||||
'speaker-1',
|
||||
'speaker-2',
|
||||
'speaker-2',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps every key null without a usable turn and ignores invalid turns', () => {
|
||||
expect(assignSpeakerKeys([segment(0, 5)], []).map((entry) => entry.speakerKey)).toEqual([null])
|
||||
expect(
|
||||
assignSpeakerKeys([segment(0, 5)], [turn('', 0, 5), turn('A', Number.NaN, 5)]).map(
|
||||
(entry) => entry.speakerKey,
|
||||
),
|
||||
).toEqual([null])
|
||||
})
|
||||
|
||||
it('lists the cluster keys in first-appearance order', () => {
|
||||
expect(
|
||||
speakerKeysInOrder([
|
||||
{ speakerKey: 'speaker-2' },
|
||||
{ speakerKey: null },
|
||||
{ speakerKey: 'speaker-1' },
|
||||
{ speakerKey: 'speaker-2' },
|
||||
]),
|
||||
).toEqual(['speaker-2', 'speaker-1'])
|
||||
})
|
||||
|
||||
it('reconciles labels 1:1 by time overlap and reports what was dropped', () => {
|
||||
const previousSegments = [{ speakerKey: 'speaker-1', startSeconds: 0, endSeconds: 10 }]
|
||||
const nextSegments = [{ speakerKey: 'speaker-1', startSeconds: 0, endSeconds: 10 }]
|
||||
|
||||
expect(
|
||||
reconcileSpeakerLabels({
|
||||
previousLabels: [{ speakerKey: 'speaker-1', label: 'Dep. Jorge Solla' }],
|
||||
previousSegments,
|
||||
nextSegments,
|
||||
}),
|
||||
).toEqual({ labels: [{ speakerKey: 'speaker-1', label: 'Dep. Jorge Solla' }], dropped: false })
|
||||
|
||||
expect(
|
||||
reconcileSpeakerLabels({
|
||||
previousLabels: [{ speakerKey: 'speaker-1', label: 'Dep. Jorge Solla' }],
|
||||
previousSegments,
|
||||
nextSegments: [{ speakerKey: 'speaker-1', startSeconds: 40, endSeconds: 50 }],
|
||||
}),
|
||||
).toEqual({ labels: [], dropped: true })
|
||||
|
||||
expect(
|
||||
reconcileSpeakerLabels({
|
||||
previousLabels: [],
|
||||
previousSegments,
|
||||
nextSegments,
|
||||
}),
|
||||
).toEqual({ labels: [], dropped: false })
|
||||
})
|
||||
|
||||
it('never shares one new cluster between two old labels (merge)', () => {
|
||||
// Two old clusters merged into one new cluster: keeping either label would
|
||||
// name the other person's speech, so both are dropped.
|
||||
const result = reconcileSpeakerLabels({
|
||||
previousLabels: [
|
||||
{ speakerKey: 'speaker-1', label: 'Solla' },
|
||||
{ speakerKey: 'speaker-2', label: 'Outra pessoa' },
|
||||
],
|
||||
previousSegments: [
|
||||
{ speakerKey: 'speaker-1', startSeconds: 0, endSeconds: 5 },
|
||||
{ speakerKey: 'speaker-2', startSeconds: 5, endSeconds: 10 },
|
||||
],
|
||||
nextSegments: [{ speakerKey: 'speaker-1', startSeconds: 0, endSeconds: 10 }],
|
||||
})
|
||||
|
||||
expect(result).toEqual({ labels: [], dropped: true })
|
||||
})
|
||||
|
||||
it('never duplicates one label across two new clusters (split)', () => {
|
||||
// One old cluster split into two new ones: the label cannot name both.
|
||||
const result = reconcileSpeakerLabels({
|
||||
previousLabels: [{ speakerKey: 'speaker-1', label: 'Solla' }],
|
||||
previousSegments: [{ speakerKey: 'speaker-1', startSeconds: 0, endSeconds: 10 }],
|
||||
nextSegments: [
|
||||
{ speakerKey: 'speaker-1', startSeconds: 0, endSeconds: 5 },
|
||||
{ speakerKey: 'speaker-2', startSeconds: 5, endSeconds: 10 },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result).toEqual({ labels: [], dropped: true })
|
||||
})
|
||||
|
||||
it('derives distinct speaker names case-insensitively, in order', () => {
|
||||
expect(
|
||||
speakerNamesFromLabels([
|
||||
{ speakerKey: 'speaker-1', label: ' Dep. Jorge Solla ' },
|
||||
{ speakerKey: 'speaker-2', label: 'dep. jorge solla' },
|
||||
{ speakerKey: 'speaker-3', label: 'Presidente' },
|
||||
{ speakerKey: 'speaker-4', label: ' ' },
|
||||
]),
|
||||
).toEqual(['Dep. Jorge Solla', 'Presidente'])
|
||||
expect(speakerNamesFromLabels(null)).toEqual([])
|
||||
})
|
||||
|
||||
it('matches a facet term the same way the ILIKE query does', () => {
|
||||
expect(speakerNameMatches('Dep. Jorge Solla', 'solla')).toBe(true)
|
||||
expect(speakerNameMatches('Dep. Jorge Solla', 'Dep. Jorge')).toBe(true)
|
||||
expect(speakerNameMatches('Presidente', 'solla')).toBe(false)
|
||||
})
|
||||
|
||||
it('names the default label with a number and pins the provider ceiling', () => {
|
||||
expect(recordingSpeakerDefaultLabel(1)).toBe('Falante 1')
|
||||
expect(recordingSpeakerDefaultLabel(12)).toBe('Falante 12')
|
||||
expect(RECORDING_DIARIZATION_MAX_SECONDS).toBe(10 * 60 * 60)
|
||||
})
|
||||
})
|
||||
44
tests/unit/recordingListFilters.unit.spec.ts
Normal file
44
tests/unit/recordingListFilters.unit.spec.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildRecordingListWhere } from '@/utilities/recordings/recordingListFilters'
|
||||
|
||||
describe('recording list filters (C199/C200)', () => {
|
||||
it('returns an empty where without search or facet', () => {
|
||||
expect(buildRecordingListWhere({ source: 'enviadas', page: 1 })).toEqual({})
|
||||
})
|
||||
|
||||
it('searches the normalized transcript text', () => {
|
||||
expect(buildRecordingListWhere({ source: 'enviadas', page: 1, q: 'Comissão' })).toEqual({
|
||||
searchText: { like: 'comissao' },
|
||||
})
|
||||
})
|
||||
|
||||
it('filters one person through the derived names', () => {
|
||||
expect(
|
||||
buildRecordingListWhere({ source: 'enviadas', page: 1, people: ['Dep. Jorge Solla'] }),
|
||||
).toEqual({ speakerNames: { contains: 'Dep. Jorge Solla' } })
|
||||
})
|
||||
|
||||
it('ORs the selected people and ANDs them with the search', () => {
|
||||
expect(
|
||||
buildRecordingListWhere({
|
||||
source: 'enviadas',
|
||||
page: 1,
|
||||
q: 'merenda',
|
||||
people: ['Solla', 'Presidente'],
|
||||
}),
|
||||
).toEqual({
|
||||
and: [
|
||||
{ searchText: { like: 'merenda' } },
|
||||
{
|
||||
or: [
|
||||
{ speakerNames: { contains: 'Solla' } },
|
||||
{ speakerNames: { contains: 'Presidente' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -7,7 +7,10 @@ import {
|
|||
buildRecordingListHref,
|
||||
parseAcervoSource,
|
||||
parseRecordingListParams,
|
||||
parseRecordingPeople,
|
||||
removeRecordingPerson,
|
||||
resolveRecordingListUrl,
|
||||
toggleRecordingPerson,
|
||||
} from '@/utilities/recordings/recordingListUrl'
|
||||
|
||||
describe('recording list URL (C199)', () => {
|
||||
|
|
@ -68,4 +71,63 @@ describe('recording list URL (C199)', () => {
|
|||
expect(buildAcervoSourceHref('enviadas')).toBe('/campanha/comunicacao/acervo?source=enviadas')
|
||||
expect(buildAcervoSourceHref('camara')).toBe('/campanha/comunicacao/acervo')
|
||||
})
|
||||
|
||||
it('parses repeated `person` values: trim, oversized drop, case-insensitive dedupe', () => {
|
||||
expect(parseRecordingPeople([' Solla ', 'solla', 'Presidente'])).toEqual([
|
||||
'Solla',
|
||||
'Presidente',
|
||||
])
|
||||
expect(parseRecordingPeople(undefined)).toEqual([])
|
||||
expect(parseRecordingPeople('')).toEqual([])
|
||||
expect(parseRecordingPeople('x'.repeat(121))).toEqual([])
|
||||
})
|
||||
|
||||
it('canonicalizes and serializes the `person` facet with the query', () => {
|
||||
const parsed = parseRecordingListParams({
|
||||
source: 'enviadas',
|
||||
q: 'merenda',
|
||||
person: ['Solla', 'Presidente'],
|
||||
})
|
||||
expect(parsed.people).toEqual(['Solla', 'Presidente'])
|
||||
|
||||
const canonical = resolveRecordingListUrl({
|
||||
source: 'enviadas',
|
||||
q: 'merenda',
|
||||
person: ['Solla', 'Presidente'],
|
||||
})
|
||||
expect(canonical.href).toBe(
|
||||
'/campanha/comunicacao/acervo?source=enviadas&q=merenda&person=Solla&person=Presidente',
|
||||
)
|
||||
expect(canonical.redirectHref).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops a duplicated facet value on canonicalization', () => {
|
||||
const canonical = resolveRecordingListUrl({
|
||||
source: 'enviadas',
|
||||
person: ['Solla', 'solla', 'x'.repeat(200)],
|
||||
})
|
||||
expect(canonical.href).toBe('/campanha/comunicacao/acervo?source=enviadas&person=Solla')
|
||||
expect(canonical.redirectHref).toBe('/campanha/comunicacao/acervo?source=enviadas&person=Solla')
|
||||
})
|
||||
|
||||
it('toggles and removes one person, resetting to the first page', () => {
|
||||
const base = { source: 'enviadas' as const, page: 3, q: 'merenda' }
|
||||
|
||||
const added = toggleRecordingPerson(base, 'Solla')
|
||||
expect(added).toEqual({ ...base, page: 1, people: ['Solla'] })
|
||||
|
||||
const removed = toggleRecordingPerson({ ...base, people: ['Solla'] }, 'solla')
|
||||
expect(removed).toEqual({ ...base, page: 1, people: undefined })
|
||||
|
||||
expect(removeRecordingPerson({ ...base, people: ['Solla', 'Presidente'] }, 'Solla')).toEqual({
|
||||
...base,
|
||||
page: 1,
|
||||
people: ['Presidente'],
|
||||
})
|
||||
expect(removeRecordingPerson({ ...base, people: ['Solla'] }, 'Solla')).toEqual({
|
||||
...base,
|
||||
page: 1,
|
||||
people: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue