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:
Francisco Solla 2026-09-13 01:48:24 -03:00
parent aca3ea6188
commit 7ca708a3cb
9 changed files with 146 additions and 14 deletions

View 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`.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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([])