mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 08:53:11 +00:00
feat(C163): pauta das lideranças, acervo por região/tema e fonte inline no relatório de cidade
This commit is contained in:
parent
28c3b3a0fe
commit
44e4502978
6 changed files with 538 additions and 127 deletions
|
|
@ -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á
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}_`)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue