mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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.
439 lines
15 KiB
JavaScript
439 lines
15 KiB
JavaScript
/**
|
||
* 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
|