feat(C163): pauta das lideranças, acervo por região/tema e fonte inline no relatório de cidade

This commit is contained in:
Francisco Solla 2026-09-15 22:09:54 -03:00
parent 28c3b3a0fe
commit 44e4502978
6 changed files with 538 additions and 127 deletions

View file

@ -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/<base>.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/<base>.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 (`<a href>`): 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 <cidade>"; 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 (`<a href>`):
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á

View file

@ -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 \<cidade\>", "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).

View file

@ -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),

View file

@ -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',

View file

@ -20,6 +20,31 @@ const URL_PATTERN = /(https?:\/\/[^\s<>"')]+)/g
const htmlWithLinks = (value) =>
htmlEscape(value).replace(URL_PATTERN, (url) => `<a href="${url}">${url}</a>`)
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 ` <a class="inline-source" href="${htmlEscape(url)}">${label}</a>`
})
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) => {
`<tr>${block.columns
.map(
(column) =>
`<td${column.numeric ? ' class="num"' : ''}>${htmlWithLinks(row[column.key])}</td>`,
`<td${column.numeric ? ' class="num"' : ''}>${renderInlineSourcesHtml(row[column.key], row.sources)}</td>`,
)
.join('')}</tr>`,
)
@ -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}_`)

View file

@ -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,