mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +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
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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 `<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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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<SpeechScope>(
|
||||
SPEECH_SCOPES.filter(({ value }) =>
|
||||
SPEECH_SCOPE_TERMS[value].some((term) => containsTerm(normalized, term)),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
|
|
|
|||
Loading…
Reference in a new issue