teqo-backup/scripts/lib/cityReportResearch.mjs
Francisco Solla 321ac7b872
Some checks failed
Deploy / preflight (push) Has been cancelled
Deploy / verify (push) Has been cancelled
Deploy / deploy staging (teqo-staging) (push) Has been cancelled
Deploy / requeue when main advanced (push) Has been cancelled
Deploy / deploy production (teqo-1313) (push) Has been cancelled
feat(relatorio-cidade): foca o relatório na mobilização do 2º turno
A federal foi decidida em 04/10; o documento de cidade passa a servir
inteiro à eleição de Lula. Saem conta eleitoral/cadeira de Solla,
concorrentes e pré-candidatos, emendas e entregas (sem fetch do Portal
da Transparência no builder), acervo de falas, panorama regional e
sinais/demandas; demografia, economia e transporte ficam como contexto
da visita.

A página 1 vira só mobilização: KPIs oficiais do artefato TSE 2026
(Lula/Δ, X₁/X₂, Solla), papel determinístico da cidade, quem mobiliza,
movimento local do 2º turno, riscos e o painel "O que pedir e falar"
(pedido Lula 13; guardrail de votos em jogo no lugar do defeso).

O checklist cai de 10 para 8 itens (emendas_web e quem_investe saem;
relacao_campo/disputa_local reancorados no 2º turno); listas
preCandidates/investments/emendasIndicators saem do contrato (arquivos
antigos seguem aceitos em replay). O snapshot enxuga para rede +
conjuntura + demografia (cityReportTerritory.mjs removido).

Prova: unit 5.232 verdes, int do snapshot sem PII e sem os grupos
aposentados, lint/format/typecheck/knip limpos e rebuild da fixture de
Cruz das Almas (9 seções, 16 páginas contra 21, página 1 fechando sem
fallback).
2026-10-10 16:57:20 -03:00

358 lines
12 KiB
JavaScript
Raw Permalink 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 no 2º turno (Lula × Flávio) — palanque, apoio ou oposição',
},
{ id: 'vereadores', label: 'Vereadores e dobradas' },
{ id: 'disputa_local', label: 'Disputa local — força do campo adversário' },
{
id: 'segundo_turno',
label: '2º turno (Lula × Flávio) — atos, palanques e posições locais',
},
{ id: 'noticias', label: 'Notícias da cidade/região (janela ≤90 dias)' },
{ id: 'imprensa_local', label: 'Imprensa e rádio local' },
]
export const RESEARCH_CHECKLIST_IDS = RESEARCH_CHECKLIST.map((item) => item.id)
const checklistById = new Map(RESEARCH_CHECKLIST.map((item) => [item.id, item]))
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 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: [],
}
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,
})
}
}
return {
municipalitySlug: raw.municipalitySlug.trim(),
researchedAt: researchedAt.toISOString(),
items,
news,
approach,
leaders,
leaderAgenda,
demography: sourcedLists.demography,
economy: sourcedLists.economy,
transport: sourcedLists.transport,
opposition: sourcedLists.opposition,
alliances: sourcedLists.alliances,
gaps,
}
}
export const researchItemById = (research, id) =>
research.items.find((item) => item.id === id) ?? null