teqo-backup/scripts/lib/dossieBlocks.mjs
Francisco Solla 4b00a8778a feat(C188): relatórios e dossiês sem truncamento mecânico
Dono único de orçamento de texto em reportText.mjs (capList,
excerptDeepDive, joinWithRemainder, summaryText/summarySurfaceText,
summaryListText), reusado por C163 e C186. Contrato summary por item nos
dois arquivos de pesquisa; superfície de resumo nunca corta com "…" e
aponta o aprofundamento; toda lista capada mostra contador; builders com
2 passadas guard-gated (integral → ponteiro → die fail-closed).

Closes #1136
2026-09-17 19:59:02 -03:00

723 lines
25 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.

/**
* Content model for the C186 dossiê (owner). Turns the read-only snapshot, the
* per-era research and the official sources (emendas, Câmara, health data) into
* the semantic report the renderer prints — and derives the `bulletinFacts`
* ledger (facts that already carry a source) that the boletim may reuse.
*
* Invariants enforced here: every delivered fact has URL+date; região/polo are
* labelled and never summed into the município; empty sections are omitted.
*/
import { formatDateTimeBr, formatMoneyCompact } from './cityReportFormat.mjs'
import {
CAREER_TIMELINE,
DOSSIER_ERAS,
DOSSIER_SCOPE,
careerTimelineHighlights,
} from './dossieCareer.mjs'
import {
INSTITUTION_UNIT,
MUNICIPALITY_UNIT,
isInstitutionUnit,
resolveDossierUnit,
} from './dossieUnit.mjs'
import { capList, stripInlineSources, summarySurfaceText } from './reportText.mjs'
const MAX_DELIVERIES_PAGE_ONE = 3
const MAX_HOOKS_PAGE_ONE = 2
const MAX_PENDING_PAGE_ONE = 4
const MAX_ERA_NUMBERS = 6
/** Curated research actions are all kept (the renderer paginates them); only the generic Câmara list is capped. */
const MAX_ERA_CAMARA_ACTIONS = 4
const MAX_REGION_ITEMS = 3
const MAX_SCOPE_LIST = 3
/** @param {string} sphere @param {any} [unit] @returns {string} */
export const dossierSphereLabel = (sphere, unit = MUNICIPALITY_UNIT) =>
resolveDossierUnit(unit).sphereLabels?.[sphere] ?? sphere
/** @param {string} sphere @param {any} [unit] @returns {string} */
export const dossierSphereBadgeClass = (sphere, unit = MUNICIPALITY_UNIT) =>
resolveDossierUnit(unit).sphereBadgeClass?.[sphere] ?? ''
const phaseLabels = {
autorizado: 'autorizado',
empenhado: 'empenhado',
liquidado: 'liquidado',
pago: 'pago',
restos: 'restos',
nao_informado: 'não informada',
}
const emendasPhaseKeys = [
['empenhado', 'empenhado'],
['liquidado', 'liquidado'],
['pago', 'pago'],
['restos', 'restoPago'],
]
const isPositive = (value) => Number.isFinite(Number(value)) && Number(value) > 0
/**
* Emenda execution rows, one per non-zero phase — never consolidating phases as
* if equivalent (empenho ≠ pagamento). Sourced from the official portal.
*/
const emendaNumberRows = (emendas) => {
const rows = []
if (!emendas || emendas.status !== 'ok') return rows
for (const row of emendas.rows ?? []) {
for (const [phase, key] of emendasPhaseKeys) {
if (!isPositive(row[key])) continue
rows.push({
object: row.functionName ?? row.type ?? 'Emenda',
value: formatMoneyCompact(row[key]),
year: row.year ? String(row.year) : null,
phase,
sphere: 'municipio',
sourceUrl: emendas.sourceUrl ?? null,
sourceDate: emendas.consultedAt ?? null,
})
}
}
return rows
}
const itemNumberRows = (items) => {
const rows = []
for (const item of items) {
for (const number of item.numbers ?? []) {
rows.push({
object: number.label,
value: number.value,
year: number.year,
phase: number.phase,
sphere: item.sphere,
sourceUrl: item.sourceUrl,
sourceDate: item.sourceDate,
})
}
}
return rows
}
const eraRecovery = {
A: 'Biografia Câmara · DOU/acervo do Ministério da Saúde · pesquisa web datada',
B: 'DOE-BA (DOOL) · notícias SESAB · Transparência Bahia · SIOPS/DATASUS',
C: 'API Câmara · Portal da Transparência · Siga Brasil · acervo interno read-only',
}
const eraMethod = {
A: 'Itens ligados ao município foram conferidos em fonte oficial e separados por esfera. Cargos técnicos e de gestão têm datas e objetos distintos — sem atribuição local sem documento contemporâneo.',
B: 'A gestão estadual é lida por equipamento, política e obra; cada linha informa esfera e fonte. Região e polo não somam ao município.',
C: 'Mandato, relatorias e execução financeira têm datas e estágios distintos. Empenho não é pagamento; o valor sempre acompanha a fase. A coleta na API da Câmara é limitada às páginas consultadas (não é um total): o que não foi coletado não aparece.',
}
const healthContext = (health) =>
health?.status === 'ok'
? (health.items ?? []).map((item) => ({
topic: item.topic,
detail: item.detail,
sourceUrl: item.sourceUrl,
sourceDate: item.sourceDate,
}))
: []
const camaraItemsAsActions = (camara, era) =>
(camara?.status === 'ok' ? (camara.items ?? []) : []).map((item) => ({
sphere: 'municipio',
year: item.year ? String(item.year) : null,
title: item.title,
detail: item.detail,
sourceUrl: item.sourceUrl,
sourceDate: item.sourceDate,
era,
}))
/**
* @typedef {Object} DossierReport
* @property {any} meta
* @property {any} cover
* @property {any} page1
* @property {any[]} trajectory
* @property {Array<{ id: string, label: string, period: string, subtitle: string, empty?: boolean, method: string, recovery: string, numbers: { items: any[], total: number, remaining: number }, actions: any[], camaraActionsRemaining: number, honors: any[] }>} eras
* @property {{
* ruleTitle: string,
* ruleBody: string,
* municipal: { label: string, items: any[], total: number, remaining: number },
* regional: { label: string, items: any[], total: number, remaining: number },
* evidence: { items: any[], total: number, remaining: number },
* context: any[],
* hook: any,
* priorityGap: any,
* }} region
* @property {{
* ruleTitle: string,
* ruleBody: string,
* lists: Array<{ key: string, label: string, sphere: string, items: any[], total: number, omitted: number }>,
* evidence: { items: any[], total: number, omitted: number },
* hook: any,
* priorityGap: any,
* }} reach
* @property {{ items: any[], total: number, omitted: number }} acervo
* @property {any[]} gaps
* @property {any[]} news
* @property {any} limits
* @property {any[]} bulletinFacts
* @property {any} [unit]
*/
/**
* Builds the dossiê report model. Dispatches on the unit descriptor: the
* municipality (C186) branch remains the default and byte-identical; the
* institution branch (C187) shares helpers but has its own sections.
*
* @param {{
* snapshot: any,
* research: any,
* emendas?: any,
* camara?: any,
* health?: any,
* generatedAt?: Date,
* unit?: any,
* }} params
* @returns {DossierReport}
*/
export const buildDossierReport = (params) =>
isInstitutionUnit(params?.unit) ? buildInstitutionReport(params) : buildMunicipalityReport(params)
const buildMunicipalityReport = ({
snapshot,
research,
emendas = null,
camara = null,
health = null,
generatedAt = new Date(),
textFallback = 'full',
}) => {
const municipality = snapshot.municipality ?? {}
const municipalityName = municipality.name ?? municipality.slug ?? 'Município'
const region = municipality.region ?? null
const poleLine = region
? `Território de Identidade ${region} · recorte regional não somável ao município`
: 'Recorte regional não somável ao município'
const items = research.items ?? []
const municipalItems = items.filter((item) => item.sphere === 'municipio')
const regionalItems = items.filter((item) => item.sphere !== 'municipio')
const eraRows = emendaNumberRows(emendas)
const camaraActions = camaraItemsAsActions(camara, 'C')
const researchActionsByEra = (era) =>
items
.filter((item) => item.era === era)
.map((item) => ({
sphere: item.sphere,
year: item.numbers?.[0]?.year ?? null,
title: item.answer,
detail: item.details,
brief: item.brief ?? null,
sourceUrl: item.sourceUrl,
sourceDate: item.sourceDate,
}))
const eraSections = DOSSIER_ERAS.map((era) => {
const ownActions = researchActionsByEra(era.id)
const extraActions = era.id === 'C' ? camaraActions : []
const numbers = capList(
era.id === 'C'
? [...eraRows, ...itemNumberRows(items.filter((i) => i.era === 'C'))]
: itemNumberRows(items.filter((i) => i.era === era.id)),
MAX_ERA_NUMBERS,
)
const honors =
era.id === 'C'
? items
.filter((item) => item.era === era.id && item.id === 'era_c_titulos')
.map((item) => ({
text: item.answer,
brief: item.brief ?? null,
sourceUrl: item.sourceUrl,
}))
: []
// Curated research actions are all kept (the renderer paginates them into
// continuation sheets); only the generic Câmara list is capped.
const camaraActionsCapped = capList(extraActions, MAX_ERA_CAMARA_ACTIONS)
const actions = [...ownActions, ...camaraActionsCapped.items]
const camaraActionsRemaining = camaraActionsCapped.remaining
if (numbers.items.length === 0 && actions.length === 0) return null
return {
...era,
method: eraMethod[era.id],
recovery: eraRecovery[era.id],
numbers,
actions,
camaraActionsRemaining,
honors,
}
}).filter(Boolean)
const deliveries = capList(
[...municipalItems]
.sort((left, right) => {
const leftEra = left.era === 'C' ? 0 : 1
const rightEra = right.era === 'C' ? 0 : 1
if (leftEra !== rightEra) return leftEra - rightEra
const leftNumber = left.numbers?.length ? 0 : 1
const rightNumber = right.numbers?.length ? 0 : 1
return leftNumber - rightNumber
})
.map((item) => {
const { text, fromSummary } = summarySurfaceText(item, textFallback)
return {
sphere: item.sphere,
era: item.era,
year: item.numbers?.[0]?.year ?? null,
title: text,
detail: fromSummary ? item.details : textFallback === 'pointer' ? null : item.details,
brief: item.brief ?? null,
value: item.numbers?.[0]?.value ?? null,
phase: item.numbers?.[0]?.phase ?? null,
sourceUrl: item.sourceUrl,
sourceDate: item.sourceDate,
}
}),
MAX_DELIVERIES_PAGE_ONE,
)
const hooks = capList(
[...municipalItems, ...regionalItems].map((item) => ({
topic: item.area,
angle: summarySurfaceText(item, textFallback).text,
brief: item.brief ?? null,
sourceUrl: item.sourceUrl,
})),
MAX_HOOKS_PAGE_ONE,
)
const gaps = (research.gaps ?? []).map((gap) => ({
label: gap.label ?? gap.id,
reason: gap.reason,
nextStep: 'Apurar em fonte primária.',
}))
const pending = capList(
(research.gaps ?? []).map((gap) => gap.label ?? gap.id),
MAX_PENDING_PAGE_ONE,
)
const context = healthContext(health)
const regionalEvidence = capList(
regionalItems.map((item) => ({
item: item.answer,
sphere: item.sphere,
evidence: item.details ?? item.label,
brief: item.brief ?? null,
sourceUrl: item.sourceUrl,
})),
MAX_REGION_ITEMS,
)
const bulletinFacts = [
...items
.filter((item) => Boolean(item.sourceUrl))
.map((item) => ({
id: item.id,
era: item.era,
sphere: item.sphere,
area: item.area,
headline: stripInlineSources(summarySurfaceText(item).text),
detail: item.details ? stripInlineSources(item.details) : null,
brief: item.brief
? {
title: stripInlineSources(item.brief.title),
note: item.brief.note ? stripInlineSources(item.brief.note) : null,
}
: null,
value: item.numbers?.[0]?.value ?? null,
numberLabel: item.numbers?.[0]?.label ?? null,
year: item.numbers?.[0]?.year ?? null,
phase: item.numbers?.[0]?.phase ?? null,
sourceUrl: item.sourceUrl,
sourceDate: item.sourceDate,
})),
...eraRows
.filter((row) => row.sourceUrl)
.map((row, index) => ({
id: `emenda-${index}`,
era: 'C',
sphere: row.sphere,
area: 'Emendas',
headline: row.object,
detail: null,
value: row.value,
numberLabel: row.object,
year: row.year,
phase: row.phase,
sourceUrl: row.sourceUrl,
sourceDate: row.sourceDate,
})),
]
const generatedAtLabel = formatDateTimeBr(generatedAt)
const readAtLabel = snapshot.meta?.readAt ? formatDateTimeBr(snapshot.meta.readAt) : '—'
return {
meta: {
title: 'Dossiê Solla por cidade',
municipalityName,
region: region ?? '—',
generatedAt,
generatedAtLabel,
readAtLabel,
},
cover: {
kicker: 'Comunicação · pesquisa documental',
series: 'Série municipal · BA',
internalNote: 'INSUMO INTERNO',
internalNoteSub: 'Não circular · não publicar',
eyebrow: 'Dossiê de atuação pública',
title: municipalityName,
subtitle: 'O que Jorge Solla fez pela cidade e por seu recorte regional ao longo da carreira',
territory: region ?? '—',
pole: poleLine,
howToUse:
'Escolha um tema, confira a fonte ao lado da afirmação e trate toda lacuna como tarefa de apuração. Este arquivo não é texto final nem peça publicitária.',
scope: DOSSIER_SCOPE,
version: 'Documento de trabalho · versão 01',
},
page1: {
timeline: careerTimelineHighlights(),
deliveries,
hooks,
pending,
},
trajectory: CAREER_TIMELINE,
eras: eraSections,
region: {
ruleTitle: 'Região não é cidade. Não some os dois recortes.',
ruleBody:
'Um equipamento de referência ou uma política regional pode atender moradores do município sem constituir entrega exclusiva para ele. Atribuição só entra com alcance documentado.',
municipal: { label: 'município', ...capList(municipalItems, MAX_SCOPE_LIST) },
regional: { label: 'região / polo', ...capList(regionalItems, MAX_SCOPE_LIST) },
evidence: regionalEvidence,
context,
hook: hooks.items[0] ?? null,
priorityGap: pending.items[0] ?? null,
},
gaps,
news: (research.news ?? []).map((row) => ({
date: row.publishedAt,
outlet: row.outlet ?? '—',
title: row.title,
url: row.url,
})),
limits: {
coverage: [
'O acervo interno de falas cobre 2011+; períodos anteriores dependem de fontes externas.',
'Resultado de busca não prova ausência histórica.',
'Emenda de bancada ou relator só recebe autoria quando a fonte a confirma.',
],
editorial: [
'Sem fonte, não publica.',
'URL e data acompanham cada afirmação não trivial.',
'Município, região e polo permanecem separados.',
'Valor sempre informa a fase de execução.',
],
},
bulletinFacts,
}
}
/* ------------------------------------------------------------------ *
* Institution branch (C187) — same owner, institution-shaped sections. *
* ------------------------------------------------------------------ */
const MAX_INSTITUTION_DELIVERIES = 3
const MAX_INSTITUTION_TIMELINE = 3
const MAX_INSTITUTION_HOOKS = 2
const MAX_INSTITUTION_PENDING = 4
const MAX_INSTITUTION_SCOPE_LIST = 5
const MAX_INSTITUTION_EVIDENCE = MAX_INSTITUTION_SCOPE_LIST * 2
const MAX_INSTITUTION_ACERVO = 6
const INSTITUTION_SCOPE =
'Escopo: carreira técnica e gestão pública até 2006 · SESAB 2007–2014 · Câmara dos Deputados 2015–2027. Instituição, setor e rede separados; sem fonte, não entra como entrega.'
const institutionEraMethod = {
A: 'Registros anteriores a 2007 que citem nominalmente a instituição ou comprovem vínculo. Cargo geral não prova ação institucional.',
B: 'A gestão estadual é lida por equipamento, programa, convênio e obra; cada linha informa abrangência e fonte. Setor e rede não somam à instituição.',
C: 'Mandato, relatorias, parcerias e execução financeira têm datas e estágios distintos. Empenho não é pagamento; o valor sempre acompanha a fase.',
}
const institutionEraRecovery = {
A: 'biografias oficiais · atos e diários · acervos institucionais · busca web datada',
B: 'DOE-BA (DOOL) · notícias SESAB/instituição · Transparência Bahia · busca web datada',
C: 'API Câmara · Portal da Transparência · acervo interno read-only · busca web datada',
}
/**
* Wraps a capped list so the renderer can declare what stayed out ("e mais N")
* instead of dropping it silently — the C188 no-truncation rule, enforced at
* the owner for both recortes. `omitted` is the C187 renderer's name for the
* same count `capList` calls `remaining`.
*/
const capped = (list, max) => {
const { items, total, remaining } = capList(list, max)
return { items, total, omitted: remaining }
}
const identityBadges = (identity) =>
[identity.kindLabel, identity.sphereLabel, identity.scope ? identity.scopeLabel : null].filter(
Boolean,
)
/**
* Speeches from the internal acervo (read-only snapshot) that the research did
* not already carry: they are sourced evidence for the institution recorte, so
* they feed the bulletin ledger too — never a second, unsourced fact.
*/
const institutionSpeechFacts = (snapshot) =>
(snapshot.speeches?.rows ?? [])
.filter((row) => Boolean(row.officialTextUrl || row.youtubeUrl || row.vodPlaybackUrl))
.map((row) => ({
id: `fala-${row.id}`,
era: 'C',
sphere: 'instituicao',
area: 'Acervo de falas',
headline:
row.summary ??
row.mentionExcerpt ??
`Pronunciamento na Câmara (${String(row.speechAt ?? '').slice(0, 4)})`,
detail: row.mentionExcerpt ?? null,
value: null,
numberLabel: null,
year: row.speechAt ? String(row.speechAt).slice(0, 4) : null,
phase: null,
sourceUrl: row.officialTextUrl ?? row.youtubeUrl ?? row.vodPlaybackUrl,
sourceDate: row.speechAt ?? null,
}))
const buildInstitutionReport = ({ snapshot, research, generatedAt = new Date() }) => {
const identity = snapshot.institution ?? {}
const subjectName = identity.name ?? identity.slug ?? 'Instituição'
const unit = INSTITUTION_UNIT
const speechFacts = institutionSpeechFacts(snapshot)
const items = research.items ?? []
const inScope = (sphere) => items.filter((item) => item.sphere === sphere)
const directItems = inScope('instituicao')
const sectorItems = inScope('setor')
const networkItems = inScope('rede')
const researchActionsByEra = (era) =>
items
.filter((item) => item.era === era)
.map((item) => ({
sphere: item.sphere,
year: item.numbers?.[0]?.year ?? null,
title: item.answer,
detail: item.details,
brief: item.brief ?? null,
sourceUrl: item.sourceUrl,
sourceDate: item.sourceDate,
}))
const eraSections = DOSSIER_ERAS.map((era) => {
const numbers = capped(
itemNumberRows(items.filter((item) => item.era === era.id)),
MAX_ERA_NUMBERS,
)
const actions = capped(researchActionsByEra(era.id), MAX_ERA_CAMARA_ACTIONS)
const honors = items
.filter((item) => item.era === era.id && item.id === 'era_c_titulos')
.map((item) => ({
text: item.answer,
detail: item.details ?? null,
brief: item.brief ?? null,
date: item.sourceDate ?? null,
year: item.numbers?.[0]?.year ?? null,
sourceUrl: item.sourceUrl,
}))
return {
...era,
method: institutionEraMethod[era.id],
recovery: institutionEraRecovery[era.id],
numbers,
actions,
honors,
empty: numbers.items.length === 0 && actions.items.length === 0,
}
})
const deliveryList = [...directItems]
.sort((left, right) => {
const leftEra = left.era === 'C' ? 0 : 1
const rightEra = right.era === 'C' ? 0 : 1
if (leftEra !== rightEra) return leftEra - rightEra
const leftNumber = left.numbers?.length ? 0 : 1
const rightNumber = right.numbers?.length ? 0 : 1
return leftNumber - rightNumber
})
.map((item) => ({
sphere: item.sphere,
era: item.era,
year: item.numbers?.[0]?.year ?? null,
title: item.answer,
detail: item.details,
brief: item.brief ?? null,
value: item.numbers?.[0]?.value ?? null,
phase: item.numbers?.[0]?.phase ?? null,
sourceUrl: item.sourceUrl,
sourceDate: item.sourceDate,
}))
const deliveries = capped(deliveryList, MAX_INSTITUTION_DELIVERIES)
const hookList = items.map((item) => ({
topic: item.area,
angle: item.answer,
brief: item.brief ?? null,
sourceUrl: item.sourceUrl,
}))
const hooks = capped(hookList, MAX_INSTITUTION_HOOKS)
const acervo = capped(
speechFacts.map((fact) => ({
id: fact.id,
period: fact.year ?? '—',
text: fact.headline,
excerpt: fact.detail,
sourceUrl: fact.sourceUrl,
})),
MAX_INSTITUTION_ACERVO,
)
const timelineList = items
.filter((item) => item.sourceUrl)
.map((item) => ({
period: item.numbers?.[0]?.year ?? (item.sourceDate ?? '').slice(0, 4) ?? '—',
role: item.answer,
brief: item.brief ?? null,
source: item.label,
url: item.sourceUrl,
}))
.sort((left, right) => String(left.period).localeCompare(String(right.period)))
const timeline = capped(timelineList, MAX_INSTITUTION_TIMELINE)
const gaps = (research.gaps ?? []).map((gap) => ({
label: gap.label ?? gap.id,
reason: gap.reason,
nextStep: 'Apurar em fonte primária.',
}))
const pendingList = (research.gaps ?? []).map((gap) => gap.label ?? gap.id)
const pending = capped(pendingList, MAX_INSTITUTION_PENDING)
const scopeItem = (sphere, key, label) => {
const sphereItems = items.filter((item) => item.sphere === sphere)
return { key, label, sphere, ...capped(sphereItems, MAX_INSTITUTION_SCOPE_LIST) }
}
const scopeLists = [
scopeItem('instituicao', 'institution', unit.sphereLabels.instituicao),
scopeItem('setor', 'sector', unit.sphereLabels.setor),
scopeItem('rede', 'network', unit.sphereLabels.rede),
]
const scopeEvidence = capped(
[...sectorItems, ...networkItems].map((item) => ({
item: item.answer,
sphere: item.sphere,
evidence: item.details ?? item.label,
brief: item.brief ?? null,
sourceUrl: item.sourceUrl,
})),
MAX_INSTITUTION_EVIDENCE,
)
const bulletinFacts = items
.filter((item) => Boolean(item.sourceUrl))
.map((item) => ({
id: item.id,
era: item.era,
sphere: item.sphere,
area: item.area,
headline: stripInlineSources(item.answer),
detail: item.details ? stripInlineSources(item.details) : null,
brief: item.brief
? {
title: stripInlineSources(item.brief.title),
note: item.brief.note ? stripInlineSources(item.brief.note) : null,
}
: null,
value: item.numbers?.[0]?.value ?? null,
numberLabel: item.numbers?.[0]?.label ?? null,
year: item.numbers?.[0]?.year ?? null,
phase: item.numbers?.[0]?.phase ?? null,
sourceUrl: item.sourceUrl,
sourceDate: item.sourceDate,
}))
.concat(speechFacts.map((fact) => ({ ...fact, headline: stripInlineSources(fact.headline) })))
const generatedAtLabel = formatDateTimeBr(generatedAt)
const readAtLabel = snapshot.meta?.readAt ? formatDateTimeBr(snapshot.meta.readAt) : '—'
return {
unit,
meta: {
title: unit.title,
subjectName,
identity,
identityBadges: identityBadges(identity),
generatedAt,
generatedAtLabel,
readAtLabel,
},
cover: {
kicker: 'Comunicação · pesquisa documental',
series: unit.series,
internalNote: 'INSUMO INTERNO — defeso 2026',
internalNoteSub: 'Não circular · não publicar',
eyebrow: 'Dossiê de atuação pública',
title: subjectName,
subtitle: 'O que Jorge Solla fez por, na e com a instituição ao longo da carreira',
howToUse:
'Localize o vínculo ou a entrega, confira fonte, data, fase e abrangência. Transforme toda lacuna em tarefa de apuração. Este arquivo não é fala pronta nem peça pública.',
scope: INSTITUTION_SCOPE,
version: 'Documento de trabalho · versão 01',
},
page1: { timeline, deliveries, hooks, pending },
trajectory: CAREER_TIMELINE,
eras: eraSections,
acervo,
reach: {
ruleTitle: 'Setor/rede não é a instituição. Não some os recortes.',
ruleBody:
'Uma política para uma categoria ou uma articulação com entidades correlatas pode alcançar a instituição sem constituir entrega exclusiva para ela.',
lists: scopeLists,
evidence: scopeEvidence,
hook: hooks.items[0] ?? null,
priorityGap: pending.items[0] ?? null,
},
gaps,
news: (research.news ?? []).map((row) => ({
date: row.publishedAt,
outlet: row.outlet ?? '—',
title: row.title,
url: row.url,
})),
limits: {
coverage: [
'O acervo interno de falas cobre 2011+; períodos anteriores dependem de fontes externas.',
'Resultado de busca não prova ausência histórica.',
'Emenda de bancada ou relator só recebe autoria quando a fonte a confirma.',
'Menção a entidade correlata (setor/rede) não é atribuição à instituição.',
],
editorial: [
'Sem fonte, não publica.',
'URL e data acompanham cada afirmação não trivial.',
'Instituição, setor e rede permanecem separados.',
'Valor sempre informa a fase de execução.',
],
},
bulletinFacts,
}
}
export const dossierPhaseLabel = (phase) => phaseLabels[phase] ?? phaseLabels.nao_informado