teqo-backup/scripts/themeSnapshot.mjs
Francisco Solla e5e1ae7ee8 feat(C190): dossiê Solla por tema/área + boletim modelo (skill + builder)
Terceira variante da família de dossiês: recorte pela área canônica do
acervo (SPEECH_TOPICS), com o pipeline C186/C187 generalizado para a forma
subject via THEME_UNIT (generalização aditiva; C186/C187 byte-idênticos).

- resolveSpeechTopic no dono speechFacets (fail-closed, sem slug inventado)
- themeSnapshot + extract-theme-snapshot (THEME_REPORT_CONFIRM=1, read-only)
- build-dossie-solla-tema (pack medido, PDF A4 + .md + boletim 1 página)
- skill/comando/2 subagentes (researcher por era + redator) + specs
- cenas theme-only no descritor (acervo universo/amostra, abrangência,
  lacunas, hierarquia de fontes, pills do boletim) certificadas pelo designer

Closes #1143
2026-09-18 17:02:56 -03:00

106 lines
2.8 KiB
JavaScript

/**
* Snapshot composer for the theme/area dossiê (C190).
*
* Runs on the homeserver against production, read-only: the recorte IS the
* acervo tag (`Speech.topics`), so there is no institution-style bridge and no
* nominal match — the theme itself is the filter. Reuses the C163 speech
* loaders (no parallel source of truth), projects out PII and returns plain
* JSON. The render step never touches the database again.
*
* Integration layer, not a pure lib: it imports Payload-coupled utilities and
* is exercised by `tests/int/themeSnapshot.int.spec.ts`.
*/
import { excerptOffsetSeconds } from '../src/lib/speechVod.ts'
import { buildSpeechListWhere } from '../src/utilities/speech/speechListFilters.ts'
const SPEECH_LIMIT = 15
const SPEECH_SELECT = {
speechAt: true,
type: true,
phase: true,
summary: true,
officialTextUrl: true,
youtubeUrl: true,
vodPlaybackUrl: true,
excerptTMs: true,
eventStartAt: true,
topics: true,
}
/**
* Projects one speech: the official summary says what it is. There is no
* `mentionExcerpt` here — the acervo tag is the recorte, not a literal mention.
*/
const projectSpeechRow = (doc) => ({
id: doc.id,
speechAt: doc.speechAt ?? null,
type: doc.type ?? null,
phase: doc.phase ?? null,
summary: doc.summary ?? null,
officialTextUrl: doc.officialTextUrl ?? null,
youtubeUrl: doc.youtubeUrl ?? null,
vodPlaybackUrl: doc.vodPlaybackUrl ?? null,
youtubeExcerptStartSeconds: excerptOffsetSeconds(doc.excerptTMs, doc.eventStartAt),
topics: Array.isArray(doc.topics) ? doc.topics : [],
})
/**
* @param {{
* payload: any,
* actor: any,
* identity: { slug: string, value: string, label: string, taxonomyNote?: string | null },
* readAt: string,
* codeSha?: string | null,
* database?: string | null,
* }} params
* @returns {Promise<any>}
*/
export const composeThemeSnapshot = async ({
payload,
actor,
identity,
readAt,
codeSha = null,
database = null,
}) => {
const topics = [identity.value]
const gaps = []
const result = await payload.find({
collection: 'speech',
where: buildSpeechListWhere({ page: 1, topics }),
depth: 0,
limit: SPEECH_LIMIT,
sort: '-speechAt',
select: SPEECH_SELECT,
user: actor,
overrideAccess: false,
})
const docs = result.docs
const totalCount = result.totalDocs
if (docs.length === 0) {
gaps.push({
id: 'acervo_sem_falas',
reason: `Nenhuma fala do acervo no tema ${identity.value} — o acervo cobre 2011+.`,
})
}
return {
meta: { readAt, codeSha, database, kind: 'theme' },
theme: {
slug: identity.slug,
value: identity.value,
label: identity.label,
taxonomyNote: identity.taxonomyNote ?? 'Taxonomia do acervo',
topics,
},
speeches: {
topics,
totalCount,
rows: docs.map((doc) => projectSpeechRow(doc)),
},
gaps,
}
}