teqo-backup/scripts/lib/cityReportResearch.mjs
Francisco Solla 9fae859c0d feat(relatorio-cidade): leva o 2º turno (Lula × Flávio) ao relatório
Artefato oficial por unidade (município e ZE de Salvador): TSE 2026
presidente por seção + Solla 2026 e a fatia de 2022 da análise
2º turno; cada número validado contra o baseline TSE e os totais da BA
pinados (pnpm build:second-round).

O relatório ganha a seção "2º turno no município (Lula × Flávio)" com
X₁/X₂ pela mesma fórmula do /potencial, o papel determinístico da cidade
(prioridade/mobilizacao/defesa/expansao) e o novo item de pesquisa
segundo_turno (movimento local com fonte); a seção fica fora da página 1
porque é determinística e a página 1 falha fechada por medição.
2026-10-09 04:01:20 -03:00

439 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Research-contract validation for the city report (C163).
*
* The web layer of the report is written by the agent as a JSON file; this
* module is the guard: every checklist item either carries a date + URL or
* becomes an explicit gap. "Sem fonte, não publica" is enforced here, before
* the renderer ever sees the data.
*/
import { DAY_MS } from '../../src/lib/text.ts'
const RESEARCH_NEWS_WINDOW_DAYS = 90
const RESEARCH_CHECKLIST = [
{ id: 'prefeito', label: 'Prefeito(a) — nome, partido, federação, situação' },
{ id: 'vice', label: 'Vice-prefeito(a) — nome, partido' },
{ id: 'relacao_campo', label: 'Relação com o campo (aliado, oposição, neutro)' },
{ id: 'vereadores', label: 'Vereadores e dobradas' },
{ id: 'disputa_local', label: 'Disputa local' },
{
id: 'segundo_turno',
label: '2º turno (Lula × Flávio) — atos, palanques e posições locais',
},
{ id: 'quem_investe', label: 'Quem mais investe na cidade (bancada e adversários)' },
{ id: 'noticias', label: 'Notícias da cidade/região (janela ≤90 dias)' },
{ id: 'imprensa_local', label: 'Imprensa e rádio local' },
{
id: 'emendas_web',
label: 'Emendas — indícios web (município, região ou polo)',
},
]
export const RESEARCH_CHECKLIST_IDS = RESEARCH_CHECKLIST.map((item) => item.id)
const checklistById = new Map(RESEARCH_CHECKLIST.map((item) => [item.id, item]))
/** Where an emenda indication points to: the município itself, its region or the regional pole. */
const EMENDA_SPHERES = ['municipio', 'regiao', 'polo']
export const isNonEmptyString = (value) => typeof value === 'string' && value.trim() !== ''
export const isValidDate = (value) => isNonEmptyString(value) && !Number.isNaN(Date.parse(value))
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).
*/
export 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.
*
* Structural failures (no slug, no research date) throw — the operator has to
* fix the file. Content failures (missing source, stale news, unknown item)
* degrade to explicit gaps, which the report prints as such.
*/
export const normalizeResearchInput = (raw, { now = new Date() } = {}) => {
if (!raw || typeof raw !== 'object') {
throw new Error('Pesquisa inválida: o arquivo precisa ser um objeto JSON.')
}
if (!isNonEmptyString(raw.municipalitySlug)) {
throw new Error('Pesquisa inválida: falta "municipalitySlug".')
}
if (!isValidDate(raw.researchedAt)) {
throw new Error(
'Pesquisa inválida: falta "researchedAt" (ISO) — a pesquisa precisa ser datada.',
)
}
const researchedAt = new Date(raw.researchedAt)
const reference = now instanceof Date ? now : new Date(now)
const gaps = []
const items = []
const seen = new Set()
for (const entry of Array.isArray(raw.items) ? raw.items : []) {
const id = typeof entry?.id === 'string' ? entry.id : null
if (!id || !checklistById.has(id)) {
gaps.push({
id: id ?? 'desconhecido',
label: id && checklistById.get(id)?.label,
reason: 'Item fora do checklist de pesquisa.',
})
continue
}
seen.add(id)
const answer = isNonEmptyString(entry.answer) ? entry.answer.trim() : null
const sourceUrl = isNonEmptyString(entry.sourceUrl) ? entry.sourceUrl.trim() : null
const sourceDate = isValidDate(entry.sourceDate) ? entry.sourceDate : null
if (!answer) {
gaps.push({ id, label: checklistById.get(id).label, reason: 'Não pesquisado.' })
continue
}
if (!sourceUrl || !sourceDate) {
gaps.push({
id,
label: checklistById.get(id).label,
reason: 'Sem fonte: cada item exige URL e data.',
})
continue
}
const extraSources = []
for (const extra of Array.isArray(entry.extraSources) ? entry.extraSources : []) {
const extraUrl = isNonEmptyString(extra?.url) ? extra.url.trim() : null
const extraDate = isValidDate(extra?.date) ? extra.date : null
if (!extraUrl || !extraDate) {
gaps.push({
id: 'fonte_extra',
label: checklistById.get(id).label,
reason: 'Fonte adicional sem URL ou data — descartada.',
})
continue
}
extraSources.push({
label: isNonEmptyString(extra.label) ? extra.label.trim() : null,
url: extraUrl,
date: extraDate,
})
}
items.push({
id,
label: checklistById.get(id).label,
answer,
// Optional short, complete, self-contained rewrite of `answer` (C188): the
// summary surfaces print it as-is; the deep dive keeps `answer` integral.
// It is not a new fact — it inherits the item's source.
summary: isNonEmptyString(entry.summary) ? entry.summary.trim() : null,
details: isNonEmptyString(entry.details) ? entry.details.trim() : null,
sourceUrl,
sourceDate,
extraSources,
consultedAt: isValidDate(entry.consultedAt) ? entry.consultedAt : null,
})
}
for (const entry of Array.isArray(raw.gaps) ? raw.gaps : []) {
if (isNonEmptyString(entry?.reason)) {
const id = isNonEmptyString(entry.id) ? entry.id : 'lacuna'
// A gap declared by the agent already accounts for the checklist item —
// otherwise the item would show up twice ("Não pesquisado." + the real gap).
if (checklistById.has(id)) seen.add(id)
gaps.push({
id,
label: isNonEmptyString(entry.label) ? entry.label : null,
reason: entry.reason.trim(),
})
}
}
for (const checklistItem of RESEARCH_CHECKLIST) {
if (!seen.has(checklistItem.id)) {
gaps.push({ id: checklistItem.id, label: checklistItem.label, reason: 'Não pesquisado.' })
}
}
const news = []
for (const entry of Array.isArray(raw.news) ? raw.news : []) {
const title = isNonEmptyString(entry?.title) ? entry.title.trim() : null
const url = isNonEmptyString(entry?.url) ? entry.url.trim() : null
const publishedAt = isValidDate(entry?.publishedAt) ? entry.publishedAt : null
if (!title || !url || !publishedAt) {
gaps.push({
id: 'noticia_sem_fonte',
reason: 'Notícia sem título, URL ou data — descartada.',
})
continue
}
if (!inNewsWindow(publishedAt, reference)) {
gaps.push({
id: 'noticia_fora_da_janela',
label: title,
reason: `Fora da janela de ${RESEARCH_NEWS_WINDOW_DAYS} dias — descartada.`,
})
continue
}
news.push({
title,
outlet: isNonEmptyString(entry.outlet) ? entry.outlet.trim() : null,
publishedAt,
url,
summary: isNonEmptyString(entry.summary) ? entry.summary.trim() : null,
})
}
const approach = []
for (const entry of Array.isArray(raw.approach) ? raw.approach : []) {
const topic = isNonEmptyString(entry?.topic) ? entry.topic.trim() : null
const suggestion = isNonEmptyString(entry?.suggestion) ? entry.suggestion.trim() : null
const sourceUrl = isNonEmptyString(entry?.sourceUrl) ? entry.sourceUrl.trim() : null
const sourceDate = isValidDate(entry?.sourceDate) ? entry.sourceDate : null
if (!topic || !suggestion) {
gaps.push({
id: 'abordagem_incompleta',
label: topic,
reason: 'Sugestão sem tema ou texto — descartada.',
})
continue
}
if (!sourceUrl || !sourceDate) {
gaps.push({
id: 'abordagem_sem_fonte',
label: topic,
reason: 'Sugestão sem fonte: URL e data são obrigatórias.',
})
continue
}
approach.push({
persona: isNonEmptyString(entry.persona) ? entry.persona.trim() : null,
topic,
suggestion,
sourceUrl,
sourceDate,
extraSources: collectExtraSources(entry),
consultedAt: isValidDate(entry.consultedAt) ? entry.consultedAt : null,
})
}
const preCandidates = []
for (const entry of Array.isArray(raw.preCandidates) ? raw.preCandidates : []) {
const name = isNonEmptyString(entry?.name) ? entry.name.trim() : null
const office = isNonEmptyString(entry?.office) ? entry.office.trim() : null
const sourceUrl = isNonEmptyString(entry?.sourceUrl) ? entry.sourceUrl.trim() : null
const sourceDate = isValidDate(entry?.sourceDate) ? entry.sourceDate : null
if (!name || !office) {
gaps.push({
id: 'precandidato_incompleto',
label: name,
reason: 'Pré-candidato sem nome ou cargo — descartado.',
})
continue
}
if (!sourceUrl || !sourceDate) {
gaps.push({
id: 'precandidato_sem_fonte',
label: name,
reason: 'Pré-candidato sem fonte: URL e data são obrigatórias.',
})
continue
}
preCandidates.push({
name,
office,
party: isNonEmptyString(entry.party) ? entry.party.trim() : null,
support: isNonEmptyString(entry.support) ? entry.support.trim() : null,
note: isNonEmptyString(entry.note) ? entry.note.trim() : null,
sourceUrl,
sourceDate,
})
}
const leaders = []
for (const entry of Array.isArray(raw.leaders) ? raw.leaders : []) {
const name = isNonEmptyString(entry?.name) ? entry.name.trim() : null
const role = isNonEmptyString(entry?.role) ? entry.role.trim() : null
const sourceUrl = isNonEmptyString(entry?.sourceUrl) ? entry.sourceUrl.trim() : null
const sourceDate = isValidDate(entry?.sourceDate) ? entry.sourceDate : null
if (!name || !role) {
gaps.push({
id: 'lideranca_incompleta',
label: name,
reason: 'Liderança sem nome ou papel — descartada.',
})
continue
}
if (!sourceUrl || !sourceDate) {
gaps.push({
id: 'lideranca_sem_fonte',
label: name,
reason: 'Liderança sem fonte: URL e data são obrigatórias.',
})
continue
}
leaders.push({
name,
role,
party: isNonEmptyString(entry.party) ? entry.party.trim() : null,
period: isNonEmptyString(entry.period) ? entry.period.trim() : null,
note: isNonEmptyString(entry.note) ? entry.note.trim() : null,
sourceUrl,
sourceDate,
})
}
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: [],
transport: [],
opposition: [],
alliances: [],
investments: [],
}
for (const key of Object.keys(sourcedLists)) {
for (const entry of Array.isArray(raw[key]) ? raw[key] : []) {
const topic = isNonEmptyString(entry?.topic) ? entry.topic.trim() : null
const detail = isNonEmptyString(entry?.detail) ? entry.detail.trim() : null
const sourceUrl = isNonEmptyString(entry?.sourceUrl) ? entry.sourceUrl.trim() : null
const sourceDate = isValidDate(entry?.sourceDate) ? entry.sourceDate : null
if (!topic || !detail) {
gaps.push({
id: `${key}_incompleto`,
label: topic,
reason: 'Item sem tema ou dado — descartado.',
})
continue
}
if (!sourceUrl || !sourceDate) {
gaps.push({
id: `${key}_sem_fonte`,
label: topic,
reason: 'Item sem fonte: URL e data são obrigatórias.',
})
continue
}
sourcedLists[key].push({
topic,
detail,
sourceUrl,
sourceDate,
extraSources: collectExtraSources(entry),
consultedAt: isValidDate(entry.consultedAt) ? entry.consultedAt : null,
})
}
}
/**
* Structured emenda indications (município/região/polo). The page-1 block is
* "quem indica o quê, para onde e com que fonte" — the free-text `emendas_web`
* synthesis alone cannot show the value/esfera per author. Every row needs a
* source; without it the row is dropped into an explicit gap.
*/
const emendasIndicators = []
for (const entry of Array.isArray(raw.emendasIndicators) ? raw.emendasIndicators : []) {
const author = isNonEmptyString(entry?.author) ? entry.author.trim() : null
const sphere = isNonEmptyString(entry?.sphere) ? entry.sphere.trim() : null
const sourceUrl = isNonEmptyString(entry?.sourceUrl) ? entry.sourceUrl.trim() : null
const sourceDate = isValidDate(entry?.sourceDate) ? entry.sourceDate : null
if (!author || !sphere || !EMENDA_SPHERES.includes(sphere)) {
gaps.push({
id: 'emenda_indicio_incompleto',
label: author,
reason: `Indício de emenda sem autor ou esfera (${EMENDA_SPHERES.join('/')}) — descartado.`,
})
continue
}
if (!sourceUrl || !sourceDate) {
gaps.push({
id: 'emenda_indicio_sem_fonte',
label: author,
reason: 'Indício de emenda sem fonte: URL e data são obrigatórias.',
})
continue
}
emendasIndicators.push({
author,
sphere,
value: isNonEmptyString(entry.value) ? entry.value.trim() : null,
purpose: isNonEmptyString(entry.purpose) ? entry.purpose.trim() : null,
year: isNonEmptyString(entry.year) ? entry.year.trim() : null,
sourceUrl,
sourceDate,
})
}
return {
municipalitySlug: raw.municipalitySlug.trim(),
researchedAt: researchedAt.toISOString(),
items,
news,
approach,
preCandidates,
leaders,
leaderAgenda,
emendasIndicators,
demography: sourcedLists.demography,
economy: sourcedLists.economy,
transport: sourcedLists.transport,
opposition: sourcedLists.opposition,
alliances: sourcedLists.alliances,
investments: sourcedLists.investments,
gaps,
}
}
export const researchItemById = (research, id) =>
research.items.find((item) => item.id === id) ?? null