Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/prompts/06-article-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@ What the aggregator does:
1. Reads every committed `.md` under `analysis/daily/$ARTICLE_DATE/$SUBFOLDER/` (including `documents/*.md` and any `ext/*.md`).
2. Strips each file's YAML front-matter, duplicate H1, and admin/footer boilerplate (`Document control`, `Audit trail`, `Generated by …`, `Run ID …`, `— End of template —`).
3. Rewrites relative `[label](path.md)` links to absolute GitHub blob URLs so every claim stays auditable.
4. Emits the **Executive Brief first** (BLUF / so-what up front), then injects a deterministic `## Reader Intelligence Guide` immediately after. Article types that produce a subset of artifacts (realtime, week-ahead, monthly-review) are fully supported: missing artifacts are skipped silently and the guide lists only the lenses generated.
4. Emits the **Executive Brief first** (journalistic lede / so-what up front), then injects a deterministic `## Reader Intelligence Guide` immediately after. Article types that produce a subset of artifacts (realtime, week-ahead, monthly-review) are fully supported: missing artifacts are skipped silently and the guide lists only the lenses generated.
5. Concatenates files in the deterministic political-intelligence order below, injecting one `## <Section title>` heading and a `_Source: [\`file.md\`](GitHub URL)_` attribution above each block. Mermaid code fences pass through untouched.
6. Emits one canonical markdown file with YAML front-matter:

`analysis/daily/$ARTICLE_DATE/$SUBFOLDER/article.md`

**Canonical order** (see `scripts/render-lib/index.ts:AGGREGATION_ORDER`):

1. `executive-brief.md` ← lede / BLUF (title + meta description come from its H1 + first real paragraph; admin bylines like `**Author**`/`**Run ID**`/`**Classification**`/`**Confidence**` are stripped before description selection)
1. `executive-brief.md` ← journalistic lede (title + meta description come from its H1 + first real paragraph; admin bylines like `**Author**`/`**Run ID**`/`**Classification**`/`**Confidence**` are stripped before description selection)
2. `synthesis-summary.md`
3. `intelligence-assessment.md` ← Key Judgments + PIRs (ICD-203 — placed immediately after the synthesis so readers meet the thesis before the evidence stack)
4. `significance-scoring.md`
Expand Down Expand Up @@ -170,7 +170,7 @@ The renderer embeds:
- Cyberpunk site header with skip-link, nav (Home, Political Intelligence, Sitemap), language switcher.
- Footer: brand, navigation, direct link to `analysis/daily/` and the repo root, Apache-2.0 + GDPR Art 9(2)(e,g) notice, client-side Mermaid loader (`js/lib/mermaid-init.mjs`).

Before staging, read the generated `article.md` once and verify it reads as a coherent political-intelligence article (not an artifact dump): BLUF first, Key Judgments early, concrete evidence density, IMF-first economic provenance where applicable, Statskontoret agency-capacity evidence where applicable, source links in every high-impact claim.
Before staging, read the generated `article.md` once and verify it reads as a coherent political-intelligence article (not an artifact dump): journalistic lede first, Key Judgments early, concrete evidence density, IMF-first economic provenance where applicable, Statskontoret agency-capacity evidence where applicable, source links in every high-impact claim.

## Translations

Expand Down
14 changes: 13 additions & 1 deletion scripts/render-lib/aggregator/aggregate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import { buildGithubBlobUrl } from '../url-helpers.js';
import { buildArticleKeywords } from '../article-seo.js';
import {
cleanArtifactBody,
createNarrativeNormalizationState,
normalizeNarrativeTerminology,
rewriteRelativeLinks,
} from './cleaning/structural.js';
import { buildFrontMatter } from './frontmatter.js';
Expand Down Expand Up @@ -133,6 +135,13 @@ export function aggregateAnalysis(input: AggregationInput): AggregationResult {
// their stated semantics.
const aliasSuppressedAtSelection = new Set<string>();

// First-use narrative annotations (confidence-code gloss, Riksdag
// document-id contextualization) must fire once per *article*, not once per
// artifact body. Own the state here and thread it through every
// normalizeNarrativeTerminology call so the annotation is not re-emitted in
// every artifact that contains a match.
const narrativeState = createNarrativeNormalizationState();

const readSection = (fileName: string, skipIfMissing: boolean): void => {
const abs = path.join(subfolderAbsPath, fileName);
if (!fs.existsSync(abs)) {
Expand All @@ -142,7 +151,10 @@ export function aggregateAnalysis(input: AggregationInput): AggregationResult {
return;
}
const raw = fs.readFileSync(abs, 'utf8');
const clean = rewriteRelativeLinks(cleanArtifactBody(raw), subfolderRepoRelPath);
const clean = normalizeNarrativeTerminology(
rewriteRelativeLinks(cleanArtifactBody(raw), subfolderRepoRelPath),
narrativeState,
);
if (!clean) {
cleanedToEmpty.add(fileName);
return;
Expand Down
87 changes: 87 additions & 0 deletions scripts/render-lib/aggregator/cleaning/structural.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,93 @@ export function stripInlineReaderGuide(body: string): string {
);
}

/**
* First-use annotation state for {@link normalizeNarrativeTerminology}.
*
* The confidence-code gloss and the Riksdag-document-id contextualization are
* *first-use only* — they must be emitted at most once per rendered article,
* not once per artifact body. The aggregator calls
* {@link normalizeNarrativeTerminology} separately for every artifact (see
* `aggregate.ts`), so the flags cannot live as function-local state: they would
* reset for each file and re-emit the annotation in every artifact that matches.
* The caller therefore owns one state object for the whole article and threads
* it through every call.
*/
export interface NarrativeNormalizationState {
confidenceExplained: boolean;
firstDocContextualized: boolean;
}

/** Fresh per-article first-use state (all flags unset). */
export function createNarrativeNormalizationState(): NarrativeNormalizationState {
return { confidenceExplained: false, firstDocContextualized: false };
}

/**
* Apply reader-facing narrative terminology normalization:
* - replace BLUF headings with journalistic lede wording
* - rename decision-support heading to plain-language wording
* - explain confidence code notation at first mention
* - contextualize the first `HDxxxxx` token as a Riksdag document id
*
* **Language scope.** Every rewrite below injects English copy (`Lede`,
* `Decisions and confidence context`, `… confidence, corroborated by multiple
* sources`, `Riksdag document #…`). Source analysis artifacts are authored in
* English and only English bodies flow through the aggregator, so the rewrites
* are gated to `lang === 'en'`. For any other language the body is returned
* untouched to avoid injecting English strings into otherwise localized prose
* (e.g. a translated `executive-brief_<lang>.md`).
*
* **First-use scope.** `confidenceExplained` / `firstDocContextualized` live on
* the caller-supplied {@link NarrativeNormalizationState} so the first-use
* annotations fire once per *article*, not once per artifact body.
*
* **Document-id scope.** Only the `HD` prefix is contextualized because it is
* the sole *bare* Riksdag document-identifier token used in these artifacts
* (matching `BILL_ID_RE` in `seo/brief-extractor.ts`, e.g. `HD03271`). Other
* Riksdag references — propositions, motions, interpellations, written
* questions, committee reports, public inquiries (`prop. 2025/26:267`,
* `MOT 2023/24:1234`, `IP 2023/24:567`) — appear as session-scoped
* `YYYY/NN:NNN` references whose trailing number is not a global document id,
* so framing them as `Riksdag document #…` would be incorrect.
*/
export function normalizeNarrativeTerminology(
body: string,
state: NarrativeNormalizationState = createNarrativeNormalizationState(),
lang: string = 'en',
): string {
if (lang !== 'en') return body;

let out = body.replace(
/^(#{2,6})\s*(?:🎯\s*)?(?:BLUF(?:\s*\(Bottom Line Up Front\))?|Bottom Line Up Front)\s*$/gim,
'$1 Lede',
);
out = out.replace(
/^(#{2,6})\s*Decisions This Brief Supports\s*$/gim,
'$1 Decisions and confidence context',
);

out = out.replace(/\b(HIGH|MEDIUM|LOW)\s*\(([A-C]\d)\)/g, (match, band: string, code: string) => {
if (state.confidenceExplained) return match;
state.confidenceExplained = true;
const explanation =
band === 'HIGH'
? 'high confidence, corroborated by multiple sources'
: band === 'MEDIUM'
? 'medium confidence, partial corroboration'
: 'low confidence, limited corroboration';
return `${band} (${code}, ${explanation})`;
});

out = out.replace(/\b(HD(\d{5,}))\b/g, (match, fullId: string, numericId: string) => {
if (state.firstDocContextualized) return match;
state.firstDocContextualized = true;
return `Riksdag document #${numericId} (${fullId})`;
});

return out;
}

// Re-exported from dedicated deduplication module (extracted for ≤200 LOC constraint).
export { dedupeAdjacentDuplicateLines, collapseRepeatedFooterBlocks } from './deduplication.js';

Expand Down
16 changes: 8 additions & 8 deletions scripts/render-lib/aggregator/order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,8 @@ export function aliasGroupFor(file: string): ReadonlySet<string> | null {
* {@link prettifyFallbackTitle}.
*/
const SECTION_TITLES: Record<string, string> = {
'executive-brief.md': 'Executive Brief',
'synthesis-summary.md': 'Synthesis Summary',
'executive-brief.md': 'What Happened',
'synthesis-summary.md': 'Why It Matters',
'significance-scoring.md': 'Significance Scoring',
'stakeholder-perspectives.md': 'Stakeholder Perspectives',
'stakeholder-impact.md': 'Stakeholder Perspectives',
Expand All @@ -178,12 +178,12 @@ const SECTION_TITLES: Record<string, string> = {
'media-framing-analysis.md': 'Media Framing Analysis',
'implementation-feasibility.md': 'Implementation Feasibility',
'devils-advocate.md': "Devil's Advocate",
'intelligence-assessment.md': 'Intelligence Assessment — Key Judgments',
'classification-results.md': 'Classification Results',
'political-classification.md': 'Political Classification',
'cross-reference-map.md': 'Cross-Reference Map',
'methodology-reflection.md': 'Methodology Reflection & Limitations',
'data-download-manifest.md': 'Data Download Manifest',
'intelligence-assessment.md': 'Key Findings',
'classification-results.md': 'Deep Dive: Classification Results',
'political-classification.md': 'Deep Dive: Political Classification',
'cross-reference-map.md': 'Deep Dive: Cross-Reference Map',
'methodology-reflection.md': 'Deep Dive: Methodology & Limitations',
'data-download-manifest.md': 'Deep Dive: Data Download Manifest',
};

/**
Expand Down
2 changes: 1 addition & 1 deletion scripts/render-lib/aggregator/reader-guide-i18n/da.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const CHROME: ReaderGuideChrome = {

export const ENTRIES: Record<string, ReaderGuideEntryI18n> = {
'executive-brief.md': {
label: 'BLUF og redaktionelle beslutninger',
label: 'Lede og redaktionelle beslutninger',
readerValue: 'hurtigt svar på hvad der skete, hvorfor det betyder noget, hvem der er ansvarlig, og den næste daterede udløser',
},
'intelligence-assessment.md': {
Expand Down
2 changes: 1 addition & 1 deletion scripts/render-lib/aggregator/reader-guide-i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const CHROME: ReaderGuideChrome = {

export const ENTRIES: Record<string, ReaderGuideEntryI18n> = {
'executive-brief.md': {
label: 'BLUF und redaktionelle Entscheidungen',
label: 'Aufmacher und redaktionelle Entscheidungen',
readerValue: 'schnelle Antwort auf was geschah, warum es wichtig ist, wer verantwortlich ist und der nächste datierte Auslöser',
},
'intelligence-assessment.md': {
Expand Down
2 changes: 1 addition & 1 deletion scripts/render-lib/aggregator/reader-guide-i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const CHROME: ReaderGuideChrome = {

export const ENTRIES: Record<string, ReaderGuideEntryI18n> = {
'executive-brief.md': {
label: 'BLUF and editorial decisions',
label: 'Lede and editorial decisions',
readerValue: 'fast answer to what happened, why it matters, who is accountable, and the next dated trigger',
},
'intelligence-assessment.md': {
Expand Down
2 changes: 1 addition & 1 deletion scripts/render-lib/aggregator/reader-guide-i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const CHROME: ReaderGuideChrome = {

export const ENTRIES: Record<string, ReaderGuideEntryI18n> = {
'executive-brief.md': {
label: 'BLUF y decisiones editoriales',
label: 'Entradilla y decisiones editoriales',
readerValue: 'respuesta rápida sobre qué sucedió, por qué importa, quién es responsable y el próximo disparador fechado',
},
'intelligence-assessment.md': {
Expand Down
2 changes: 1 addition & 1 deletion scripts/render-lib/aggregator/reader-guide-i18n/fi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const CHROME: ReaderGuideChrome = {

export const ENTRIES: Record<string, ReaderGuideEntryI18n> = {
'executive-brief.md': {
label: 'BLUF ja toimitukselliset päätökset',
label: 'Ingressi ja toimitukselliset päätökset',
readerValue: 'nopea vastaus siihen mitä tapahtui, miksi sillä on väliä, kuka on vastuussa ja seuraava päivätty laukaisin',
},
'intelligence-assessment.md': {
Expand Down
2 changes: 1 addition & 1 deletion scripts/render-lib/aggregator/reader-guide-i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const CHROME: ReaderGuideChrome = {

export const ENTRIES: Record<string, ReaderGuideEntryI18n> = {
'executive-brief.md': {
label: 'BLUF et décisions éditoriales',
label: 'Chapeau et décisions éditoriales',
readerValue: "réponse rapide sur ce qui s'est passé, pourquoi c'est important, qui est responsable et le prochain déclencheur daté",
},
'intelligence-assessment.md': {
Expand Down
2 changes: 1 addition & 1 deletion scripts/render-lib/aggregator/reader-guide-i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const CHROME: ReaderGuideChrome = {

export const ENTRIES: Record<string, ReaderGuideEntryI18n> = {
'executive-brief.md': {
label: 'BLUFおよび編集方針',
label: 'リード段落と編集方針',
readerValue: '何が起きたか、なぜ重要か、誰が責任を負うか、次の日付付きトリガーへの迅速な回答',
},
'intelligence-assessment.md': {
Expand Down
2 changes: 1 addition & 1 deletion scripts/render-lib/aggregator/reader-guide-i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const CHROME: ReaderGuideChrome = {

export const ENTRIES: Record<string, ReaderGuideEntryI18n> = {
'executive-brief.md': {
label: 'BLUF 및 편집 결정',
label: '리드 문단 및 편집 결정',
readerValue: '무엇이 일어났는지, 왜 중요한지, 누가 책임이 있는지, 다음 날짜 지정 트리거에 대한 빠른 답변',
},
'intelligence-assessment.md': {
Expand Down
2 changes: 1 addition & 1 deletion scripts/render-lib/aggregator/reader-guide-i18n/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const CHROME: ReaderGuideChrome = {

export const ENTRIES: Record<string, ReaderGuideEntryI18n> = {
'executive-brief.md': {
label: 'BLUF en redactionele beslissingen',
label: 'Intro en redactionele beslissingen',
readerValue: 'snel antwoord op wat er gebeurde, waarom het ertoe doet, wie verantwoordelijk is en de volgende gedateerde trigger',
},
'intelligence-assessment.md': {
Expand Down
2 changes: 1 addition & 1 deletion scripts/render-lib/aggregator/reader-guide-i18n/no.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const CHROME: ReaderGuideChrome = {

export const ENTRIES: Record<string, ReaderGuideEntryI18n> = {
'executive-brief.md': {
label: 'BLUF og redaksjonelle beslutninger',
label: 'Ingress og redaksjonelle beslutninger',
readerValue: 'raskt svar på hva som skjedde, hvorfor det betyr noe, hvem som er ansvarlig og neste daterte utløser',
},
'intelligence-assessment.md': {
Expand Down
2 changes: 1 addition & 1 deletion scripts/render-lib/aggregator/reader-guide-i18n/sv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const CHROME: ReaderGuideChrome = {

export const ENTRIES: Record<string, ReaderGuideEntryI18n> = {
'executive-brief.md': {
label: 'BLUF och redaktionella beslut',
label: 'Ingress och redaktionella beslut',
readerValue: 'snabbt svar på vad som hände, varför det spelar roll, vem som är ansvarig och nästa daterade utlösare',
},
'intelligence-assessment.md': {
Expand Down
2 changes: 1 addition & 1 deletion scripts/render-lib/aggregator/reader-guide-i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const CHROME: ReaderGuideChrome = {

export const ENTRIES: Record<string, ReaderGuideEntryI18n> = {
'executive-brief.md': {
label: 'BLUF与编辑决策',
label: '导语与编辑决策',
readerValue: '快速回答发生了什么、为何重要、谁负责以及下一个带日期的触发器',
},
'intelligence-assessment.md': {
Expand Down
2 changes: 1 addition & 1 deletion scripts/render-lib/aggregator/reader-guide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export interface ReaderGuideEntry {
export const READER_GUIDE_ENTRIES: readonly ReaderGuideEntry[] = [
{
file: 'executive-brief.md',
label: 'BLUF and editorial decisions',
label: 'Lede and editorial decisions',
readerValue: 'fast answer to what happened, why it matters, who is accountable, and the next dated trigger',
},
{
Expand Down
Loading
Loading