diff --git a/.agents/skills/relatorio-cidade/SKILL.md b/.agents/skills/relatorio-cidade/SKILL.md index e9d253e5..959e2f7b 100644 --- a/.agents/skills/relatorio-cidade/SKILL.md +++ b/.agents/skills/relatorio-cidade/SKILL.md @@ -107,6 +107,15 @@ URL por item) e emendas lidas da fonte oficial **em tempo de geração**. Págin "sourceUrl": "https://…", "sourceDate": "2024-08-14" } ], + "leaderAgenda": [ // pauta provável por liderança (rede + mesmo campo) + { + "name": "…", + "field": "Aliado (prefeito) | Aliado (federação PT/PCdoB) | Rede da campanha (base) | …", + "topics": "Saúde (hospital municipal)", // pauta provável (hipótese) + "hook": "Gancho recente publicado (o que a fonte diz)", // topics OU hook obrigatório + "sourceUrl": "https://…", "sourceDate": "2026-08-31" + } + ], "demography": [ // cor/raça e poder aquisitivo (Censo 2022) { "topic": "Cor/raça (Censo 2022)", "detail": "Parda 73,1% …", @@ -135,9 +144,24 @@ URL por item) e emendas lidas da fonte oficial **em tempo de geração**. Págin Checklist (`id`s): `prefeito`, `vice`, `relacao_campo`, `vereadores`, `disputa_local`, `quem_investe`, `noticias`, `imprensa_local`, `emendas_web`. Item sem `sourceUrl`/`sourceDate` é convertido em lacuna pelo validador; item -ausente também. `approach`, `preCandidates` e `leaders` são listas de pesquisa: -cada entrada sem fonte vira lacuna (`abordagem_sem_fonte`, -`precandidato_sem_fonte`, `lideranca_sem_fonte`) e não entra no PDF. +ausente também. `approach`, `preCandidates`, `leaders` e `leaderAgenda` são +listas de pesquisa: cada entrada sem fonte vira lacuna (`abordagem_sem_fonte`, +`precandidato_sem_fonte`, `lideranca_sem_fonte`, `agenda_lideranca_sem_fonte`) e +não entra no PDF. Campos de texto livre (`detail`, `hook`, `suggestion`, +`support`, `answer`) aceitam os **tokens de fonte inline** `{{fonte}}` / +`{{fonte:N}}`, resolvidos para `(fonte)` no ponto exato da citação (ver "Links +clicáveis / fonte inline" adiante). + +**`leaderAgenda` — pauta provável das lideranças (rede + mesmo campo):** para +cada liderança que importa na visita, registre o que ela tende a priorizar +(Saúde, Educação, Infraestrutura…) e o **gancho recente publicado** que ancora a +leitura, sempre com URL+data. `name` é obrigatório; exige `topics` **ou** `hook`; +sem fonte, não entra. Priorize (a) a **rede da campanha** (as lideranças na base +Teqo, com `supportStatus`) e (b) o **mesmo campo** de Solla — é onde há margem +de conversa. É **hipótese a validar na conversa, não declaração do líder**: o +bloco "Pauta das lideranças (pesquisa)" (seção 3) sai com essa ressalva. Liderança +de rede **sem rastro público** não vira item: fica como tarefa de ativação +(cadastrar/atualizar `supportStatus`), não como pauta inventada. **`emendas_web` — indícios de emenda (município, região ou polo):** quando a fonte oficial não atribui emenda ao município, pesquise artigos, falas e @@ -205,7 +229,9 @@ cacheado em `data/relatorios-cidade/.emendas.json` para replay. e os prováveis candidatos do campo do prefeito (pesquisa), além da **frente de oposição** quando pesquisada (fatos com fonte) · `3. Rede e lideranças` — inclui as lideranças locais pesquisadas (ex-prefeitos/vices, - vereadores mais votados, com partido) · `4. Conjuntura` · `5. Sinais` · `6. + vereadores mais votados, com partido) e a **Pauta das lideranças (pesquisa)**: + pauta provável + gancho recente por liderança da rede/mesmo campo, marcada + como hipótese · `4. Conjuntura` · `5. Sinais` · `6. Demandas e visitas` · `7. Demografia` — IBGE Censo 2022 do artefato + **complemento pesquisado** (cor/raça e poder aquisitivo) · `8. Atividade econômica (pesquisa)` · `9. Transporte e conexões (pesquisa)` · `10. Acervo @@ -220,8 +246,30 @@ cacheado em `data/relatorios-cidade/.emendas.json` para replay. 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 - tabela, fontes por linha e a seção de fontes. +- **Acervo por região e tema:** como cidade pequena quase nunca tem fala + própria, a seção 10 mostra três recortes **não sobrepostos**: falas do + município (quando existem), **menções à região** (demais municípios do mesmo + Território de Identidade) e **falas por tema regional** (temas-chave do + interior). Região/polo **nunca** é somado como fala da cidade — o bloco diz + isso explicitamente. Fonte: `speeches.region` e `speeches.topics` do snapshot + (extraídos read-only no homeserver; ausentes nos snapshots antigos → seção + degrada para a busca por município sem quebrar). **Cada recorte diz por que a + fala entrou:** a tabela do município mostra "Menção a "; a da região + mostra "Município da região citado"; a de tema mostra a coluna **"Tema"** — + porque essas falas foram selecionadas por tema, **não** por menção ao + município/região (não rotular como "marcada com o município" o que só foi + escolhido por tema). +- **Links clicáveis / fonte inline:** todo URL no PDF é um link (``): + células de tabela, fontes por linha e a seção de fontes. Nos textos de pesquisa + (oposição, dobradinhas, investimentos, pauta das lideranças, abordagem, + demografia…), o `(fonte)` com hyperlink entra **inline no próprio texto, logo + após o trecho que cita o fato verificado** — não numa coluna. Um mesmo texto + pode citar mais de uma fonte: use `{{fonte}}` para a fonte principal + (`sourceUrl`) e `{{fonte:2}}`, `{{fonte:3}}`… para cada entrada de + `extraSources`; o renderer resolve os tokens em `(fonte)` / `(fonte N)` e o + companion `.md` vira `[(fonte)](url)`. Se o texto não tiver token, o builder + acrescenta a(s) fonte(s) no fim do trecho. A coluna **Fonte** fica só com a + data e o link completo continua listado abaixo da tabela (`> Fontes:`). - **Numeração dinâmica e omissão de seções vazias:** o builder numera as seções depois de montá-las (o `id` é estável; o número é posicional). As seções **Sinais recentes** e **Demandas e visitas** são **omitidas quando não há diff --git a/docs/changelog/2026-09-15-c163-liderancas-acervo-fonte-inline.md b/docs/changelog/2026-09-15-c163-liderancas-acervo-fonte-inline.md new file mode 100644 index 00000000..884b63ba --- /dev/null +++ b/docs/changelog/2026-09-15-c163-liderancas-acervo-fonte-inline.md @@ -0,0 +1 @@ +**C163 — relatório de cidade: pauta das lideranças, acervo por região/tema e fonte inline (2026-09-15):** o relatório de cidade pré-viagem ganhou duas frentes de pesquisa e uma convenção de fonte inline. (1) **`leaderAgenda`** — novo campo opcional do `research.json` e bloco **"Pauta das lideranças (pesquisa)"** na seção 3 (`scripts/lib/cityReportResearch.mjs` valida `name` + `topics`/`hook` + URL/data; sem fonte vira `agenda_lideranca_sem_fonte`; `scripts/lib/cityReportBlocks.mjs` renderiza `Líder · Campo · Pauta provável · Gancho recente · Fonte`), com a ressalva de que a pauta é **hipótese ancorada em fonte a validar na conversa**, priorizando rede da campanha e mesmo campo; liderança de rede sem rastro público é lacuna de ativação, não pauta inventada. (2) **Acervo por região e tema** — o extrator (`scripts/cityReportSnapshot.mjs`) passou a devolver três recortes não sobrepostos de `speeches`: município, **região** (demais municípios do mesmo Território de Identidade, `speeches.region`) e **temas regionais** (`speeches.topics`, temas-chave do interior via enum `SPEECH_TOPICS`); a seção 10 mostra as três tabelas com o rótulo honesto de cada recorte — "Menção a \", "Município da região citado" e **"Tema"** (falas selecionadas por tema **não** são marcadas com o município e não podem ser rotuladas como tal); região/polo nunca é somado como fala da cidade e snapshots antigos degradam para a busca por município sem quebrar. (3) **Fonte inline** — os textos de pesquisa aceitam os tokens `{{fonte}}` (fonte principal) e `{{fonte:2}}`/`{{fonte:3}}`… (`extraSources`), resolvidos pelo renderer em `(fonte)` / `(fonte N)` **no ponto exato da citação**, no PDF e no companion `.md` (`[(fonte)](url)`); a coluna Fonte fica só com a data e a lista completa de URLs segue abaixo da tabela (`> Fontes:`). `extraSources` agora é preservado também em `opposition`/`alliances`/`investments`/`approach`/`leaderAgenda`. Skill `.agents/skills/relatorio-cidade/SKILL.md` atualizada (contrato do `leaderAgenda`, recortes do acervo, tokens inline). Unit C163 verdes (37/37); sem migration/UI/access/Consent/e2e (artefatos PDF/MD e `research.json`/`snapshot.json` seguem gitignored). diff --git a/scripts/cityReportSnapshot.mjs b/scripts/cityReportSnapshot.mjs index 52c82691..d2e45503 100644 --- a/scripts/cityReportSnapshot.mjs +++ b/scripts/cityReportSnapshot.mjs @@ -11,7 +11,7 @@ */ import { FEDERAL_DEPUTY_OFFICE, HISTORICAL_SERIES_YEARS } from '../src/lib/electionResults.ts' -import { getMunicipalityCatalogEntry } from '../src/lib/municipalityCatalog.ts' +import { getMunicipalityCatalogEntry, municipalityCatalog } 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' @@ -32,6 +32,22 @@ import { loadSegmentsForSpeeches } from '../src/utilities/speech/speechPageData. const SPEECH_LIMIT = 10 const DEMAND_LIMIT = 20 const COMPETITOR_LIMIT = 5 +/** + * Region-relevant speech themes: the acervo search for a small município is + * nearly always empty, so the report also shows what the deputy said about the + * REGION and about the themes that matter there. These are the enum values from + * `SPEECH_TOPICS` (src/lib/speechFacets.ts). + */ +const SPEECH_REGION_TOPICS = [ + 'saude', + 'educacao', + 'agricultura', + 'infraestrutura', + 'meio-ambiente', + 'cultura', + 'economia-trabalho', + 'habitacao-cidades', +] /** Data value of the office enum (only the federal constant is exported today). */ const STATE_DEPUTY_OFFICE = 'deputado_estadual' const REFERENCE_YEAR = 2022 @@ -44,65 +60,112 @@ const projectActivity = (activity) => ({ locality: activity.locality, }) +const SPEECH_SELECT = { + speechAt: true, + type: true, + phase: true, + summary: true, + officialTextUrl: true, + youtubeUrl: true, + vodPlaybackUrl: true, + excerptTMs: true, + eventStartAt: true, + mentionedMunicipalities: true, + topics: true, +} + /** - * Speeches mentioning the município, projected for the report: the official - * summary says what the speech is; `mentionExcerpt` is the passage that - * literally names the city (null when the acervo tag has no literal mention in - * the ASR segments — e.g. a gazetteer tagging among dozens of municípios); - * `mentionedMunicipalityCount` gives that context. + * Projects one speech for the report: the official summary says what the + * speech is; `mentionExcerpt` is the passage that literally names the matched + * município (null when the acervo tag has no literal mention in the ASR + * segments); `matchedMunicipality` is which name matched; the count gives the + * "tagged among dozens" context; `topics` carries the facet themes. */ -const loadSpeeches = async (payload, actor, municipalityID, municipalityName) => { - const result = await payload.find({ - collection: 'speech', - where: buildSpeechListWhere({ page: 1, municipalities: [municipalityID] }), - depth: 0, - limit: SPEECH_LIMIT, - sort: '-speechAt', - select: { - speechAt: true, - type: true, - phase: true, - summary: true, - officialTextUrl: true, - youtubeUrl: true, - vodPlaybackUrl: true, - excerptTMs: true, - eventStartAt: true, - mentionedMunicipalities: true, - }, - user: actor, - overrideAccess: false, - }) +const projectSpeechRow = (doc, segments, matchNames) => { + const candidates = matchNames + .map((name) => ({ name, key: normalizeForSearch(name) })) + .filter((candidate) => candidate.key) + const matched = candidates.find(({ key }) => + segments.some((segment) => normalizeForSearch(segment.text).includes(key)), + ) + const mentionSegment = matched + ? segments.find((segment) => normalizeForSearch(segment.text).includes(matched.key)) + : undefined + return { + id: doc.id, + speechAt: doc.speechAt ?? null, + type: doc.type ?? null, + phase: doc.phase ?? null, + summary: doc.summary ?? null, + officialTextUrl: doc.officialTextUrl ?? null, + youtubeUrl: doc.youtubeUrl ?? null, + vodPlaybackUrl: doc.vodPlaybackUrl ?? null, + /** Offset of the excerpt inside the session video (YouTube start). */ + youtubeExcerptStartSeconds: excerptOffsetSeconds(doc.excerptTMs, doc.eventStartAt), + mentionExcerpt: mentionSegment?.text ?? null, + matchedMunicipality: matched?.name ?? null, + mentionedMunicipalityCount: Array.isArray(doc.mentionedMunicipalities) + ? doc.mentionedMunicipalities.length + : 0, + topics: Array.isArray(doc.topics) ? doc.topics : [], + } +} + +/** + * Speeches for the report, in three non-overlapping groups: the município, the + * rest of its Território de Identidade (mantido separado — região não é a + * cidade) and the region-relevant themes. A speech already shown in a more + * specific group is never repeated in the next one. + */ +const loadSpeeches = async (payload, actor, { municipalityID, municipalityName, region }) => { + const find = (state) => + payload.find({ + collection: 'speech', + where: buildSpeechListWhere({ page: 1, ...state }), + depth: 0, + limit: SPEECH_LIMIT, + sort: '-speechAt', + select: SPEECH_SELECT, + user: actor, + overrideAccess: false, + }) + + const cityResult = await find({ municipalities: [municipalityID] }) + const cityIDs = new Set(cityResult.docs.map((doc) => doc.id)) + + const regionIDs = region?.ids ?? [] + const regionResult = regionIDs.length + ? await find({ municipalities: regionIDs }) + : { docs: [], totalDocs: 0 } + const regionDocs = regionResult.docs.filter((doc) => !cityIDs.has(doc.id)) + + const seen = new Set([...cityIDs, ...regionDocs.map((doc) => doc.id)]) + const topicResult = await find({ topics: SPEECH_REGION_TOPICS }) + const topicDocs = topicResult.docs.filter((doc) => !seen.has(doc.id)) + + const allDocs = [...cityResult.docs, ...regionDocs, ...topicDocs] const segmentsBySpeech = await loadSegmentsForSpeeches( payload, actor, - result.docs.map((doc) => doc.id), + allDocs.map((doc) => doc.id), ) - const normalizedCity = municipalityName ? normalizeForSearch(municipalityName) : '' + const segmentsOf = (doc) => segmentsBySpeech.get(doc.id) ?? [] + const cityNames = municipalityName ? [municipalityName] : [] + const regionNames = region?.names ?? [] + return { - totalCount: result.totalDocs, - rows: result.docs.map((doc) => { - const segments = segmentsBySpeech.get(doc.id) ?? [] - const mentionSegment = normalizedCity - ? segments.find((segment) => normalizeForSearch(segment.text).includes(normalizedCity)) - : undefined - return { - id: doc.id, - speechAt: doc.speechAt ?? null, - type: doc.type ?? null, - phase: doc.phase ?? null, - summary: doc.summary ?? null, - officialTextUrl: doc.officialTextUrl ?? null, - youtubeUrl: doc.youtubeUrl ?? null, - vodPlaybackUrl: doc.vodPlaybackUrl ?? null, - /** Offset of the excerpt inside the session video (YouTube start). */ - youtubeExcerptStartSeconds: excerptOffsetSeconds(doc.excerptTMs, doc.eventStartAt), - mentionExcerpt: mentionSegment?.text ?? null, - mentionedMunicipalityCount: Array.isArray(doc.mentionedMunicipalities) - ? doc.mentionedMunicipalities.length - : 0, - } - }), + totalCount: cityResult.totalDocs, + rows: cityResult.docs.map((doc) => projectSpeechRow(doc, segmentsOf(doc), cityNames)), + region: { + label: region?.label ?? null, + totalCount: regionDocs.length, + rows: regionDocs.map((doc) => projectSpeechRow(doc, segmentsOf(doc), regionNames)), + }, + topics: { + themes: SPEECH_REGION_TOPICS, + totalCount: topicDocs.length, + rows: topicDocs.map((doc) => projectSpeechRow(doc, segmentsOf(doc), cityNames)), + }, } } @@ -204,9 +267,37 @@ export const composeCityReportSnapshot = async ({ const context = await resolveAccessibleMunicipalityContext(payload, actor, slug) const detail = await getMunicipalityDetailViewModel(payload, context, actor) const dossier = await loadMunicipalityDossierData(payload, actor, detail) + + const catalogEntry = getMunicipalityCatalogEntry(slug) + const regionSlugs = catalogEntry + ? municipalityCatalog.filter((row) => row.region === catalogEntry.region).map((row) => row.slug) + : [] + const regionResult = regionSlugs.length + ? await payload.find({ + collection: 'municipality', + where: { slug: { in: regionSlugs } }, + depth: 0, + limit: 0, + pagination: false, + select: { slug: true, name: true }, + user: actor, + overrideAccess: false, + }) + : { docs: [] } + const regionDocs = regionResult.docs.filter((doc) => doc.slug !== slug) + const region = { + label: detail.region ?? catalogEntry?.region ?? null, + ids: regionDocs.map((doc) => doc.id).filter((id) => id !== context.id), + names: regionDocs.map((doc) => doc.name).filter(Boolean), + } + const [advisors, speeches, demands, federalCompetitors, stateCompetitors] = await Promise.all([ loadAdvisorSummaries(payload, actor, detail.advisorIDs), - loadSpeeches(payload, actor, context.id, detail.city), + loadSpeeches(payload, actor, { + municipalityID: context.id, + municipalityName: detail.city, + region, + }), loadDemands(payload, actor, context.id), loadCompetitors(payload, actor, slug, FEDERAL_DEPUTY_OFFICE), loadCompetitors(payload, actor, slug, STATE_DEPUTY_OFFICE), diff --git a/scripts/lib/cityReportBlocks.mjs b/scripts/lib/cityReportBlocks.mjs index c44873c6..0c79aef1 100644 --- a/scripts/lib/cityReportBlocks.mjs +++ b/scripts/lib/cityReportBlocks.mjs @@ -18,6 +18,7 @@ import { campaignDemandStatusLabels, } from '../../src/lib/schemas/campaignDemand.ts' import { municipalityUpdatePolarityLabels } from '../../src/lib/schemas/municipalityUpdate.ts' +import { SPEECH_TOPICS } from '../../src/lib/speechFacets.ts' import { formatVoteEstimateEndpointsLabel, voteEstimateScenarioLabels, @@ -67,6 +68,32 @@ const sourceOfficial = (label, url, date) => ({ date: date ?? null, }) +/** + * Inline-source tokens: a free-text field (detail/hook/suggestion/value/support) + * can carry `{{fonte}}` for the item's primary URL and `{{fonte:2}}`, + * `{{fonte:3}}`… for its `extraSources`. The renderers turn them into a + * `(fonte)` link exactly where the text cites the verified fact, so one excerpt + * can point to more than one outlet. When the text has no token, the primary + * (and any extra) URL is appended at the end — "inline com o trecho", never a + * bare column. + */ +const INLINE_SOURCE_TOKEN = /\{\{fonte(?::\d+)?\}\}/ +const inlineSourceTokens = (count) => + Array.from({ length: count }, (_, index) => + index === 0 ? '{{fonte}}' : `{{fonte:${index + 1}}}`, + ).join(' ') + +const sourcesOf = (item) => + [item?.sourceUrl, ...((item?.extraSources ?? []).map((extra) => extra?.url) ?? [])].filter( + (url) => typeof url === 'string' && url.trim() !== '', + ) + +const withInlineSources = (text, sources) => { + if (!text || !sources.length) return text + if (INLINE_SOURCE_TOKEN.test(text)) return text + return `${text} ${inlineSourceTokens(sources.length)}` +} + /** * Página 1 = resumo de uma olhada: a fonte do item entra compacta (pesquisa web * + data). A URL completa de cada item é preservada em "Fontes e limites" @@ -410,11 +437,15 @@ const buildOppositionBlock = (research) => { { key: 'detail', label: 'O que a fonte diz', width: 62 }, { key: 'source', label: 'Fonte', width: 16 }, ], - rows: entries.map((item) => ({ - topic: item.topic, - detail: item.detail, - source: formatDateBr(item.sourceDate), - })), + rows: entries.map((item) => { + const sources = sourcesOf(item) + return { + topic: item.topic, + detail: withInlineSources(item.detail, sources), + source: formatDateBr(item.sourceDate), + sources, + } + }), note: 'Fato publicado com fonte datada — não é condenação. Atribua à fonte e não infira culpa; tema nacional pode ser de mão dupla.', sources: entries.map((item) => ({ kind: 'web', @@ -461,13 +492,17 @@ const buildCompetitorsSection = ({ snapshot, research }) => { { key: 'support', label: 'Apoio' }, { key: 'source', label: 'Fonte' }, ], - rows: preCandidates.map((item) => ({ - name: item.name, - office: item.office, - party: item.party ?? '—', - support: item.support ?? '—', - source: formatDateBr(item.sourceDate), - })), + rows: preCandidates.map((item) => { + const sources = sourcesOf(item) + return { + name: item.name, + office: item.office, + party: item.party ?? '—', + support: withInlineSources(item.support ?? '—', sources), + source: formatDateBr(item.sourceDate), + sources, + } + }), note: 'Nomes declarados em fontes datadas; o histórico de votos vem das tabelas acima quando o nome aparece na base.', sources: preCandidates.map((item) => ({ kind: 'web', @@ -585,6 +620,9 @@ const buildNetworkSection = ({ snapshot, research }) => { }) } + const leaderAgenda = buildLeaderAgendaBlock(research) + if (leaderAgenda) blocks.push(leaderAgenda) + blocks.push( ...buildSourcedListBlocks({ title: 'Dobradinhas (pesquisa)', @@ -596,6 +634,45 @@ const buildNetworkSection = ({ snapshot, research }) => { return blocks } +/** + * Pauta provável de cada liderança do mesmo campo / da rede: hipótese ancorada + * em fonte pública, NÃO um fato — a nota do bloco deixa isso explícito para + * ninguém tratar a leitura como compromisso do líder. + */ +const buildLeaderAgendaBlock = (research) => { + const entries = research?.leaderAgenda ?? [] + if (!entries.length) return null + return { + kind: 'table', + title: 'Pauta das lideranças (pesquisa)', + columns: [ + { key: 'name', label: 'Líder', width: 18 }, + { key: 'field', label: 'Campo', width: 13 }, + { key: 'topics', label: 'Pauta provável', width: 22 }, + { key: 'hook', label: 'Gancho recente', width: 33 }, + { key: 'source', label: 'Fonte', width: 14 }, + ], + rows: entries.map((item) => { + const sources = sourcesOf(item) + return { + name: item.name, + field: item.field ?? '—', + topics: item.topics ?? '—', + hook: withInlineSources(item.hook ?? '—', sources), + source: formatDateBr(item.sourceDate), + sources, + } + }), + note: '“Pauta provável” é hipótese ancorada em fonte pública — validar na conversa; não é declaração do líder. Prioriza rede da campanha e mesmo campo.', + sources: entries.map((item) => ({ + kind: 'web', + label: `${item.name} — pauta provável`, + url: item.sourceUrl, + date: item.sourceDate, + })), + } +} + const buildConjunctureSection = ({ snapshot, research }) => { const conjuncture = snapshot.conjuncture const trend = conjuncture?.politicalTrend @@ -812,11 +889,15 @@ const buildSourcedListBlocks = ({ title, note, items }) => { { key: 'detail', label: 'Dado', width: 62 }, { key: 'source', label: 'Fonte', width: 16 }, ], - rows: items.map((item) => ({ - topic: item.topic, - detail: item.detail, - source: formatDateBr(item.sourceDate), - })), + rows: items.map((item) => { + const sources = sourcesOf(item) + return { + topic: item.topic, + detail: withInlineSources(item.detail, sources), + source: formatDateBr(item.sourceDate), + sources, + } + }), note, sources: items.map((item) => ({ kind: 'web', @@ -853,11 +934,15 @@ const buildDemographicsSection = ({ snapshot, research }) => { demographics.medianAge === null ? '—' : `${formatInteger(demographics.medianAge)} anos`, }, ].map((row) => ({ ...row, source: 'base Teqo' })) - const researchRows = (research.demography ?? []).map((item) => ({ - indicator: item.topic, - value: item.detail, - source: formatDateBr(item.sourceDate), - })) + const researchRows = (research.demography ?? []).map((item) => { + const sources = sourcesOf(item) + return { + indicator: item.topic, + value: withInlineSources(item.detail, sources), + source: formatDateBr(item.sourceDate), + sources, + } + }) return [ { @@ -918,59 +1003,154 @@ const youtubeStartSeconds = (row) => ? Math.max(0, Math.floor(row.youtubeExcerptStartSeconds) - YOUTUBE_VIDEO_OFFSET_SECONDS) : null +const speechTopicLabel = (value) => + SPEECH_TOPICS.find((topic) => topic.value === value)?.label ?? value + +/** + * Why the row is in the table decides the "mention" text: a city row is tagged + * with the município in the acervo; a region row is tagged with another + * município of the same Território; a theme row is selected by topic and was + * NOT necessarily tagged with any of them — so it shows the theme, never a + * false "menção ao município". + */ +const speechMention = (row, variant) => { + if (row.mentionExcerpt) return row.mentionExcerpt.slice(0, SPEECH_MENTION_MAX) + const count = row.mentionedMunicipalityCount + if (variant === 'region') { + return count + ? `Marcada no acervo com município da região — o nome não aparece nos trechos (a fala cita ${formatInteger(count)} municípios).` + : 'Marcada no acervo com município da região — trecho não localizado.' + } + return count + ? `Marcada no acervo — nome não localizado nos trechos (a fala cita ${formatInteger(count)} municípios).` + : 'Marcada no acervo — trecho não localizado.' +} + +const projectSpeechTableRow = (row, variant) => { + const base = { + date: formatDateBr(row.speechAt), + phase: row.phase ?? '—', + description: row.summary ? row.summary.slice(0, SPEECH_SUMMARY_MAX) : '—', + video: row.youtubeUrl + ? withYoutubeTimestamp(row.youtubeUrl, youtubeStartSeconds(row)) + : (row.vodPlaybackUrl ?? '—'), + transcript: row.officialTextUrl ?? '—', + } + if (variant === 'theme') { + const labels = (row.topics ?? []).map(speechTopicLabel).join(' · ') + return { ...base, topics: labels || '—' } + } + return { ...base, mention: speechMention(row, variant) } +} + +const buildSpeechTable = ({ title, rows, variant = 'city', cityLabel, note, readAt }) => { + const middle = + variant === 'theme' + ? { key: 'topics', label: 'Tema', width: 27 } + : { + key: 'mention', + label: variant === 'region' ? 'Município da região citado' : `Menção a ${cityLabel}`, + width: 27, + } + return { + kind: 'table', + title, + columns: [ + { key: 'date', label: 'Data', width: 9 }, + { key: 'phase', label: 'Fase', width: 10 }, + { key: 'description', label: 'O que é', width: 32 }, + middle, + { key: 'video', label: 'Vídeo', width: 11 }, + { key: 'transcript', label: 'Transcrição', width: 11 }, + ], + rows: rows.map((row) => projectSpeechTableRow(row, variant)), + note, + sources: [sourceTeqo('base Teqo — acervo C153/C155', readAt)], + } +} + const buildSpeechesSection = ({ snapshot }) => { const { speeches, municipality } = snapshot - if (!speeches.rows.length) { + const cityLabel = municipality?.name ?? 'o município' + const cityRows = speeches.rows ?? [] + const region = speeches.region ?? { label: null, rows: [] } + const topics = speeches.topics ?? { themes: [], rows: [] } + const regionRows = region.rows ?? [] + const themeRows = topics.rows ?? [] + + if (!cityRows.length && !regionRows.length && !themeRows.length) { return [ { kind: 'callout', tone: 'gap', title: 'Acervo de falas', body: [ - `Busca por município no acervo: nenhum trecho encontrado (${formatInteger(speeches.totalCount)} no total).`, + `Busca por município no acervo: nenhum trecho encontrado (${formatInteger(speeches.totalCount)} no total) e nenhum recorte regional/temático.`, GAP_COPY, ], }, ] } - const cityLabel = municipality?.name ?? 'o município' - return [ - { - kind: 'table', + + const blocks = [] + if (cityRows.length) { + blocks.push( + buildSpeechTable({ + title: 'Acervo de falas', + rows: cityRows, + variant: 'city', + cityLabel, + note: [ + '“O que é” = sumário oficial da Câmara; “Menção” = passagem que cita o município ou, quando o nome não aparece nos trechos, a marcação do acervo (C153/C155) com o nº de municípios da fala; “Vídeo” e “Transcrição” = links oficiais da Câmara quando existem.', + speeches.totalCount > cityRows.length + ? `Mostrando ${cityRows.length} de ${speeches.totalCount} falas.` + : null, + ] + .filter(Boolean) + .join(' '), + readAt: snapshot.meta?.readAt, + }), + ) + } else { + blocks.push({ + kind: 'callout', + tone: 'info', title: 'Acervo de falas', - columns: [ - { key: 'date', label: 'Data', width: 9 }, - { key: 'phase', label: 'Fase', width: 10 }, - { key: 'description', label: 'O que é', width: 32 }, - { key: 'mention', label: `Menção a ${cityLabel}`, width: 27 }, - { key: 'video', label: 'Vídeo', width: 11 }, - { key: 'transcript', label: 'Transcrição', width: 11 }, + body: [ + `Nenhuma fala cita ${cityLabel} diretamente; abaixo vão o recorte da região e os temas do mandato (não somar região/polo como fala da cidade).`, ], - rows: speeches.rows.map((row) => ({ - date: formatDateBr(row.speechAt), - phase: row.phase ?? '—', - description: row.summary ? row.summary.slice(0, SPEECH_SUMMARY_MAX) : '—', - mention: row.mentionExcerpt - ? row.mentionExcerpt.slice(0, SPEECH_MENTION_MAX) - : row.mentionedMunicipalityCount - ? `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, youtubeStartSeconds(row)) - : (row.vodPlaybackUrl ?? '—'), - transcript: row.officialTextUrl ?? '—', - })), - note: [ - '“O que é” = sumário oficial da Câmara; “Menção” = passagem que cita o município ou, quando o nome não aparece nos trechos, a marcação do acervo (C153/C155) com o nº de municípios da fala; “Vídeo” e “Transcrição” = links oficiais da Câmara quando existem.', - speeches.totalCount > speeches.rows.length - ? `Mostrando ${speeches.rows.length} de ${speeches.totalCount} falas.` - : null, - ] - .filter(Boolean) - .join(' '), - sources: [sourceTeqo('base Teqo — acervo C153/C155', snapshot.meta?.readAt)], - }, - ] + }) + } + + if (regionRows.length) { + blocks.push( + buildSpeechTable({ + title: `Acervo — menções à região (${region.label ?? 'Território de Identidade'})`, + rows: regionRows, + variant: 'region', + note: `Falas marcadas no acervo com municípios do mesmo Território de Identidade (${region.label ?? 'região'}) — a região NÃO é a cidade; use como contexto, nunca some à conta do município.`, + readAt: snapshot.meta?.readAt, + }), + ) + } + + if (themeRows.length) { + blocks.push( + buildSpeechTable({ + title: 'Acervo — falas por tema regional', + rows: themeRows, + variant: 'theme', + note: `Falas do mandato **selecionadas por tema** (${(topics.themes ?? []) + .map(speechTopicLabel) + .join( + ', ', + )}) — o critério é o tema, não uma menção ao município ou à região; use como repertório do tema, sem somar à conta da cidade.`, + readAt: snapshot.meta?.readAt, + }), + ) + } + + return blocks } const buildApproachSection = ({ research }) => { @@ -994,11 +1174,15 @@ const buildApproachSection = ({ research }) => { { key: 'suggestion', label: 'Sugestão' }, { key: 'source', label: 'Fonte' }, ], - rows: approach.map((item) => ({ - topic: item.persona ? `${item.persona} · ${item.topic}` : item.topic, - suggestion: item.suggestion, - source: formatDateBr(item.sourceDate), - })), + rows: approach.map((item) => { + const sources = sourcesOf(item) + return { + topic: item.persona ? `${item.persona} · ${item.topic}` : item.topic, + suggestion: withInlineSources(item.suggestion, sources), + source: formatDateBr(item.sourceDate), + sources, + } + }), note: 'Sugestões das personas de ciência política e coordenação de campanha, ancoradas nas fontes listadas (URL por item); sem fonte, não entra.', sources: approach.map((item) => ({ kind: 'web', diff --git a/scripts/lib/cityReportRender.mjs b/scripts/lib/cityReportRender.mjs index 412befe3..5073400d 100644 --- a/scripts/lib/cityReportRender.mjs +++ b/scripts/lib/cityReportRender.mjs @@ -20,6 +20,31 @@ const URL_PATTERN = /(https?:\/\/[^\s<>"')]+)/g const htmlWithLinks = (value) => htmlEscape(value).replace(URL_PATTERN, (url) => `${url}`) +const INLINE_SOURCE_PATTERN = /\{\{fonte(?::(\d+))?\}\}/g + +/** + * Resolves the `{{fonte}}` / `{{fonte:N}}` tokens the content model leaves in + * the citation text into `(fonte)` anchors — inline, right where the excerpt + * cites the verified fact, so one text can point to more than one source. + */ +const renderInlineSourcesHtml = (value, sources) => + htmlWithLinks(value).replace(INLINE_SOURCE_PATTERN, (_match, index) => { + const position = Number(index ?? 1) - 1 + const url = sources?.[position] + if (!url) return '' + const label = sources.length > 1 ? `(fonte ${position + 1})` : '(fonte)' + return ` ${label}` + }) + +const renderInlineSourcesMd = (value, sources) => + String(value ?? '').replace(INLINE_SOURCE_PATTERN, (_match, index) => { + const position = Number(index ?? 1) - 1 + const url = sources?.[position] + if (!url) return '' + const label = sources.length > 1 ? `fonte ${position + 1}` : 'fonte' + return ` [(${label})](${url})` + }) + const sourceKindLabels = { teqo: 'base Teqo', web: 'pesquisa web', @@ -112,6 +137,7 @@ const PRINT_CSS = ` .stats .stat-hint { display: block; font-weight: 400; color: #71717a; font-size: 7.4pt; } .source { color: #71717a; font-size: 7.2pt; margin: 1.4mm 0 0; } .source a { color: #1d4ed8; word-break: break-all; } + .inline-source { color: #1d4ed8; white-space: nowrap; } a { color: #1d4ed8; text-decoration: underline; text-decoration-color: #bfdbfe; text-underline-offset: 1.5px; } .callout { border-left: 2.2mm solid #b45309; background: #fffbeb; padding: 2.4mm 3mm; border-radius: 0 1.6mm 1.6mm 0; } .callout.gap { border-left-color: #71717a; background: #f4f4f5; } @@ -235,7 +261,7 @@ const renderTable = (block) => { `${block.columns .map( (column) => - `${htmlWithLinks(row[column.key])}`, + `${renderInlineSourcesHtml(row[column.key], row.sources)}`, ) .join('')}`, ) @@ -434,7 +460,9 @@ const mdBlock = (block) => { lines.push(`| ${block.columns.map(() => '---').join(' | ')} |`) for (const row of block.rows) { lines.push( - `| ${block.columns.map((column) => String(row[column.key] ?? '')).join(' | ')} |`, + `| ${block.columns + .map((column) => renderInlineSourcesMd(row[column.key], row.sources)) + .join(' | ')} |`, ) } if (block.note) lines.push(`\n_${block.note}_`) diff --git a/scripts/lib/cityReportResearch.mjs b/scripts/lib/cityReportResearch.mjs index 3fb79af4..b541540b 100644 --- a/scripts/lib/cityReportResearch.mjs +++ b/scripts/lib/cityReportResearch.mjs @@ -38,6 +38,21 @@ const inNewsWindow = (publishedAt, reference) => Date.parse(publishedAt) >= reference.getTime() - RESEARCH_NEWS_WINDOW_DAYS * DAY_MS && Date.parse(publishedAt) <= reference.getTime() + DAY_MS +/** + * Validated `extraSources` of one research entry: additional URLs that support + * the same item (a text can cite facts from more than one outlet). Each needs + * url+date; invalid ones are dropped (the primary source already carries the + * item, so a broken extra is not worth a visible gap). + */ +const collectExtraSources = (entry) => + (Array.isArray(entry?.extraSources) ? entry.extraSources : []) + .map((extra) => ({ + label: isNonEmptyString(extra?.label) ? extra.label.trim() : null, + url: isNonEmptyString(extra?.url) ? extra.url.trim() : null, + date: isValidDate(extra?.date) ? extra.date : null, + })) + .filter((extra) => extra.url && extra.date) + /** * Validates/normalizes the agent-written research JSON. * @@ -197,6 +212,7 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => { suggestion, sourceUrl, sourceDate, + extraSources: collectExtraSources(entry), consultedAt: isValidDate(entry.consultedAt) ? entry.consultedAt : null, }) } @@ -267,6 +283,47 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => { }) } + const leaderAgenda = [] + for (const entry of Array.isArray(raw.leaderAgenda) ? raw.leaderAgenda : []) { + const name = isNonEmptyString(entry?.name) ? entry.name.trim() : null + const topics = isNonEmptyString(entry?.topics) ? entry.topics.trim() : null + const hook = isNonEmptyString(entry?.hook) ? entry.hook.trim() : null + const sourceUrl = isNonEmptyString(entry?.sourceUrl) ? entry.sourceUrl.trim() : null + const sourceDate = isValidDate(entry?.sourceDate) ? entry.sourceDate : null + if (!name) { + gaps.push({ + id: 'agenda_lideranca_incompleta', + reason: 'Pauta de liderança sem nome — descartada.', + }) + continue + } + if (!topics && !hook) { + gaps.push({ + id: 'agenda_lideranca_sem_pauta', + label: name, + reason: 'Liderança sem pauta provável nem gancho recente — descartada.', + }) + continue + } + if (!sourceUrl || !sourceDate) { + gaps.push({ + id: 'agenda_lideranca_sem_fonte', + label: name, + reason: 'Pauta de liderança sem fonte: URL e data são obrigatórias.', + }) + continue + } + leaderAgenda.push({ + name, + field: isNonEmptyString(entry.field) ? entry.field.trim() : null, + topics, + hook, + sourceUrl, + sourceDate, + extraSources: collectExtraSources(entry), + }) + } + const sourcedLists = { demography: [], economy: [], @@ -302,6 +359,7 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => { detail, sourceUrl, sourceDate, + extraSources: collectExtraSources(entry), consultedAt: isValidDate(entry.consultedAt) ? entry.consultedAt : null, }) } @@ -315,6 +373,7 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => { approach, preCandidates, leaders, + leaderAgenda, demography: sourcedLists.demography, economy: sourcedLists.economy, transport: sourcedLists.transport,