mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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`.
359 lines
12 KiB
JavaScript
359 lines
12 KiB
JavaScript
/**
|
||
* 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,
|
||
}
|
||
}
|