diff --git a/.agents/skills/relatorio-cidade/SKILL.md b/.agents/skills/relatorio-cidade/SKILL.md index f88da118..08c05791 100644 --- a/.agents/skills/relatorio-cidade/SKILL.md +++ b/.agents/skills/relatorio-cidade/SKILL.md @@ -178,10 +178,11 @@ cacheado em `data/relatorios-cidade/.emendas.json` para replay. econômica (pesquisa)` · `9. Transporte e conexões (pesquisa)` · `10. Acervo de falas` — cada fala com "O que é" (sumário oficial) e "Menção ao município" (passagem que cita a cidade ou, se o nome não aparece nos trechos, a marcação - do acervo com o nº de municípios), mais **Vídeo** (YouTube já no **início da - fala** — `youtubeStartSeconds` = `speechAt` − início da sessão, via - `speechStartOffsetSeconds`; fallback para o VOD da Câmara, que é o clipe do - próprio trecho) e **Transcrição** (PDF do Diário) quando existirem · + do acervo com o nº de municípios), mais **Vídeo** (YouTube no trecho da fala + — `youtubeExcerptStartSeconds` = época do trecho (`excerptTMs`) − início da + sessão, com folga de 20s no link; o `speechAt` da API é o horário do slot, + não o início real; fallback para o VOD da Câmara, que é o clipe do próprio + trecho) e **Transcrição** (PDF do Diário) quando existirem · `11. Notícias e imprensa` · `12. Panorama regional` · `13. Abordagem sugerida (personas)` · `14. Fontes e limites`. diff --git a/docs/changelog/2026-09-15-c163-falas-ancora-trecho.md b/docs/changelog/2026-09-15-c163-falas-ancora-trecho.md new file mode 100644 index 00000000..118f5c99 --- /dev/null +++ b/docs/changelog/2026-09-15-c163-falas-ancora-trecho.md @@ -0,0 +1 @@ +**C163 — vídeo ancorado no trecho da Câmara, com folga (2026-09-15):** o `speechAt` da API (14:08) é o horário do _slot_, não o início real da fala — os segmentos do trecho mostram o orador anterior nesse momento, e o 1º segmento do clipe é o presidente concedendo a palavra (o trecho gerado a partir do `excerptTMs` começa às 14:10:45). O relatório volta a ancorar no trecho oficial (`youtubeExcerptStartSeconds` = `excerptTMs` − início da sessão) e aplica **folga de 20s** no link (`YOUTUBE_LEAD_IN_SECONDS`) para não abrir já dentro da fala; na fala 641: `t=625s`. A função `speechStartOffsetSeconds` (hipótese errada de que `speechAt` fosse o início) foi removida do dono `src/lib/speechVod.ts`. Unit 3118/3118, `tsc`/eslint verdes; snapshot reextraído e PDF regerado. diff --git a/scripts/cityReportSnapshot.mjs b/scripts/cityReportSnapshot.mjs index 6a28b200..52c82691 100644 --- a/scripts/cityReportSnapshot.mjs +++ b/scripts/cityReportSnapshot.mjs @@ -14,7 +14,7 @@ import { FEDERAL_DEPUTY_OFFICE, HISTORICAL_SERIES_YEARS } from '../src/lib/elect import { getMunicipalityCatalogEntry } from '../src/lib/municipalityCatalog.ts' import { getMunicipalityVoteRank } from '../src/lib/municipalityVoteRank.ts' import { normalizeForSearch } from '../src/lib/speechSearch.ts' -import { speechStartOffsetSeconds } from '../src/lib/speechVod.ts' +import { excerptOffsetSeconds } from '../src/lib/speechVod.ts' import { campaignRoleLabels } from '../src/utilities/campaignUserProfile.ts' import { loadMunicipalityDossierData } from '../src/utilities/municipality/municipalityDossierData.ts' import { @@ -66,6 +66,7 @@ const loadSpeeches = async (payload, actor, municipalityID, municipalityName) => officialTextUrl: true, youtubeUrl: true, vodPlaybackUrl: true, + excerptTMs: true, eventStartAt: true, mentionedMunicipalities: true, }, @@ -95,7 +96,7 @@ const loadSpeeches = async (payload, actor, municipalityID, municipalityName) => youtubeUrl: doc.youtubeUrl ?? null, vodPlaybackUrl: doc.vodPlaybackUrl ?? null, /** Offset of the excerpt inside the session video (YouTube start). */ - youtubeStartSeconds: speechStartOffsetSeconds(doc.speechAt, doc.eventStartAt), + youtubeExcerptStartSeconds: excerptOffsetSeconds(doc.excerptTMs, doc.eventStartAt), mentionExcerpt: mentionSegment?.text ?? null, mentionedMunicipalityCount: Array.isArray(doc.mentionedMunicipalities) ? doc.mentionedMunicipalities.length diff --git a/scripts/lib/cityReportBlocks.mjs b/scripts/lib/cityReportBlocks.mjs index e1a716b1..9cd88a82 100644 --- a/scripts/lib/cityReportBlocks.mjs +++ b/scripts/lib/cityReportBlocks.mjs @@ -853,10 +853,14 @@ const buildTransportSection = ({ research }) => }) /** - * YouTube link starts where the excerpt sits in the session video - * (`youtubeOffsetSeconds`, from the Câmara excerpt epoch − session start). - * The VOD clip is the excerpt itself, so it needs no timestamp. + * YouTube link starts at the Câmara excerpt of the speech (`excerptTMs` − + * session start): `speechAt` is the API slot clock, not when the deputy + * actually speaks (for speech 641 the previous speaker is still on at + * `speechAt`). A short lead-in keeps the link from starting already inside the + * speech when the session video drifts a few seconds. */ +const YOUTUBE_LEAD_IN_SECONDS = 20 + const withYoutubeTimestamp = (url, offsetSeconds) => { if (!url) return null if (!Number.isFinite(offsetSeconds) || offsetSeconds <= 0) return url @@ -864,6 +868,11 @@ const withYoutubeTimestamp = (url, offsetSeconds) => { return `${url}${separator}t=${Math.floor(offsetSeconds)}s` } +const youtubeStartSeconds = (row) => + Number.isFinite(row.youtubeExcerptStartSeconds) + ? Math.max(0, Math.floor(row.youtubeExcerptStartSeconds) - YOUTUBE_LEAD_IN_SECONDS) + : null + const buildSpeechesSection = ({ snapshot }) => { const { speeches, municipality } = snapshot if (!speeches.rows.length) { @@ -902,7 +911,7 @@ const buildSpeechesSection = ({ snapshot }) => { ? `Marcada no acervo — nome não localizado nos trechos (a fala cita ${formatInteger(row.mentionedMunicipalityCount)} municípios).` : 'Marcada no acervo — trecho não localizado.', video: row.youtubeUrl - ? withYoutubeTimestamp(row.youtubeUrl, row.youtubeStartSeconds) + ? withYoutubeTimestamp(row.youtubeUrl, youtubeStartSeconds(row)) : (row.vodPlaybackUrl ?? '—'), transcript: row.officialTextUrl ?? '—', })), diff --git a/src/lib/speechVod.ts b/src/lib/speechVod.ts index d9779459..c621b490 100644 --- a/src/lib/speechVod.ts +++ b/src/lib/speechVod.ts @@ -226,22 +226,3 @@ export const excerptOffsetSeconds = (excerptTMs: unknown, eventStartAt: unknown) const offset = Math.floor(wallClockSeconds(epochMs) - startSeconds) return offset >= 0 ? offset : null } - -/** - * Seconds from the session start (`eventStartAt`) to the speech itself - * (`speechAt`, the Câmara wall clock of the speech) — where a video link must - * start to open at the beginning of the deputy's speech, not at a curated - * excerpt inside it. Null when either side is unparseable or the speech - * precedes the session start. - */ -export const speechStartOffsetSeconds = ( - speechAt: unknown, - eventStartAt: unknown, -): number | null => { - const speechSeconds = naiveWallClockSeconds(speechAt) - const startSeconds = naiveWallClockSeconds(eventStartAt) - if (speechSeconds === null || startSeconds === null) return null - - const offset = Math.floor(speechSeconds - startSeconds) - return offset >= 0 ? offset : null -} diff --git a/tests/unit/cityReportBlocks.unit.spec.ts b/tests/unit/cityReportBlocks.unit.spec.ts index 25fb512b..333921c5 100644 --- a/tests/unit/cityReportBlocks.unit.spec.ts +++ b/tests/unit/cityReportBlocks.unit.spec.ts @@ -278,7 +278,7 @@ const snapshot = { officialTextUrl: 'https://camara.test/discurso', youtubeUrl: 'https://youtube.test/video', vodPlaybackUrl: 'https://vod.test/video.mp4', - youtubeStartSeconds: 125, + youtubeExcerptStartSeconds: 645, mentionExcerpt: 'Trecho da fala que cita Feira de Santana', mentionedMunicipalityCount: 3, }, @@ -445,7 +445,7 @@ describe('buildCityReport', () => { expect(falas.columns!.find((column) => column.key === 'mention')!.width).toBe(27) expect(rows[0].description).toBe('Resumo da fala') expect(rows[0].mention).toContain('cita Feira de Santana') - expect(rows[0].video).toBe('https://youtube.test/video?t=125s') + expect(rows[0].video).toBe('https://youtube.test/video?t=625s') expect(rows[0].transcript).toBe('https://camara.test/discurso') expect(rows[1].description).toBe('Saúde indígena na Bahia') expect(rows[1].mention).toContain('nome não localizado nos trechos') diff --git a/tests/unit/speechVod.unit.spec.ts b/tests/unit/speechVod.unit.spec.ts index 4611d932..b73c56d8 100644 --- a/tests/unit/speechVod.unit.spec.ts +++ b/tests/unit/speechVod.unit.spec.ts @@ -2,11 +2,7 @@ import { describe, expect, it } from 'vitest' -import { - excerptOffsetSeconds, - parseYoutubeVideoId, - speechStartOffsetSeconds, -} from '@/lib/speechVod' +import { excerptOffsetSeconds, parseYoutubeVideoId } from '@/lib/speechVod' /** * The VOD URL/status/duration contracts moved from `scripts/lib/camaraSpeeches.mjs` @@ -54,25 +50,6 @@ describe('excerptOffsetSeconds', () => { }) }) -describe('speechStartOffsetSeconds', () => { - it('measures the speech start against the session start', () => { - expect(speechStartOffsetSeconds('2018-03-13T14:08', '2018-03-13T14:00')).toBe(480) - expect(speechStartOffsetSeconds('2026-08-11T15:12:30', '2026-08-11T15:00')).toBe(750) - }) - - it('reads both sides as BRT wall clock across an old DST transition', () => { - // 2018-11-04: clocks move to -02:00 — the naive difference stays local. - expect(speechStartOffsetSeconds('2018-11-04T12:00', '2018-11-04T11:00')).toBe(3600) - }) - - it('returns null when the speech precedes the session or input is unparseable', () => { - expect(speechStartOffsetSeconds('2018-03-13T13:59', '2018-03-13T14:00')).toBeNull() - expect(speechStartOffsetSeconds(null, '2018-03-13T14:00')).toBeNull() - expect(speechStartOffsetSeconds('2018-03-13T14:08', null)).toBeNull() - expect(speechStartOffsetSeconds('2018-03-13T25:00', '2018-03-13T14:00')).toBeNull() - }) -}) - describe('parseYoutubeVideoId', () => { const videoId = 'lLhRDkSPw0A'