mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
fix(C153): colisão de sourceKey, match de município e reclassificação limpa
Chave sufixada por hash de conteúdo para falas distintas que compartilham data/tipo/fase (caso real 2026-06-17T17:16); keywords oficiais deixam de alimentar o gazetteer de lugares; nomes ambíguos (Saúde, Wagner, Santana...) exigem contexto de lugar; teste de nav do communicator; plano com prova viva e changelog.
This commit is contained in:
parent
aca3ea6188
commit
7ca708a3cb
9 changed files with 146 additions and 14 deletions
1
docs/changelog/2026-09-13-c153.md
Normal file
1
docs/changelog/2026-09-13-c153.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
**C153 — Catálogo de falas do Solla: import idempotente e busca textual (2026-09-13):** o acervo de falas ganhou banco próprio. Duas collections no grupo `Comunicação`: `speech` (data/hora, legislatura/ano, tipo/fase, duração, sumário, transcrição oficial, keywords cruas, evento/YouTube, identidade de vídeo `eventId+audioId+excerptTMs` com links do VOD, quem presidia, temas/alcance com proveniência `gazetteer|llm|manual`, municípios/pessoas/programas/projetos citados) e `speechSegment` (segmentos ASR com `start/end`, `text` e `searchText` normalizado; admin-hidden). Papel novo `communicator` ("Assessor de Comunicação") com leitura do catálogo sem herdar nenhuma área staff — leitura = communicator/coordinator/candidate/admin, update = coordinator/candidate/admin, create/delete = admin; `advisor`/`leader` negados (migration adiciona o valor ao enum). Busca: `searchText` normalizado (NFD sem acento, minúsculas) + índice GIN `pg_trgm` hand-written, o motor do C154. Import `scripts/import-camara-speeches.mjs` (`pnpm camara:import`, guard de banco local) reusa a lógica pura do piloto C152 via `scripts/lib/camaraFetch.mjs` (HTTP/VOD/ASR extraídos) e `scripts/lib/camaraSpeeches.mjs` (adições: `speechSourceKey`, `speechContentHash`, `parseOfficialKeywords`, `legislatureForDate`, `parseDurationToSeconds`, `parsePresidingOfficerTransitions`/`resolvePresidingOfficer` para "quem presidia" a partir das trocas de mesa); facetas via gazetteer offline (`src/lib/speechGazetteer.ts`) + LLM Deep Infra `deepseek-ai/DeepSeek-V4-Flash` com zod e fallback, persistidas por `upsertSpeechBundle` (`src/utilities/speech/speechImport.ts`) em transação, idempotente por `sourceKey`, preservando curadoria `manual` e nunca apagando segmentos/mídia sem substituto. Prova viva na 57ª legislatura (banco local): 234 discursos, 7.590 segmentos, 232 com vídeo, 231 com segmentos, 224 com quem presidia, 208 com pessoas e 42 com municípios citados; re-run idempotente em 83s (0 criados, 0 ASR, 0 LLM); colisão real de chave (2026-06-17T17:16) resolvida com sufixo por hash de conteúdo; gap conhecido de 1 áudio sem faixa suportada (Deep Infra 400). 16 unit tests de facetas/busca + 31 do módulo Câmara + int de access/upsert; migrations `20260913_001112_add_speech_catalog` e `20260913_001200_add_speech_segment_trgm_index`.
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Impl: Catálogo de falas do Solla: import idempotente e busca textual
|
# Impl: Catálogo de falas do Solla: import idempotente e busca textual
|
||||||
|
|
||||||
Status: em execução
|
Status: executado
|
||||||
Atualizado em: 2026-09-12
|
Atualizado em: 2026-09-13
|
||||||
Issue: #955
|
Issue: #955
|
||||||
Intenção: docs/plans/catalogo-falas-solla.md
|
Intenção: docs/plans/catalogo-falas-solla.md
|
||||||
Appetite restante: herdado (~2–3 dias; a Fase 4 é wall-clock de máquina, sem trabalho humano)
|
Appetite restante: herdado (~2–3 dias; a Fase 4 é wall-clock de máquina, sem trabalho humano)
|
||||||
|
|
@ -150,11 +150,21 @@ flowchart LR
|
||||||
|
|
||||||
## Aceite de engenharia
|
## Aceite de engenharia
|
||||||
|
|
||||||
- [ ] Aceite de produto da intenção coberto: 57ª no banco local com transcrição oficial + segmentos + facetas + menções; reexecução sem duplicar; relatório por execução (total, com vídeo, sem trecho, falhas, tempo, custo ASR); busca por palavra com e sem acento; keywords cruas preservadas.
|
- [x] Aceite de produto da intenção coberto: 57ª no banco local com transcrição oficial + segmentos + facetas + menções; reexecução sem duplicar; relatório por execução (total, com vídeo, sem trecho, falhas, tempo, custo ASR); busca por palavra com e sem acento; keywords cruas preservadas.
|
||||||
- [ ] Invariantes AGENTS/engineering-standards: `access` explícito nas duas collections; migration via `pnpm migrate:create` (+ índice hand-written registrado); `src/utilities/speech/`; `server-only` nos módulos acoplados; identificadores em inglês; sem `Contact` paralelo; `data/camara/` gitignored; `overrideAccess: true` com comentário "bypass"; knip sem órfão; crédito CC BY 4.0 no admin/relatório.
|
- [x] Invariantes AGENTS/engineering-standards: `access` explícito nas duas collections; migration via `pnpm migrate:create` (+ índice hand-written registrado); `src/utilities/speech/`; `server-only` nos módulos acoplados; identificadores em inglês; sem `Contact` paralelo; `data/camara/` gitignored; `overrideAccess: true` com comentário "bypass"; knip sem órfão; crédito CC BY 4.0 no admin/relatório.
|
||||||
- [ ] Testes de domínio: unit (facets, search, presiding, role predicate) e int (access allow/deny, upsert idempotente, replace de segmentos, manual preservado, `segments: undefined`, hook `searchText` + `contains`).
|
- [x] Testes de domínio: unit (facets, search, presiding, role predicate) e int (access allow/deny, upsert idempotente, replace de segmentos, manual preservado, `segments: undefined`, hook `searchText` + `contains`).
|
||||||
- [ ] Gates: `pnpm gate:fast`; `pnpm migrate`/`migrate:status` limpos; `pnpm knip`; `pnpm generate:types` sem diff; `pnpm test:e2e:affected`; `pnpm push`; changelog `docs/changelog/2026-09-12-c153.md`.
|
- [x] Gates: `pnpm gate:fast`; `pnpm migrate`/`migrate:status` limpos; `pnpm knip`; `pnpm generate:types` sem diff; e2e curado do CI (`campaignPermissionProfileHttp` + `campaignDemandVisibility`) verde em dev; `pnpm push`; changelog `docs/changelog/2026-09-13-c153.md`.
|
||||||
- [ ] Prova viva registrada (relatório JSON da 57ª + re-run idempotente) no PR.
|
- [x] Prova viva registrada (relatório JSON da 57ª + re-run idempotente) no PR.
|
||||||
|
|
||||||
|
## Prova viva (2026-09-13, `teqo_wt153`)
|
||||||
|
|
||||||
|
- **Run inicial:** 234 discursos listados/processados em 10.773s; 206 com segmentos; 26 falhas de rede/ASR.
|
||||||
|
- **Re-run de recuperação:** 19 min; 231 com segmentos; colisão de `sourceKey` (2026-06-17T17:16, "PELA ORDEM") detectada e resolvida com chave sufixada por hash de conteúdo — as duas falas existem.
|
||||||
|
- **Re-run idempotente final:** 83s, `0 criados / 234 atualizados`, 0 chamadas de ASR, 0 de LLM (nada re-transcrito nem re-classificado).
|
||||||
|
- **Estado final:** 234 discursos; 7.590 segmentos; 232 com identidade de vídeo; 231 com segmentos; 224 com "quem presidia"; 208 com pessoas citadas; 89 com programas; 42 com município citado; keywords oficiais preservadas; 1 chave sufixada (colisão real).
|
||||||
|
- **Busca (contrato do C154):** `search_text LIKE '%saude%'` → 295 segmentos; `'%petrobras%'` → 34; `'%educacao%'` → 114; `' sus '` → 20.
|
||||||
|
- **Gap conhecido (1/234):** o trecho de 2023-03-15T17:16 é rejeitado pela Deep Infra ("Invalid or unsupported audio file") — MP4 válido de 9,7 MB, mas sem faixa de áudio suportada. O discurso existe com transcrição oficial e sem segmentos; documentado no relatório. Extrair áudio com ffmpeg é o fallback conhecido (não instalado; não vale a dependência por 1 arquivo).
|
||||||
|
- **Falsos positivos de município corrigidos no caminho:** keywords oficiais (vocabulário de tema, ex. "Saúde") deixaram de alimentar o match de lugares; nomes ambíguos (Saúde, Central, Palmeiras, Planalto, Santana, Wagner, Juazeiro...) exigem contexto de lugar ("em X", "cidade de X"). Reclassificação final sem os 24 falsos positivos de Saúde (BA).
|
||||||
|
|
||||||
## Self-score decision-quality
|
## Self-score decision-quality
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import {
|
||||||
parsePresidingOfficerTransitions,
|
parsePresidingOfficerTransitions,
|
||||||
resolvePresidingOfficer,
|
resolvePresidingOfficer,
|
||||||
selectSpeechEvents,
|
selectSpeechEvents,
|
||||||
|
speechContentHash,
|
||||||
speechDate,
|
speechDate,
|
||||||
speechSourceKey,
|
speechSourceKey,
|
||||||
} from './lib/camaraSpeeches.mjs'
|
} from './lib/camaraSpeeches.mjs'
|
||||||
|
|
@ -238,9 +239,21 @@ async function transcribeExcerpt(eventId, excerpt, options) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processSpeech(payload, speech, options, run) {
|
async function processSpeech(payload, speech, options, run) {
|
||||||
const sourceKey = speechSourceKey(speech)
|
// Identity: the API triple is the key; a genuinely distinct speech sharing
|
||||||
|
// the triple (seen 2026-06-17T17:16) gets a content-hash suffix instead of
|
||||||
|
// overwriting the first one.
|
||||||
|
const baseKey = speechSourceKey(speech)
|
||||||
|
const contentHash = speechContentHash(speech.sumario, speech.transcricao)
|
||||||
|
const baseState = await findSpeechImportState(payload, baseKey)
|
||||||
|
const suffixedKey =
|
||||||
|
Boolean(baseState) &&
|
||||||
|
contentHash !== speechContentHash(baseState.summary, baseState.officialTranscript)
|
||||||
|
const sourceKey = suffixedKey ? `${baseKey}#${contentHash}` : baseKey
|
||||||
|
const existing = suffixedKey ? await findSpeechImportState(payload, sourceKey) : baseState
|
||||||
|
|
||||||
const report = {
|
const report = {
|
||||||
sourceKey,
|
sourceKey,
|
||||||
|
suffixedKey,
|
||||||
speechAt: speech.dataHoraInicio,
|
speechAt: speech.dataHoraInicio,
|
||||||
eventId: null,
|
eventId: null,
|
||||||
excerpt: null,
|
excerpt: null,
|
||||||
|
|
@ -253,7 +266,6 @@ async function processSpeech(payload, speech, options, run) {
|
||||||
failures: [],
|
failures: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await findSpeechImportState(payload, sourceKey)
|
|
||||||
const keywords = parseOfficialKeywords(speech.keywords)
|
const keywords = parseOfficialKeywords(speech.keywords)
|
||||||
const date = speechDate(speech.dataHoraInicio)
|
const date = speechDate(speech.dataHoraInicio)
|
||||||
const year = /^\d{4}-\d{2}-\d{2}$/.test(date) ? Number(date.slice(0, 4)) : null
|
const year = /^\d{4}-\d{2}-\d{2}$/.test(date) ? Number(date.slice(0, 4)) : null
|
||||||
|
|
@ -422,6 +434,7 @@ const speechLine = (report) => {
|
||||||
report.created ? 'criado' : 'atualizado',
|
report.created ? 'criado' : 'atualizado',
|
||||||
report.excerpt ? `trecho a=${report.excerpt.audioId} t=${report.excerpt.tMs}` : 'sem trecho',
|
report.excerpt ? `trecho a=${report.excerpt.audioId} t=${report.excerpt.tMs}` : 'sem trecho',
|
||||||
]
|
]
|
||||||
|
if (report.suffixedKey) parts.push('chave sufixada por colisão')
|
||||||
if (report.vodState) parts.push(`VOD ${report.vodState}`)
|
if (report.vodState) parts.push(`VOD ${report.vodState}`)
|
||||||
if (report.segmentCount > 0) parts.push(`${report.segmentCount} segmentos`)
|
if (report.segmentCount > 0) parts.push(`${report.segmentCount} segmentos`)
|
||||||
if (report.presidingOfficer) parts.push(`presidiu ${report.presidingOfficer}`)
|
if (report.presidingOfficer) parts.push(`presidiu ${report.presidingOfficer}`)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
* decisions, so the fragile rules (HTML anchors, time-of-day matching, epoch
|
* decisions, so the fragile rules (HTML anchors, time-of-day matching, epoch
|
||||||
* offsets, provider shapes) are unit-tested without network.
|
* offsets, provider shapes) are unit-tested without network.
|
||||||
*/
|
*/
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
|
||||||
/** Câmara's open-data deputy id for Jorge Solla (used by the pilot). */
|
/** Câmara's open-data deputy id for Jorge Solla (used by the pilot). */
|
||||||
export const SOLLA_DEPUTY_ID = 178857
|
export const SOLLA_DEPUTY_ID = 178857
|
||||||
|
|
@ -78,7 +79,9 @@ export const speechDate = (iso) => String(iso ?? '').slice(0, 10)
|
||||||
/**
|
/**
|
||||||
* Stable natural key of an open-data speech. The API has no id, so identity is
|
* Stable natural key of an open-data speech. The API has no id, so identity is
|
||||||
* the naive datetime plus the speech type and event phase — the record is
|
* the naive datetime plus the speech type and event phase — the record is
|
||||||
* updated in place across imports (C153 idempotency).
|
* updated in place across imports (C153 idempotency). Two genuinely distinct
|
||||||
|
* speeches can share that triple (seen on 2026-06-17T17:16, "PELA ORDEM"); the
|
||||||
|
* importer disambiguates those with `speechContentHash`.
|
||||||
*
|
*
|
||||||
* @param {{ dataHoraInicio?: string, tipoDiscurso?: string, faseEvento?: { titulo?: string } } | null | undefined} speech
|
* @param {{ dataHoraInicio?: string, tipoDiscurso?: string, faseEvento?: { titulo?: string } } | null | undefined} speech
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
|
|
@ -90,6 +93,21 @@ export const speechSourceKey = (speech) =>
|
||||||
String(speech?.faseEvento?.titulo ?? '').trim(),
|
String(speech?.faseEvento?.titulo ?? '').trim(),
|
||||||
].join('|')
|
].join('|')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Short content hash (summary + official transcript) used to disambiguate a
|
||||||
|
* `sourceKey` collision: the first item keeps the base key, a distinct item
|
||||||
|
* gets `<baseKey>#<hash>` and stays stable across re-imports.
|
||||||
|
*
|
||||||
|
* @param {unknown} summary
|
||||||
|
* @param {unknown} transcript
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export const speechContentHash = (summary, transcript) =>
|
||||||
|
createHash('sha256')
|
||||||
|
.update(`${String(summary ?? '').trim()}\n${String(transcript ?? '').trim()}`)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 12)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Official keywords come as a newline-separated string; keep each keyword raw.
|
* Official keywords come as a newline-separated string; keep each keyword raw.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -364,6 +364,28 @@ const maskFalsePositivePhrases = (value: string): string => {
|
||||||
return masked
|
return masked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Municipality names that are also common words, institutions or people
|
||||||
|
* ("Saúde", "Central", "Palmeiras", "Planalto", "Santana", "Wagner", the
|
||||||
|
* senator Jaques Wagner): a bare capitalized match is not enough, so they
|
||||||
|
* require a place context. Curated — grows with observed false positives.
|
||||||
|
*/
|
||||||
|
const AMBIGUOUS_MUNICIPALITY_CITIES = new Set([
|
||||||
|
'saude',
|
||||||
|
'central',
|
||||||
|
'gloria',
|
||||||
|
'palmeiras',
|
||||||
|
'planalto',
|
||||||
|
'santana',
|
||||||
|
'seabra',
|
||||||
|
'urandi',
|
||||||
|
'wanderley',
|
||||||
|
'wagner',
|
||||||
|
'juazeiro',
|
||||||
|
])
|
||||||
|
|
||||||
|
const PLACE_CONTEXT = /(?:^|[^a-z0-9])(?:em|para|cidade de|municipio de|distrito de) $/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Matches catalog municipality names in the text. Longest names are consumed
|
* Matches catalog municipality names in the text. Longest names are consumed
|
||||||
* first so "São Félix do Coribe" never also yields "Coribe"; a match must
|
* first so "São Félix do Coribe" never also yields "Coribe"; a match must
|
||||||
|
|
@ -386,6 +408,12 @@ export const matchMunicipalityMentions = (text: string): MunicipalityCatalogEntr
|
||||||
while ((match = pattern.exec(haystack)) !== null) {
|
while ((match = pattern.exec(haystack)) !== null) {
|
||||||
const start = match.index + match[1].length
|
const start = match.index + match[1].length
|
||||||
if (!/[A-Z]/.test(comparable[start] ?? '')) continue
|
if (!/[A-Z]/.test(comparable[start] ?? '')) continue
|
||||||
|
if (
|
||||||
|
AMBIGUOUS_MUNICIPALITY_CITIES.has(matcher.normalized) &&
|
||||||
|
!PLACE_CONTEXT.test(comparable.slice(0, start).toLowerCase())
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
found = true
|
found = true
|
||||||
for (let index = start; index < start + matcher.normalized.length; index += 1) {
|
for (let index = start; index < start + matcher.normalized.length; index += 1) {
|
||||||
masked[index] = ' '
|
masked[index] = ' '
|
||||||
|
|
@ -415,16 +443,22 @@ export type SpeechFacetInput = {
|
||||||
/**
|
/**
|
||||||
* Offline facet pass: topic lexicon + scope lexicon + municipality gazetteer.
|
* Offline facet pass: topic lexicon + scope lexicon + municipality gazetteer.
|
||||||
* People/programs/projects require the LLM pass; this pass never invents them.
|
* People/programs/projects require the LLM pass; this pass never invents them.
|
||||||
|
* Municipality mentions ignore the official keywords: they are a controlled
|
||||||
|
* topic vocabulary ("Saúde" the theme) and produced false place matches.
|
||||||
*/
|
*/
|
||||||
export const classifySpeechByGazetteer = (input: SpeechFacetInput): SpeechFacetClassification => {
|
export const classifySpeechByGazetteer = (input: SpeechFacetInput): SpeechFacetClassification => {
|
||||||
const text = [input.summary ?? '', input.keywords?.join(' ') ?? '', input.transcript].join(' ')
|
const keywordText = input.keywords?.join(' ') ?? ''
|
||||||
const normalized = normalizeForSearch(text)
|
const normalized = normalizeForSearch(
|
||||||
|
[input.summary ?? '', keywordText, input.transcript].join(' '),
|
||||||
|
)
|
||||||
|
|
||||||
const topics = SPEECH_TOPICS.filter(({ value }) =>
|
const topics = SPEECH_TOPICS.filter(({ value }) =>
|
||||||
SPEECH_TOPIC_TERMS[value].some((term) => containsTerm(normalized, term)),
|
SPEECH_TOPIC_TERMS[value].some((term) => containsTerm(normalized, term)),
|
||||||
).map(({ value }) => value)
|
).map(({ value }) => value)
|
||||||
|
|
||||||
const municipalities = matchMunicipalityMentions(text)
|
const municipalities = matchMunicipalityMentions(
|
||||||
|
[input.summary ?? '', input.transcript].join(' '),
|
||||||
|
)
|
||||||
const matchedScopes = new Set<SpeechScope>(
|
const matchedScopes = new Set<SpeechScope>(
|
||||||
SPEECH_SCOPES.filter(({ value }) =>
|
SPEECH_SCOPES.filter(({ value }) =>
|
||||||
SPEECH_SCOPE_TERMS[value].some((term) => containsTerm(normalized, term)),
|
SPEECH_SCOPE_TERMS[value].some((term) => containsTerm(normalized, term)),
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@ export type SpeechImportState = {
|
||||||
durationSeconds: number | null
|
durationSeconds: number | null
|
||||||
vodPlaybackUrl: string | null
|
vodPlaybackUrl: string | null
|
||||||
vodDownloadUrl: string | null
|
vodDownloadUrl: string | null
|
||||||
|
summary: string | null
|
||||||
|
officialTranscript: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SpeechUpsertCounts = {
|
export type SpeechUpsertCounts = {
|
||||||
|
|
@ -83,6 +85,8 @@ export const findSpeechImportState = async (
|
||||||
durationSeconds: true,
|
durationSeconds: true,
|
||||||
vodPlaybackUrl: true,
|
vodPlaybackUrl: true,
|
||||||
vodDownloadUrl: true,
|
vodDownloadUrl: true,
|
||||||
|
summary: true,
|
||||||
|
officialTranscript: true,
|
||||||
},
|
},
|
||||||
// Intentional bypass: the import CLI is a trusted actor with no session.
|
// Intentional bypass: the import CLI is a trusted actor with no session.
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
|
|
@ -106,6 +110,8 @@ export const findSpeechImportState = async (
|
||||||
durationSeconds: speech.durationSeconds ?? null,
|
durationSeconds: speech.durationSeconds ?? null,
|
||||||
vodPlaybackUrl: speech.vodPlaybackUrl ?? null,
|
vodPlaybackUrl: speech.vodPlaybackUrl ?? null,
|
||||||
vodDownloadUrl: speech.vodDownloadUrl ?? null,
|
vodDownloadUrl: speech.vodDownloadUrl ?? null,
|
||||||
|
summary: speech.summary ?? null,
|
||||||
|
officialTranscript: speech.officialTranscript ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
parseVodStatus,
|
parseVodStatus,
|
||||||
resolvePresidingOfficer,
|
resolvePresidingOfficer,
|
||||||
selectSpeechEvents,
|
selectSpeechEvents,
|
||||||
|
speechContentHash,
|
||||||
speechDate,
|
speechDate,
|
||||||
speechSourceKey,
|
speechSourceKey,
|
||||||
speechTimeOfDaySeconds,
|
speechTimeOfDaySeconds,
|
||||||
|
|
@ -274,6 +275,18 @@ describe('speechSourceKey', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('speechContentHash', () => {
|
||||||
|
it('is stable for the same content and differs otherwise', () => {
|
||||||
|
expect(speechContentHash('Sumário', 'Transcrição')).toBe(
|
||||||
|
speechContentHash('Sumário', 'Transcrição'),
|
||||||
|
)
|
||||||
|
expect(speechContentHash('Sumário', 'Transcrição')).not.toBe(
|
||||||
|
speechContentHash('Outro sumário', 'Transcrição'),
|
||||||
|
)
|
||||||
|
expect(speechContentHash(null, null)).toHaveLength(12)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('parseOfficialKeywords', () => {
|
describe('parseOfficialKeywords', () => {
|
||||||
it('splits the raw newline string and keeps each keyword', () => {
|
it('splits the raw newline string and keeps each keyword', () => {
|
||||||
expect(parseOfficialKeywords('Saúde, SUS\r\nEducação\r\n\r\nEscola')).toEqual([
|
expect(parseOfficialKeywords('Saúde, SUS\r\nEducação\r\n\r\nEscola')).toEqual([
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,14 @@ describe('agenda sidebar entry', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('communicator sidebar', () => {
|
||||||
|
it('has no staff destinations before the C154 vertical lands', () => {
|
||||||
|
expect(getCampaignNav('communicator')).toEqual([])
|
||||||
|
expect(getCampaignBottomNav('communicator')).toHaveLength(0)
|
||||||
|
expect(getCampaignOverflowNav('communicator')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('mobile bottom nav', () => {
|
describe('mobile bottom nav', () => {
|
||||||
it('offers exactly five primary items to staff, zero to leaders', () => {
|
it('offers exactly five primary items to staff, zero to leaders', () => {
|
||||||
for (const role of ['coordinator', 'advisor', 'candidate'] as const) {
|
for (const role of ['coordinator', 'advisor', 'candidate'] as const) {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,23 @@ describe('classifySpeechByGazetteer', () => {
|
||||||
|
|
||||||
expect(result.scopes).toEqual(['bahia'])
|
expect(result.scopes).toEqual(['bahia'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('ignores official keywords for place mentions (controlled topic vocabulary)', () => {
|
||||||
|
const result = classifySpeechByGazetteer({
|
||||||
|
summary: 'Debate sobre financiamento da educação.',
|
||||||
|
keywords: ['Saúde', 'Educação', 'Governo federal'],
|
||||||
|
transcript: 'Precisamos investir na educação pública.',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.topics).toContain('saude')
|
||||||
|
expect(result.municipalities).toEqual([])
|
||||||
|
|
||||||
|
const withPlace = classifySpeechByGazetteer({
|
||||||
|
summary: 'Investimento em Saúde, no interior da Bahia.',
|
||||||
|
transcript: 'A comitiva esteve em Saúde.',
|
||||||
|
})
|
||||||
|
expect(withPlace.municipalities.map((entry) => entry.slug)).toEqual(['saude'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('matchMunicipalityMentions', () => {
|
describe('matchMunicipalityMentions', () => {
|
||||||
|
|
@ -59,12 +76,24 @@ describe('matchMunicipalityMentions', () => {
|
||||||
it('requires a capitalized proper noun (prose "saúde" is not the municipality Saúde)', () => {
|
it('requires a capitalized proper noun (prose "saúde" is not the municipality Saúde)', () => {
|
||||||
expect(matchMunicipalityMentions('A saúde pública é prioridade.')).toEqual([])
|
expect(matchMunicipalityMentions('A saúde pública é prioridade.')).toEqual([])
|
||||||
expect(
|
expect(
|
||||||
matchMunicipalityMentions('A agenda passou por Saúde, no interior da Bahia.').map(
|
matchMunicipalityMentions('A agenda passou em Saúde, no interior da Bahia.').map(
|
||||||
(entry) => entry.slug,
|
(entry) => entry.slug,
|
||||||
),
|
),
|
||||||
).toEqual(['saude'])
|
).toEqual(['saude'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('requires a place context for ambiguous names (Saúde, Wagner, Santana)', () => {
|
||||||
|
expect(matchMunicipalityMentions('A visita da Ministra da Saúde foi destaque.')).toEqual([])
|
||||||
|
expect(matchMunicipalityMentions('O senador Jaques Wagner discursou.')).toEqual([])
|
||||||
|
expect(matchMunicipalityMentions('O deputado Santana votou com o governo.')).toEqual([])
|
||||||
|
expect(
|
||||||
|
matchMunicipalityMentions('Ele esteve em Saúde, no interior.').map((entry) => entry.slug),
|
||||||
|
).toEqual(['saude'])
|
||||||
|
expect(
|
||||||
|
matchMunicipalityMentions('A comitiva passou em Wagner.').map((entry) => entry.slug),
|
||||||
|
).toEqual(['wagner'])
|
||||||
|
})
|
||||||
|
|
||||||
it('ignores institutional phrases that contain a municipality name', () => {
|
it('ignores institutional phrases that contain a municipality name', () => {
|
||||||
expect(matchMunicipalityMentions('O Sistema Único de Saúde atende a população.')).toEqual([])
|
expect(matchMunicipalityMentions('O Sistema Único de Saúde atende a população.')).toEqual([])
|
||||||
expect(matchMunicipalityMentions('O Banco Central elevou os juros.')).toEqual([])
|
expect(matchMunicipalityMentions('O Banco Central elevou os juros.')).toEqual([])
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue