From 7ca708a3cbab13d37e48e02f916191dfedfdd56f Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 13 Sep 2026 01:48:24 -0300 Subject: [PATCH] =?UTF-8?q?fix(C153):=20colis=C3=A3o=20de=20sourceKey,=20m?= =?UTF-8?q?atch=20de=20munic=C3=ADpio=20e=20reclassifica=C3=A7=C3=A3o=20li?= =?UTF-8?q?mpa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/changelog/2026-09-13-c153.md | 1 + docs/plans/catalogo-falas-solla-impl.md | 24 ++++++++++----- scripts/import-camara-speeches.mjs | 17 +++++++++-- scripts/lib/camaraSpeeches.mjs | 20 ++++++++++++- src/lib/speechGazetteer.ts | 40 +++++++++++++++++++++++-- src/utilities/speech/speechImport.ts | 6 ++++ tests/unit/camaraSpeeches.unit.spec.ts | 13 ++++++++ tests/unit/campaignNav.unit.spec.ts | 8 +++++ tests/unit/speechFacets.unit.spec.ts | 31 ++++++++++++++++++- 9 files changed, 146 insertions(+), 14 deletions(-) create mode 100644 docs/changelog/2026-09-13-c153.md diff --git a/docs/changelog/2026-09-13-c153.md b/docs/changelog/2026-09-13-c153.md new file mode 100644 index 00000000..ad92b2a9 --- /dev/null +++ b/docs/changelog/2026-09-13-c153.md @@ -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`. diff --git a/docs/plans/catalogo-falas-solla-impl.md b/docs/plans/catalogo-falas-solla-impl.md index b9e9a013..f7ece502 100644 --- a/docs/plans/catalogo-falas-solla-impl.md +++ b/docs/plans/catalogo-falas-solla-impl.md @@ -1,7 +1,7 @@ # Impl: Catálogo de falas do Solla: import idempotente e busca textual -Status: em execução -Atualizado em: 2026-09-12 +Status: executado +Atualizado em: 2026-09-13 Issue: #955 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) @@ -150,11 +150,21 @@ flowchart LR ## 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. -- [ ] 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`). -- [ ] 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`. -- [ ] Prova viva registrada (relatório JSON da 57ª + re-run idempotente) no PR. +- [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. +- [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. +- [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`). +- [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`. +- [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 diff --git a/scripts/import-camara-speeches.mjs b/scripts/import-camara-speeches.mjs index d623d740..7738a319 100644 --- a/scripts/import-camara-speeches.mjs +++ b/scripts/import-camara-speeches.mjs @@ -45,6 +45,7 @@ import { parsePresidingOfficerTransitions, resolvePresidingOfficer, selectSpeechEvents, + speechContentHash, speechDate, speechSourceKey, } from './lib/camaraSpeeches.mjs' @@ -238,9 +239,21 @@ async function transcribeExcerpt(eventId, excerpt, options) { } 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 = { sourceKey, + suffixedKey, speechAt: speech.dataHoraInicio, eventId: null, excerpt: null, @@ -253,7 +266,6 @@ async function processSpeech(payload, speech, options, run) { failures: [], } - const existing = await findSpeechImportState(payload, sourceKey) const keywords = parseOfficialKeywords(speech.keywords) const date = speechDate(speech.dataHoraInicio) 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.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.segmentCount > 0) parts.push(`${report.segmentCount} segmentos`) if (report.presidingOfficer) parts.push(`presidiu ${report.presidingOfficer}`) diff --git a/scripts/lib/camaraSpeeches.mjs b/scripts/lib/camaraSpeeches.mjs index c9074785..911b3cdb 100644 --- a/scripts/lib/camaraSpeeches.mjs +++ b/scripts/lib/camaraSpeeches.mjs @@ -9,6 +9,7 @@ * decisions, so the fragile rules (HTML anchors, time-of-day matching, epoch * 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). */ 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 * 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 * @returns {string} @@ -90,6 +93,21 @@ export const speechSourceKey = (speech) => String(speech?.faseEvento?.titulo ?? '').trim(), ].join('|') +/** + * Short content hash (summary + official transcript) used to disambiguate a + * `sourceKey` collision: the first item keeps the base key, a distinct item + * gets `#` 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. * diff --git a/src/lib/speechGazetteer.ts b/src/lib/speechGazetteer.ts index 7031db42..12a6372b 100644 --- a/src/lib/speechGazetteer.ts +++ b/src/lib/speechGazetteer.ts @@ -364,6 +364,28 @@ const maskFalsePositivePhrases = (value: string): string => { 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 * 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) { const start = match.index + match[1].length 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 for (let index = start; index < start + matcher.normalized.length; index += 1) { masked[index] = ' ' @@ -415,16 +443,22 @@ export type SpeechFacetInput = { /** * Offline facet pass: topic lexicon + scope lexicon + municipality gazetteer. * 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 => { - const text = [input.summary ?? '', input.keywords?.join(' ') ?? '', input.transcript].join(' ') - const normalized = normalizeForSearch(text) + const keywordText = input.keywords?.join(' ') ?? '' + const normalized = normalizeForSearch( + [input.summary ?? '', keywordText, input.transcript].join(' '), + ) const topics = SPEECH_TOPICS.filter(({ value }) => SPEECH_TOPIC_TERMS[value].some((term) => containsTerm(normalized, term)), ).map(({ value }) => value) - const municipalities = matchMunicipalityMentions(text) + const municipalities = matchMunicipalityMentions( + [input.summary ?? '', input.transcript].join(' '), + ) const matchedScopes = new Set( SPEECH_SCOPES.filter(({ value }) => SPEECH_SCOPE_TERMS[value].some((term) => containsTerm(normalized, term)), diff --git a/src/utilities/speech/speechImport.ts b/src/utilities/speech/speechImport.ts index 95b74096..a8287c8e 100644 --- a/src/utilities/speech/speechImport.ts +++ b/src/utilities/speech/speechImport.ts @@ -57,6 +57,8 @@ export type SpeechImportState = { durationSeconds: number | null vodPlaybackUrl: string | null vodDownloadUrl: string | null + summary: string | null + officialTranscript: string | null } export type SpeechUpsertCounts = { @@ -83,6 +85,8 @@ export const findSpeechImportState = async ( durationSeconds: true, vodPlaybackUrl: true, vodDownloadUrl: true, + summary: true, + officialTranscript: true, }, // Intentional bypass: the import CLI is a trusted actor with no session. overrideAccess: true, @@ -106,6 +110,8 @@ export const findSpeechImportState = async ( durationSeconds: speech.durationSeconds ?? null, vodPlaybackUrl: speech.vodPlaybackUrl ?? null, vodDownloadUrl: speech.vodDownloadUrl ?? null, + summary: speech.summary ?? null, + officialTranscript: speech.officialTranscript ?? null, } } diff --git a/tests/unit/camaraSpeeches.unit.spec.ts b/tests/unit/camaraSpeeches.unit.spec.ts index 747c045a..d1e2e75a 100644 --- a/tests/unit/camaraSpeeches.unit.spec.ts +++ b/tests/unit/camaraSpeeches.unit.spec.ts @@ -16,6 +16,7 @@ import { parseVodStatus, resolvePresidingOfficer, selectSpeechEvents, + speechContentHash, speechDate, speechSourceKey, 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', () => { it('splits the raw newline string and keeps each keyword', () => { expect(parseOfficialKeywords('Saúde, SUS\r\nEducação\r\n\r\nEscola')).toEqual([ diff --git a/tests/unit/campaignNav.unit.spec.ts b/tests/unit/campaignNav.unit.spec.ts index 1672c64a..d2609d2a 100644 --- a/tests/unit/campaignNav.unit.spec.ts +++ b/tests/unit/campaignNav.unit.spec.ts @@ -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', () => { it('offers exactly five primary items to staff, zero to leaders', () => { for (const role of ['coordinator', 'advisor', 'candidate'] as const) { diff --git a/tests/unit/speechFacets.unit.spec.ts b/tests/unit/speechFacets.unit.spec.ts index 9f7a5ec7..6d969478 100644 --- a/tests/unit/speechFacets.unit.spec.ts +++ b/tests/unit/speechFacets.unit.spec.ts @@ -37,6 +37,23 @@ describe('classifySpeechByGazetteer', () => { 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', () => { @@ -59,12 +76,24 @@ describe('matchMunicipalityMentions', () => { 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 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, ), ).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', () => { expect(matchMunicipalityMentions('O Sistema Único de Saúde atende a população.')).toEqual([]) expect(matchMunicipalityMentions('O Banco Central elevou os juros.')).toEqual([])