teqo-backup/scripts/lib/dossieResearch.mjs
Francisco Solla f658d5eaaa fix(C186): dossiê/boletim sem cortes — copy reformulada (brief) + paginação (Related #1132)
O builder cortava o texto no meio com "…" quando não cabia no A4, perdendo
informação. Agora:

- contrato de pesquisa ganha `brief` (`title` ≤80 / `note` ≤120) reescrito pelo
  researcher: o PDF imprime o brief reformulado e o registro/.md mantém
  `answer`/`details` integrais (sem fonte nova, sem trocar a fase);
- sem `brief` o renderer cai no texto completo e a guarda de fit A4 falha
  fechado — nunca mais corta em silêncio;
- tabelas longas (lacunas, notícias) e os cards de "Atuação registrada" das eras
  são paginados em folhas de continuação: nada é truncado nem descartado;
- corrige honrarias da Era C vazando para a página da Era B;
- remove o `clipCopy`/`MAX_NEWS_ROWS`; SKILL.md e agent doc documentam o `brief`.
2026-09-17 18:19:56 -03:00

359 lines
12 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 for the C186 dossiê (owner). The dossiê is researched one
* file per era (`<slug>.<era>.research.json`); each claim either carries a date
* + URL or becomes an explicit gap. Reuses the C163 validation primitives
* (exported additively from `cityReportResearch.mjs`) instead of twinning them.
*/
import { collectExtraSources, isNonEmptyString, isValidDate } from './cityReportResearch.mjs'
import { DOSSIER_ERA_IDS } from './dossieCareer.mjs'
/** Explicit spheres: a regional/pole item is never summed into the município. */
const DOSSIER_SPHERES = ['municipio', 'regiao', 'polo']
/** Execution phases (empenho ≠ pagamento). Unknown/absent degrades to "não informada". */
const DOSSIER_PHASES = ['autorizado', 'empenhado', 'liquidado', 'pago', 'restos', 'nao_informado']
/**
* Copy budget for a printed `brief` (reformulated headline + note that must fit
* the fixed A4 surfaces with no ellipsis). The full `answer`/`details` stay in
* the record and the companion `.md`; the `brief` is the reformulated, shorter
* version the PDF prints. Items without a `brief` fall back to the full text and
* the builder's A4 fit guard fails closed if it overflows.
*/
export const DOSSIER_BRIEF_TITLE_MAX = 80
export const DOSSIER_BRIEF_NOTE_MAX = 120
/** Checklist per era — the dossiê's research checklist. `area` labels the boletim card. */
const DOSSIER_RESEARCH_CHECKLIST = [
{
id: 'era_a_formacao',
era: 'A',
area: 'Formação',
label: 'Formação e residência (UFBA/INAMPS)',
},
{
id: 'era_a_sesab',
era: 'A',
area: 'Saúde pública',
label: 'SESAB — epidemiologia e sanitarismo (até 1998)',
},
{
id: 'era_a_consultor_ms',
era: 'A',
area: 'Ministério da Saúde',
label: 'Consultoria no Ministério da Saúde (1995–1999)',
},
{
id: 'era_a_conquista',
era: 'A',
area: 'Saúde municipal',
label: 'Secretaria Municipal de Saúde de Vitória da Conquista (1999–2002)',
},
{
id: 'era_a_sas_ms',
era: 'A',
area: 'Ministério da Saúde',
label: 'Secretaria de Atenção à Saúde do Ministério da Saúde (2003–2005)',
},
{ id: 'era_b_sesab', era: 'B', area: 'Saúde estadual', label: 'Gestão da SESAB (2007–2014)' },
{
id: 'era_b_equipamentos',
era: 'B',
area: 'Equipamentos',
label: 'Hospitais, UPAs e equipamentos na região',
},
{
id: 'era_b_programas',
era: 'B',
area: 'Programas',
label: 'Programas e políticas estaduais de saúde',
},
{
id: 'era_b_obras',
era: 'B',
area: 'Obras',
label: 'Obras e investimentos em saúde no município/região',
},
{
id: 'era_c_discursos',
era: 'C',
area: 'Mandato',
label: 'Pronunciamentos e falas com menção ao município',
},
{
id: 'era_c_proposicoes',
era: 'C',
area: 'Mandato',
label: 'Proposições, relatorias e ações legislativas',
},
{
id: 'era_c_emendas',
era: 'C',
area: 'Emendas',
label: 'Emendas e recursos para o município/região',
},
{
id: 'era_c_titulos',
era: 'C',
area: 'Reconhecimento',
label: 'Títulos, honrarias e vínculos locais',
},
{
id: 'era_c_atuacao',
era: 'C',
area: 'Atuação regional',
label: 'Atuação e articulação regional',
},
]
export const DOSSIER_RESEARCH_CHECKLIST_IDS = DOSSIER_RESEARCH_CHECKLIST.map((item) => item.id)
const checklistById = new Map(DOSSIER_RESEARCH_CHECKLIST.map((item) => [item.id, item]))
export const dossierChecklistForEra = (era) =>
DOSSIER_RESEARCH_CHECKLIST.filter((item) => item.era === era)
const normalizeSphere = (value) => {
if (!isNonEmptyString(value)) return 'municipio'
const sphere = value.trim()
return DOSSIER_SPHERES.includes(sphere) ? sphere : null
}
const normalizePhase = (value) => {
if (!isNonEmptyString(value)) return 'nao_informado'
const phase = value.trim()
return DOSSIER_PHASES.includes(phase) ? phase : 'nao_informado'
}
const normalizeBrief = (entry) => {
const title = isNonEmptyString(entry?.title) ? entry.title.trim() : null
if (!title) return null
return { title, note: isNonEmptyString(entry?.note) ? entry.note.trim() : null }
}
/** Non-fatal: an over-budget brief is kept, but the A4 fit guard will likely fail — surface it early. */
const warnIfBriefOverBudget = (id, brief) => {
if (!brief) return
if (brief.title.length > DOSSIER_BRIEF_TITLE_MAX) {
console.warn(
`[dossie-research] brief.title de ${id} tem ${brief.title.length} chars (teto ${DOSSIER_BRIEF_TITLE_MAX}).`,
)
}
if ((brief.note?.length ?? 0) > DOSSIER_BRIEF_NOTE_MAX) {
console.warn(
`[dossie-research] brief.note de ${id} tem ${brief.note.length} chars (teto ${DOSSIER_BRIEF_NOTE_MAX}).`,
)
}
}
const normalizeNumbers = (entry) => {
const numbers = []
for (const number of Array.isArray(entry?.numbers) ? entry.numbers : []) {
const label = isNonEmptyString(number?.label) ? number.label.trim() : null
const value =
number?.value === null || number?.value === undefined
? null
: String(number.value).trim() || null
if (!label || !value) continue
numbers.push({
label,
value,
year: isNonEmptyString(number?.year) ? number.year.trim() : null,
phase: normalizePhase(number?.phase),
})
}
return numbers
}
/**
* Validates/normalizes one era's agent-written research JSON.
*
* Structural failures (no slug, no research date, no/unknown era) throw — the
* operator fixes the file. Content failures (missing source, unknown item, item
* from another era) degrade to explicit gaps.
*/
export const normalizeDossierResearchInput = (raw) => {
if (!raw || typeof raw !== 'object') {
throw new Error('Pesquisa do dossiê inválida: o arquivo precisa ser um objeto JSON.')
}
if (!isNonEmptyString(raw.municipalitySlug)) {
throw new Error('Pesquisa do dossiê inválida: falta "municipalitySlug".')
}
if (!isValidDate(raw.researchedAt)) {
throw new Error(
'Pesquisa do dossiê inválida: falta "researchedAt" (ISO) — a pesquisa precisa ser datada.',
)
}
const era = isNonEmptyString(raw.era) ? raw.era.trim().toUpperCase() : null
if (!era || !DOSSIER_ERA_IDS.includes(era)) {
throw new Error('Pesquisa do dossiê inválida: falta "era" (A, B ou C).')
}
const researchedAt = new Date(raw.researchedAt)
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
const checklistItem = id ? checklistById.get(id) : null
if (!checklistItem) {
gaps.push({
id: id ?? 'desconhecido',
label: null,
reason: 'Item fora do checklist de pesquisa do dossiê.',
})
continue
}
if (checklistItem.era !== era) {
gaps.push({
id,
label: checklistItem.label,
reason: `Item da Era ${checklistItem.era} enviado no arquivo da Era ${era}.`,
})
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: checklistItem.label, reason: 'Não pesquisado.' })
continue
}
if (!sourceUrl || !sourceDate) {
gaps.push({
id,
label: checklistItem.label,
reason: 'Sem fonte: cada item exige URL e data.',
})
continue
}
const sphere = normalizeSphere(entry.sphere)
if (!sphere) {
gaps.push({
id,
label: checklistItem.label,
reason: `Esfera inválida (esperado ${DOSSIER_SPHERES.join('/')}).`,
})
continue
}
const brief = normalizeBrief(entry.brief)
warnIfBriefOverBudget(id, brief)
items.push({
id,
era,
label: checklistItem.label,
area: checklistItem.area,
answer,
details: isNonEmptyString(entry.details) ? entry.details.trim() : null,
brief,
sphere,
numbers: normalizeNumbers(entry),
sourceUrl,
sourceDate,
extraSources: collectExtraSources(entry),
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'
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 dossierChecklistForEra(era)) {
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
}
news.push({
era,
title,
outlet: isNonEmptyString(entry.outlet) ? entry.outlet.trim() : null,
publishedAt,
url,
summary: isNonEmptyString(entry.summary) ? entry.summary.trim() : null,
})
}
return {
municipalitySlug: raw.municipalitySlug.trim(),
era,
researchedAt: researchedAt.toISOString(),
items,
news,
gaps,
}
}
/** Short receipt the researcher subagent returns (never the body). */
export const dossierResearchReceipt = (research) => ({
slug: research.municipalitySlug,
era: research.era,
status: 'ok',
researchPath: `data/dossie-solla-cidade/${research.municipalitySlug}.${research.era.toLowerCase()}.research.json`,
researchedAt: research.researchedAt,
itemCount: research.items.length,
gapCount: research.gaps.length,
newsCount: research.news.length,
gaps: research.gaps.map((gap) => gap.id),
})
/**
* Merges the per-era research files into one view. Fails closed when the slugs
* disagree (a mixed bundle would print another city's facts).
*/
export const mergeDossierResearch = (researches) => {
const list = Array.isArray(researches) ? researches.filter(Boolean) : []
if (list.length === 0) throw new Error('Dossiê sem pesquisa: nenhum arquivo por era foi lido.')
const slug = list[0].municipalitySlug
for (const research of list) {
if (research.municipalitySlug !== slug) {
throw new Error(
`Pesquisas de municípios diferentes ("${slug}" e "${research.municipalitySlug}") — pare e regenere.`,
)
}
}
const eraOrder = new Map(DOSSIER_ERA_IDS.map((id, index) => [id, index]))
const items = list
.flatMap((research) => research.items)
.sort((left, right) => (eraOrder.get(left.era) ?? 0) - (eraOrder.get(right.era) ?? 0))
const news = list
.flatMap((research) => research.news)
.sort((left, right) => Date.parse(right.publishedAt) - Date.parse(left.publishedAt))
const gaps = list.flatMap((research) => research.gaps)
const researchedAt = list
.map((research) => research.researchedAt)
.sort()
.at(-1)
return {
municipalitySlug: slug,
eras: list.map((research) => research.era),
items,
news,
gaps,
researchedAt,
}
}