diff --git a/.agents/skills/relatorio-cidade/SKILL.md b/.agents/skills/relatorio-cidade/SKILL.md index 664b577d..f88da118 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á na minutagem - do trecho — `youtubeOffsetSeconds` = época do trecho − início da sessão; - 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` · + 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 · + `11. Notícias e imprensa` · `12. Panorama regional` · `13. Abordagem sugerida (personas)` · `14. Fontes e limites`. - **Links clicáveis:** todo URL no PDF é um link (``): células de diff --git a/docs/changelog/2026-09-15-c163-falas-inicio-da-fala.md b/docs/changelog/2026-09-15-c163-falas-inicio-da-fala.md new file mode 100644 index 00000000..0ba0e401 --- /dev/null +++ b/docs/changelog/2026-09-15-c163-falas-inicio-da-fala.md @@ -0,0 +1 @@ +**C163 — vídeo do acervo no início da fala, não no trecho curado (2026-09-15):** o link do YouTube abria 2m45s dentro do discurso porque usava `excerptTMs` (época de um trecho curado, 14:10:45) em vez do início da fala (14:08). O dono `src/lib/speechVod.ts` ganhou `speechStartOffsetSeconds(speechAt, eventStartAt)` (mesma matemática de relógio de parede BRT do `excerptOffsetSeconds`, com testes de DST e virada de dia) e o relatório passou a usar `youtubeStartSeconds` = `speechAt` − início da sessão: na fala 641, **480s (8min)** em vez de 645s → `youtube.com/watch?v=2cX_gKkJH7Q&t=480s`. Unit 3121/3121, `tsc`/eslint verdes; snapshot reextraído e PDF regerado. diff --git a/scripts/cityReportSnapshot.mjs b/scripts/cityReportSnapshot.mjs index 4dbfdc04..6a28b200 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 { excerptOffsetSeconds } from '../src/lib/speechVod.ts' +import { speechStartOffsetSeconds } from '../src/lib/speechVod.ts' import { campaignRoleLabels } from '../src/utilities/campaignUserProfile.ts' import { loadMunicipalityDossierData } from '../src/utilities/municipality/municipalityDossierData.ts' import { @@ -66,7 +66,6 @@ const loadSpeeches = async (payload, actor, municipalityID, municipalityName) => officialTextUrl: true, youtubeUrl: true, vodPlaybackUrl: true, - excerptTMs: true, eventStartAt: true, mentionedMunicipalities: true, }, @@ -96,7 +95,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). */ - youtubeOffsetSeconds: excerptOffsetSeconds(doc.excerptTMs, doc.eventStartAt), + youtubeStartSeconds: speechStartOffsetSeconds(doc.speechAt, 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 84e97f76..e1a716b1 100644 --- a/scripts/lib/cityReportBlocks.mjs +++ b/scripts/lib/cityReportBlocks.mjs @@ -902,7 +902,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.youtubeOffsetSeconds) + ? withYoutubeTimestamp(row.youtubeUrl, row.youtubeStartSeconds) : (row.vodPlaybackUrl ?? '—'), transcript: row.officialTextUrl ?? '—', })), diff --git a/src/lib/speechVod.ts b/src/lib/speechVod.ts index a6b50967..d9779459 100644 --- a/src/lib/speechVod.ts +++ b/src/lib/speechVod.ts @@ -193,6 +193,23 @@ const wallClockSeconds = (epochMs: number): number => { ) } +/** BRT wall-clock seconds-since-epoch of a naive Câmara datetime, or null. */ +const naiveWallClockSeconds = (value: unknown): number | null => { + const match = NAIVE_DATETIME.exec(String(value ?? '').trim()) + if (!match) return null + const [, year, month, day, hour, minute, second = '0'] = match + const [y, mo, d, h, mi, s] = [year, month, day, hour, minute, second].map(Number) + if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 59) { + return null + } + const ms = Date.UTC(y, mo - 1, d, h, mi, s) + const date = new Date(ms) + if (date.getUTCFullYear() !== y || date.getUTCMonth() !== mo - 1 || date.getUTCDate() !== d) { + return null + } + return ms / 1000 +} + /** * Seconds from the session start (`eventStartAt`, naive Câmara wall clock) to * the excerpt (`excerptTMs`, epoch ms). Both sides are read as BRT wall clock, @@ -203,23 +220,28 @@ export const excerptOffsetSeconds = (excerptTMs: unknown, eventStartAt: unknown) const epochMs = Number(excerptTMs) if (!Number.isFinite(epochMs) || epochMs <= 0 || epochMs > MAX_DATE_MS) return null - const match = NAIVE_DATETIME.exec(String(eventStartAt ?? '').trim()) - if (!match) return null - const [, year, month, day, hour, minute, second = '0'] = match - const [y, mo, d, h, mi, s] = [year, month, day, hour, minute, second].map(Number) - if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 59) { - return null - } - const startMs = Date.UTC(y, mo - 1, d, h, mi, s) - const startDate = new Date(startMs) - if ( - startDate.getUTCFullYear() !== y || - startDate.getUTCMonth() !== mo - 1 || - startDate.getUTCDate() !== d - ) { - return null - } + const startSeconds = naiveWallClockSeconds(eventStartAt) + if (startSeconds === null) return null - const offset = Math.floor(wallClockSeconds(epochMs) - startMs / 1000) + 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 ecc9634c..25fb512b 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', - youtubeOffsetSeconds: 125, + youtubeStartSeconds: 125, mentionExcerpt: 'Trecho da fala que cita Feira de Santana', mentionedMunicipalityCount: 3, }, diff --git a/tests/unit/speechVod.unit.spec.ts b/tests/unit/speechVod.unit.spec.ts index b73c56d8..4611d932 100644 --- a/tests/unit/speechVod.unit.spec.ts +++ b/tests/unit/speechVod.unit.spec.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest' -import { excerptOffsetSeconds, parseYoutubeVideoId } from '@/lib/speechVod' +import { + excerptOffsetSeconds, + parseYoutubeVideoId, + speechStartOffsetSeconds, +} from '@/lib/speechVod' /** * The VOD URL/status/duration contracts moved from `scripts/lib/camaraSpeeches.mjs` @@ -50,6 +54,25 @@ 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'