/** * Research contract for the dossiê (owner: C186; unit seam added by C187). The * dossiê is researched one file per 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. * * The `unit` descriptor selects the slug field, the sphere vocabulary and the * per-era checklist; without it the behaviour is the C186 municipality one. */ import { collectExtraSources, isNonEmptyString, isValidDate } from './cityReportResearch.mjs' import { DOSSIER_ERA_IDS } from './dossieCareer.mjs' import { MUNICIPALITY_UNIT, resolveDossierUnit } from './dossieUnit.mjs' /** 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 /** Municipality checklist (C186). `area` labels the boletim card. */ const MUNICIPALITY_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', }, ] /** Institution checklist (C187) — same eras, institution-oriented. */ const INSTITUTION_RESEARCH_CHECKLIST = [ { id: 'era_a_formacao', era: 'A', area: 'Formação', label: 'Formação, residência e pesquisa com vínculo nominal à instituição', }, { id: 'era_a_sesab', era: 'A', area: 'Gestão pública', label: 'Atuação na SESAB com efeito ou menção à instituição', }, { id: 'era_a_consultor_ms', era: 'A', area: 'Ministério da Saúde', label: 'Consultoria no Ministério da Saúde com interface institucional', }, { id: 'era_a_conquista', era: 'A', area: 'Gestão municipal', label: 'Gestão municipal de saúde com parceria institucional', }, { id: 'era_a_sas_ms', era: 'A', area: 'Ministério da Saúde', label: 'Secretaria de Atenção à Saúde do MS com interface institucional', }, { id: 'era_a_vinculo', era: 'A', area: 'Vínculo', label: 'Vínculo institucional documentado (professor, pesquisador, conselheiro)', }, { id: 'era_b_sesab', era: 'B', area: 'Gestão estadual', label: 'Gestão da SESAB (2007–2014) com a instituição', }, { id: 'era_b_equipamentos', era: 'B', area: 'Equipamentos', label: 'Equipamentos e serviços de saúde ligados à instituição', }, { id: 'era_b_programas', era: 'B', area: 'Programas', label: 'Programas e políticas estaduais com a instituição', }, { id: 'era_b_obras', era: 'B', area: 'Obras', label: 'Obras e investimentos na instituição', }, { id: 'era_b_convenios', era: 'B', area: 'Convênios', label: 'Convênios, termos e parcerias formais com a instituição', }, { id: 'era_c_discursos', era: 'C', area: 'Mandato', label: 'Pronunciamentos e falas com menção à instituição', }, { id: 'era_c_proposicoes', era: 'C', area: 'Mandato', label: 'Proposições e relatorias de interesse da instituição', }, { id: 'era_c_emendas', era: 'C', area: 'Emendas', label: 'Emendas e recursos destinados à instituição', }, { id: 'era_c_titulos', era: 'C', area: 'Reconhecimento', label: 'Títulos, honrarias e homenagens da instituição', }, { id: 'era_c_atuacao', era: 'C', area: 'Atuação', label: 'Atuação e articulação institucional', }, { id: 'era_c_parcerias', era: 'C', area: 'Parcerias', label: 'Parcerias, audiências e articulação formal com a instituição', }, ] /** @type {Record>} */ const CHECKLIST_BY_UNIT = { municipality: MUNICIPALITY_RESEARCH_CHECKLIST, institution: INSTITUTION_RESEARCH_CHECKLIST, } const checklistFor = (unit) => CHECKLIST_BY_UNIT[unit.id] ?? MUNICIPALITY_RESEARCH_CHECKLIST /** Municipality checklist ids (C186 surface kept stable). */ export const DOSSIER_RESEARCH_CHECKLIST_IDS = MUNICIPALITY_RESEARCH_CHECKLIST.map((item) => item.id) /** * @param {string} era * @param {any} [unit] * @returns {Array<{ id: string, era: string, area: string, label: string }>} */ export const dossierChecklistForEra = (era, unit = MUNICIPALITY_UNIT) => checklistFor(resolveDossierUnit(unit)).filter((item) => item.era === era) const normalizeSphere = (value, unit) => { if (!isNonEmptyString(value)) return unit.defaultSphere const sphere = value.trim() return unit.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 } /** * @typedef {Object} NormalizedDossierResearch * @property {string} era * @property {string} researchedAt * @property {any[]} items * @property {any[]} news * @property {any[]} gaps */ /** * 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. * * @param {any} raw * @param {{ unit?: any }} [options] * @returns {NormalizedDossierResearch & Record} */ export const normalizeDossierResearchInput = (raw, { unit } = {}) => { const resolvedUnit = resolveDossierUnit(unit) const checklist = checklistFor(resolvedUnit) const checklistById = new Map(checklist.map((item) => [item.id, item])) const slug = isNonEmptyString(raw?.[resolvedUnit.slugField]) ? raw[resolvedUnit.slugField].trim() : null if (!raw || typeof raw !== 'object') { throw new Error('Pesquisa do dossiê inválida: o arquivo precisa ser um objeto JSON.') } if (!slug) { throw new Error(`Pesquisa do dossiê inválida: falta "${resolvedUnit.slugField}".`) } 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, resolvedUnit) if (!sphere) { gaps.push({ id, label: checklistItem.label, reason: `Esfera inválida (esperado ${resolvedUnit.spheres.join('/')}).`, }) continue } const brief = normalizeBrief(entry.brief) warnIfBriefOverBudget(id, brief) items.push({ id, era, label: checklistItem.label, area: checklistItem.area, answer, // Optional short, complete, self-contained rewrite of `answer` (C188): the // resumo/boletim print it as-is; the era pages keep `answer`/`details` // integral. Inherits the item's source — not a new fact. summary: isNonEmptyString(entry.summary) ? entry.summary.trim() : null, 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, resolvedUnit)) { 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 { [resolvedUnit.slugField]: slug, era, researchedAt: researchedAt.toISOString(), items, news, gaps, } } /** * Short receipt the researcher subagent returns (never the body). * * @param {any} research * @param {{ unit?: any }} [options] * @returns {any} */ export const dossierResearchReceipt = (research, { unit } = {}) => { const resolvedUnit = resolveDossierUnit(unit) const slug = research[resolvedUnit.slugField] return { slug, era: research.era, status: 'ok', researchPath: `${resolvedUnit.researchDir}/${slug}.${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 unit's facts). * * @param {any} researches * @param {{ unit?: any }} [options] * @returns {{ eras: string[], items: any[], news: any[], gaps: any[], researchedAt: string } & Record} */ export const mergeDossierResearch = (researches, { unit } = {}) => { const resolvedUnit = resolveDossierUnit(unit) const slugField = resolvedUnit.slugField 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][slugField] for (const research of list) { if (research[slugField] !== slug) { const noun = resolvedUnit.id === 'institution' ? 'instituições' : 'municípios' throw new Error( `Pesquisas de ${noun} diferentes ("${slug}" e "${research[slugField]}") — 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 { [slugField]: slug, eras: list.map((research) => research.era), items, news, gaps, researchedAt, } }