diff --git a/.github/workflows/news-monthly-review.md b/.github/workflows/news-monthly-review.md index e5033c0761..2bd37acd11 100644 --- a/.github/workflows/news-monthly-review.md +++ b/.github/workflows/news-monthly-review.md @@ -746,12 +746,26 @@ EN/SV only: all headings, meta, content in correct language; no untranslated `da ## Step 3d: Economic Commentary (MANDATORY) -> After Step 3c and **before** calling `safeoutputs.create_pull_request`, re-open `economic-data.json` and replace the placeholder `commentary` string with a 2–4 sentence paragraph that: -> - cites **2–3 concrete numeric values** from `dataPoints`; -> - ties the numbers to the day's political developments (not definitions of indicators); +> After Step 3c and **before** calling `safeoutputs.create_pull_request`, re-open `economic-data.json` and replace the placeholder `commentary` string with a **6–8 sentence paragraph of ≥200 words** (enforced by `scripts/validate-economic-context.ts` — `monthly-review` = 200) that: +> - cites **≥4 concrete numeric values** from `dataPoints` (month-on-month or year-over-year changes, Nordic comparison, primary-indicator trajectory); +> - ties the numbers to the month's political developments (not definitions of indicators); > - is written in plain English (translations are produced downstream by `news-translate`); > - meets the minimum word count in the coverage matrix for this article type. > > Banned phrasings (the multi-dim quality score flags these): "The political landscape remains fluid…", "Touches on X policy…", pure indicator definitions. > +> **Sankey / flow diagram** (required for `monthly-review`): `scripts/generate-news-enhanced/generators.ts` calls `buildArticleVisualizationSections` with `alwaysEmit: true` for this article type, so `class="sankey-section"` is auto-appended whenever the month has at least **one** document — even when every document collapses into a single doc-type bucket. The only case where no Sankey is emitted is an empty month (`docs.length === 0`); in that edge case the visualization builder returns an empty section list. The AI writer does not need to emit Sankey HTML directly — just verify the generated HTML contains `class="sankey-section"` before opening the PR: +> ```bash +> if grep -l 'class="sankey-section"' news/$ARTICLE_DATE-monthly-review-*.html; then +> echo "✅ Sankey section present" +> else +> doc_count=$(find "analysis/daily/$ARTICLE_DATE/monthly-review/documents" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l) +> if [ "$doc_count" = "0" ]; then +> echo "ℹ️ Sankey section not emitted — the month has 0 documents (validator allows this)" +> else +> echo "❌ Sankey section missing — the validator will block the PR"; exit 1 +> fi +> fi +> ``` +> > Full rules: [`.github/aw/ECONOMIC_DATA_CONTRACT.md`](../aw/ECONOMIC_DATA_CONTRACT.md) §"Writing the AI commentary — workflow Step 3d". diff --git a/.github/workflows/news-weekly-review.md b/.github/workflows/news-weekly-review.md index 3f8305b567..7be3ae9cf7 100644 --- a/.github/workflows/news-weekly-review.md +++ b/.github/workflows/news-weekly-review.md @@ -737,12 +737,26 @@ EN/SV only: all headings, meta, content in correct language; no untranslated `da ## Step 3d: Economic Commentary (MANDATORY) -> After Step 3c and **before** calling `safeoutputs.create_pull_request`, re-open `economic-data.json` and replace the placeholder `commentary` string with a 2–4 sentence paragraph that: -> - cites **2–3 concrete numeric values** from `dataPoints`; -> - ties the numbers to the day's political developments (not definitions of indicators); +> After Step 3c and **before** calling `safeoutputs.create_pull_request`, re-open `economic-data.json` and replace the placeholder `commentary` string with a **4–6 sentence paragraph of ≥150 words** (enforced by `scripts/validate-economic-context.ts` — `weekly-review` = 150, `monthly-review` = 200) that: +> - cites **≥3 concrete numeric values** from `dataPoints` (e.g. Nordic GDP comparison + Swedish unemployment trajectory); +> - ties the numbers to the week's political developments (not definitions of indicators); > - is written in plain English (translations are produced downstream by `news-translate`); > - meets the minimum word count in the coverage matrix for this article type. > > Banned phrasings (the multi-dim quality score flags these): "The political landscape remains fluid…", "Touches on X policy…", pure indicator definitions. > +> **Sankey / flow diagram** (required for `weekly-review`): `scripts/generate-news-enhanced/generators.ts` calls `buildArticleVisualizationSections` with `alwaysEmit: true` for this article type, so `class="sankey-section"` is auto-appended whenever the week has at least **one** document — even when every document collapses into a single doc-type bucket. The only case where no Sankey is emitted is an empty week (`docs.length === 0`); in that edge case the visualization builder returns an empty section list. The AI writer does not need to emit Sankey HTML directly — just verify the generated HTML contains `class="sankey-section"` before opening the PR: +> ```bash +> if grep -l 'class="sankey-section"' news/$ARTICLE_DATE-weekly-review-*.html; then +> echo "✅ Sankey section present" +> else +> doc_count=$(find "analysis/daily/$ARTICLE_DATE/weekly-review/documents" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l) +> if [ "$doc_count" = "0" ]; then +> echo "ℹ️ Sankey section not emitted — the week has 0 documents (validator allows this)" +> else +> echo "❌ Sankey section missing — the validator will block the PR"; exit 1 +> fi +> fi +> ``` +> > Full rules: [`.github/aw/ECONOMIC_DATA_CONTRACT.md`](../aw/ECONOMIC_DATA_CONTRACT.md) §"Writing the AI commentary — workflow Step 3d". diff --git a/analysis/daily/2026-04-18/weekly-review/economic-data.json b/analysis/daily/2026-04-18/weekly-review/economic-data.json index ca3d32e04d..836641bcd8 100644 --- a/analysis/daily/2026-04-18/weekly-review/economic-data.json +++ b/analysis/daily/2026-04-18/weekly-review/economic-data.json @@ -25,7 +25,7 @@ { "countryCode": "SWE", "countryName": "Sweden", "indicatorId": "SL.UEM.TOTL.ZS", "indicatorName": "Unemployment Rate (%)", "date": "2022", "value": 7.417 }, { "countryCode": "SWE", "countryName": "Sweden", "indicatorId": "SL.UEM.TOTL.ZS", "indicatorName": "Unemployment Rate (%)", "date": "2021", "value": 8.803 } ], - "commentary": "Sweden's 2026 Spring Budget arrives against a challenging backdrop: GDP growth of just 0.82% in 2024 — sharply trailing Denmark's 3.5% and Norway's 2.1% — while unemployment climbed to 8.7% in 2025, the highest since the pandemic. The government's Extra Amendment Budget cutting fuel taxes by 82 öre per litre directly targets the household cost pressure that has driven consumer confidence down, but the fuel-tax intervention risks undermining Sweden's green transition credibility at precisely the moment when the electricity system reform (Prop. 240) and wind power revenue-sharing (Prop. 239) signal serious climate ambition. The fiscal arithmetic is tight: Sweden recovered from its 2023 GDP contraction (-0.20%), but the pace of recovery lags Nordic peers, giving the Social Democrat opposition ammunition to challenge the government's economic stewardship heading into the September 2026 election.", + "commentary": "Sweden's 2026 Spring Budget arrives against a challenging backdrop: GDP growth of just 0.82% in 2024 — sharply trailing Denmark's 3.5% and Norway's 2.1% — while unemployment climbed to 8.7% in 2025, the highest since the pandemic. The government's Extra Amendment Budget cutting fuel taxes by 82 öre per litre directly targets the household cost pressure that has driven consumer confidence down, but the fuel-tax intervention risks undermining Sweden's green transition credibility at precisely the moment when the electricity system reform (Prop. 240) and wind power revenue-sharing (Prop. 239) signal serious climate ambition. The fiscal arithmetic is tight: Sweden recovered from its 2023 GDP contraction (-0.20%), but the pace of recovery lags Nordic peers, giving the Social Democrat opposition ammunition to challenge the government's economic stewardship heading into the September 2026 election. Finland's 0.42% 2024 growth further isolates Denmark (3.48%) and Norway (2.10%) as the Nordic outperformers, a comparison the Tidö coalition must explain as households face a fourth consecutive year above 7.4% unemployment. Labor-market data show the jobless rate falling from 8.8% (2021) through 7.4% (2022) before reversing to 7.6% (2023), 8.4% (2024) and 8.7% (2025) — a 1.3-percentage-point deterioration across two years that directly erodes the coalition's 2022 jobs mandate and reframes the Spring Budget debate around household resilience rather than reform ambition.", "charts": [ { "type": "nordic-comparison-bar", diff --git a/news/2026-04-18-weekly-review-en.html b/news/2026-04-18-weekly-review-en.html index ed5fc8d023..97b261c2a1 100644 --- a/news/2026-04-18-weekly-review-en.html +++ b/news/2026-04-18-weekly-review-en.html @@ -967,7 +967,7 @@

Voting Alignment by Issue

Economic Context

-

Sweden's 2026 Spring Budget arrives against a challenging backdrop: GDP growth of just 0.82% in 2024 — sharply trailing Denmark's 3.5% and Norway's 2.1% — while unemployment climbed to 8.7% in 2025, the highest since the pandemic. The government's Extra Amendment Budget cutting fuel taxes by 82 öre per litre directly targets the household cost pressure that has driven consumer confidence down, but the fuel-tax intervention risks undermining Sweden's green transition credibility at precisely the moment when the electricity system reform (Prop. 240) and wind power revenue-sharing (Prop. 239) signal serious climate ambition. The fiscal arithmetic is tight: Sweden recovered from its 2023 GDP contraction (-0.20%), but the pace of recovery lags Nordic peers, giving the Social Democrat opposition ammunition to challenge the government's economic stewardship heading into the September 2026 election.

+

Sweden's 2026 Spring Budget arrives against a challenging backdrop: GDP growth of just 0.82% in 2024 — sharply trailing Denmark's 3.5% and Norway's 2.1% — while unemployment climbed to 8.7% in 2025, the highest since the pandemic. The government's Extra Amendment Budget cutting fuel taxes by 82 öre per litre directly targets the household cost pressure that has driven consumer confidence down, but the fuel-tax intervention risks undermining Sweden's green transition credibility at precisely the moment when the electricity system reform (Prop. 240) and wind power revenue-sharing (Prop. 239) signal serious climate ambition. The fiscal arithmetic is tight: Sweden recovered from its 2023 GDP contraction (-0.20%), but the pace of recovery lags Nordic peers, giving the Social Democrat opposition ammunition to challenge the government's economic stewardship heading into the September 2026 election. Finland's 0.42% 2024 growth further isolates Denmark (3.48%) and Norway (2.10%) as the Nordic outperformers, a comparison the Tidö coalition must explain as households face a fourth consecutive year above 7.4% unemployment. Labor-market data show the jobless rate falling from 8.8% (2021) through 7.4% (2022) before reversing to 7.6% (2023), 8.4% (2024) and 8.7% (2025) — a 1.3-percentage-point deterioration across two years that directly erodes the coalition's 2022 jobs mandate and reframes the Spring Budget debate around household resilience rather than reform ambition.

@@ -1000,10 +1000,75 @@

Economic Context

+
+

Legislative Flow — Riksdag Week 16

+

Flow of Week 16 (2026-04-11 → 2026-04-17) Riksdag output from initiating actors to document types.

+
+ + Legislative Flow — Riksdag Week 16 + + + Government Coalition (M/KD/L/SD) + + Foreign Ministry (UD) + + Riksdag Committees (KU/JuU/UFöU) + + Opposition (S/V/MP/C) + + Propositions (HD03100/99/236) + + Treaty Bills (HD03231/232) + + Committee Reports (KU32/33, JuU15, UFöU3) + + Opposition Motions + + 3 propositions + + 2 treaty bills + + 4 committee reports + + Opposition motions + +
+ + + + + + + + + +
Riksdag Week 16 legislative flow
SourceTargetValueNote
Government CoalitionPropositions3HD03100 Vårpropositionen, HD0399 Vårändringsbudgeten, HD03236 Extra ändringsbudget
Foreign MinistryTreaty Bills2HD03231 Ukraine Tribunal, HD03232 International Compensation Commission
Riksdag CommitteesCommittee Reports4KU32, KU33, JuU15 (145–142), UFöU3
Opposition (S/V/MP/C)Opposition Motions1Counter-motions filed against Spring Budget trilogy
+
+ - -

📊 Analysis & Sources

This article is based on AI-driven political intelligence analysis. Full methodology and analysis files:

diff --git a/scripts/generate-news-enhanced/generators.ts b/scripts/generate-news-enhanced/generators.ts index 6611216f58..782c8391b4 100644 --- a/scripts/generate-news-enhanced/generators.ts +++ b/scripts/generate-news-enhanced/generators.ts @@ -410,13 +410,194 @@ export function buildAnalysisEnrichmentSections( // --------------------------------------------------------------------------- /** - * Build SWOT, dashboard, and economic TemplateSections for standard article - * types (not deep-inspection, which has its own richer builder). + * Article types that MUST carry an inline Sankey flow diagram to satisfy the + * economic-data contract (`scripts/validate-economic-context.ts` → + * `COVERAGE_MATRIX[*].requiresD3`). Kept here (and not imported) because + * the generator ships in `dist/lib/` while the validator lives in + * `scripts/`, and we only need the article-type identifiers. + */ +const REVIEW_ARTICLE_TYPES_REQUIRING_SANKEY: ReadonlySet = new Set([ + 'weekly-review', + 'monthly-review', +]); + +/** Mapping from doc-type bucket key to Sankey node color / source actor. */ +const SANKEY_DOC_TYPE_SPEC: ReadonlyArray<{ + /** Bucket key as produced by {@link effectiveType}. */ + bucket: string; + /** Node id in the Sankey. */ + nodeId: string; + /** Doc-type code passed to {@link docTypeLabel} for localization. */ + localizeKey: string; + /** Semantic node color. */ + color: SankeyNode['color']; + /** Originating actor node id. */ + source: 'gov' | 'opp' | 'pvt'; +}> = [ + { bucket: 'prop', nodeId: 'prop', localizeKey: 'prop', color: 'orange', source: 'gov' }, + { bucket: 'bet', nodeId: 'bet', localizeKey: 'bet', color: 'blue', source: 'opp' }, + { bucket: 'mot', nodeId: 'mot', localizeKey: 'mot', color: 'yellow', source: 'opp' }, + { bucket: 'sfs', nodeId: 'sfs', localizeKey: 'sfs', color: 'green', source: 'gov' }, + { bucket: 'skr', nodeId: 'skr', localizeKey: 'skr', color: 'green', source: 'gov' }, + // EU bucket aggregates 'fpm' + 'eu' raw types; use 'fpm' for localization + // (DOC_TYPE_DISPLAY has a localized entry for 'fpm' — EU-facing docs). + { bucket: 'eu', nodeId: 'eu', localizeKey: 'fpm', color: 'blue', source: 'gov' }, + { bucket: 'pressm', nodeId: 'pressm', localizeKey: 'pressm', color: 'orange', source: 'gov' }, + { bucket: 'ext', nodeId: 'ext', localizeKey: 'ext', color: 'purple', source: 'pvt' }, + { bucket: 'other', nodeId: 'other', localizeKey: 'other', color: 'purple', source: 'pvt' }, +]; + +/** Raw bucket keys that are merged into the aggregate `eu` bucket. */ +const SANKEY_EU_RAW_TYPES = ['fpm', 'eu'] as const; + +/** + * Bucket a set of documents by {@link effectiveType} and collapse EU-related + * raw types (`fpm`, `eu`) into a single `eu` bucket. Every raw type not + * explicitly classified (i.e. not in the known bucket list) falls into + * `other`. Exposed to both the review and deep-inspection Sankey builders + * so we have exactly one source of truth for actor → doc-type semantics. + */ +function bucketDocsForSankey(docs: RawDocument[]): Map { + const buckets = new Map(); + const push = (key: string, d: RawDocument): void => { + let arr = buckets.get(key); + if (!arr) { arr = []; buckets.set(key, arr); } + arr.push(d); + }; + + const known = new Set(SANKEY_DOC_TYPE_SPEC.map(s => s.bucket)); + for (const d of docs) { + const raw = effectiveType(d); + const collapsed = (SANKEY_EU_RAW_TYPES as readonly string[]).includes(raw) + ? 'eu' + : raw; + if (known.has(collapsed)) push(collapsed, d); + else push('other', d); + } + return buckets; +} + +/** + * Build the shared Sankey node / flow arrays for a set of documents. The + * nodes/flows honour actor semantics used by both the review-article and + * deep-inspection generators: government coalition initiates propositions, + * laws, government communications, EU positions, and press releases; the + * opposition initiates committee reports and motions; private sector / + * external actors carry external references and uncategorised documents. + * + * All doc-type node labels are localized via {@link docTypeLabel}. The + * stakeholder node labels fall back to English when a language is missing + * from {@link AI_STAKEHOLDER_NAMES}. + */ +function buildLegislativeSankeyNodesAndFlows( + docs: RawDocument[], + lang: Language, +): { nodes: SankeyNode[]; flows: SankeyFlow[] } { + const buckets = bucketDocsForSankey(docs); + + const govName = AI_STAKEHOLDER_NAMES['government-coalition'][lang] ?? AI_STAKEHOLDER_NAMES['government-coalition'].en; + const oppName = AI_STAKEHOLDER_NAMES['opposition'][lang] ?? AI_STAKEHOLDER_NAMES['opposition'].en; + const privateName = AI_STAKEHOLDER_NAMES['private-sector'][lang] ?? AI_STAKEHOLDER_NAMES['private-sector'].en; + + const nodes: SankeyNode[] = [ + { id: 'gov', label: govName, color: 'cyan' }, + { id: 'opp', label: oppName, color: 'magenta' }, + { id: 'pvt', label: privateName, color: 'purple' }, + ]; + const flows: SankeyFlow[] = []; + + for (const spec of SANKEY_DOC_TYPE_SPEC) { + const bucketDocs = buckets.get(spec.bucket) ?? []; + if (bucketDocs.length === 0) continue; + nodes.push({ + id: spec.nodeId, + label: docTypeLabel(spec.localizeKey, lang, bucketDocs.length), + color: spec.color, + }); + flows.push({ + source: spec.source, + target: spec.nodeId, + value: bucketDocs.length, + label: `${bucketDocs.length}`, + }); + } + + return { nodes, flows }; +} + +/** Options accepted by {@link buildLegislativeSankeySection}. */ +interface BuildLegislativeSankeyOptions { + /** + * When true (review article types), emit the Sankey even when only a + * single doc-type flow can be derived. Deep-inspection leaves this + * `undefined` and keeps the legacy "uninformative single-flow" guard. + * + * Because {@link bucketDocsForSankey} funnels any unknown raw type into + * the `other` bucket, `docs.length > 0` always yields at least one flow + * — so `alwaysEmit: true` effectively means "emit whenever `docs.length + * >= 1`", and no extra placeholder is needed. + */ + alwaysEmit?: boolean; +} + +/** + * Build a legislative-flow Sankey TemplateSection from a set of documents. + * Returns null when `docs` is empty, or when fewer than two non-trivial + * flows can be derived **and** `alwaysEmit` is not set (letting callers + * decide whether to append a single-flow section). + * + * {@link bucketDocsForSankey} guarantees every non-empty `docs` yields at + * least one flow (unknown raw types fall into the `other` bucket), so + * `alwaysEmit: true` is sufficient for the validator's `requiresD3: true` + * review article types — the Sankey never silently disappears for weeks / + * months that happen to contain only one doc-type. + */ +function buildLegislativeSankeySection( + docs: RawDocument[], + topic: string | null, + lang: Language, + opts: BuildLegislativeSankeyOptions = {}, +): TemplateSection | null { + if (docs.length === 0) return null; + + const { nodes, flows } = buildLegislativeSankeyNodesAndFlows(docs, lang); + + if (flows.length < 2 && !opts.alwaysEmit) return null; + + // Only override the localized defaults emitted by `generateSankeySection` + // when a topic is supplied (topic text is content-specific and is + // typically English) or when the article is English. For every other + // language, fall through to the generator's `SECTION_TITLES[lang]` so + // the Sankey heading renders in the target language. + const shouldOverrideCopy = lang === 'en' || Boolean(topic); + return generateSankeySection({ + nodes, + flows, + lang, + ...(shouldOverrideCopy + ? { + title: topic ? `Legislative Flow — ${topic}` : 'Legislative Flow', + summary: `Flow of ${docs.length} parliamentary documents from initiating actors to document types`, + } + : {}), + }); +} + +/** + * Build SWOT, dashboard, Sankey, and economic TemplateSections for + * standard article types (not deep-inspection, which has its own richer + * builder). * - * Produces 1–3 sections depending on available data: - * - SWOT stakeholder analysis (always, when docs.length >= 2) + * Produces 1–4 sections depending on available data: + * - SWOT stakeholder analysis (when docs.length >= 2) * - Chart.js dashboard with document type breakdown (when docs.length >= 3) - * - Economic dashboard (when policyDomains match World Bank indicators) + * - Legislative-flow Sankey (when `articleType` requires D3 in the economic + * data contract — `weekly-review`, `monthly-review`; these article types + * pass `alwaysEmit: true` so a Sankey is produced whenever `docs.length + * >= 1`, even when only a single doc-type flow can be derived) + * - Economic dashboard (when policyDomains match World Bank indicators — + * emits when `docs.length >= 1` and either an `economic-data.json` + * artefact is present or domains can be detected from the documents) * * Each section is safe to append to `generateArticleHTML({ sections })`. */ @@ -427,20 +608,30 @@ export function buildArticleVisualizationSections( context?: { date?: string; articleType?: string }, ): TemplateSection[] { const sections: TemplateSection[] = []; - if (docs.length < 2) return sections; - - try { - // ── 1. SWOT stakeholder analysis ────────────────────────────────────── - const stakeholders = buildAISwotStakeholders(docs, topic ?? '', lang); - if (stakeholders.length > 0) { - const swotSection = generateStakeholderSwotSection({ stakeholders, lang }); - sections.push(swotSection); - } - } catch { /* graceful degradation */ } + // Nothing to visualize at all when no documents are present. + if (docs.length === 0) return sections; + + // SWOT and the multi-chart dashboard need a minimum sample size to be + // meaningful, but the Sankey (review types) and economic dashboard are + // driven by the economic-data contract and must still emit for sparse + // weeks/months with as few as one document. + const enoughForSwot = docs.length >= 2; + const enoughForDashboard = docs.length >= 3; + + if (enoughForSwot) { + try { + // ── 1. SWOT stakeholder analysis ──────────────────────────────────── + const stakeholders = buildAISwotStakeholders(docs, topic ?? '', lang); + if (stakeholders.length > 0) { + const swotSection = generateStakeholderSwotSection({ stakeholders, lang }); + sections.push(swotSection); + } + } catch { /* graceful degradation */ } + } - try { - // ── 2. Chart.js dashboard (doc-type breakdown + AI analysis) ────────── - if (docs.length >= 3) { + if (enoughForDashboard) { + try { + // ── 2. Chart.js dashboard (doc-type breakdown + AI analysis) ──────── const dashboardAnalysis = analyzeDashboardData(docs, topic ?? '', lang); if (dashboardAnalysis.charts.length > 0 || dashboardAnalysis.tables.length > 0) { const dashboardSection = generateDashboardSection({ @@ -454,6 +645,23 @@ export function buildArticleVisualizationSections( }); sections.push(dashboardSection); } + } catch { /* graceful degradation */ } + } + + try { + // ── 2b. Legislative-flow Sankey for retrospective reviews ───────────── + // The economic-data contract marks `weekly-review` and `monthly-review` + // as `requiresD3: true`, so emit an inline SVG Sankey whenever the + // article belongs to one of those types — regardless of whether the + // week/month collapsed to a single doc-type. Deep-inspection has its + // own richer Sankey inside `buildDeepInspectionSections`, so we skip + // it here to avoid duplicates. + if ( + context?.articleType && + REVIEW_ARTICLE_TYPES_REQUIRING_SANKEY.has(context.articleType) + ) { + const sankeySection = buildLegislativeSankeySection(docs, topic, lang, { alwaysEmit: true }); + if (sankeySection) sections.push(sankeySection); } } catch { /* graceful degradation */ } @@ -1969,29 +2177,10 @@ function buildDeepInspectionSections( ): TemplateSection[] { if (docs.length === 0) return []; - // Single-pass classification: bucket docs by effectiveType() to avoid N×filter passes. - // EU docs use both 'fpm' and 'eu' raw types; effectiveType() preserves the raw value, - // so we merge both into the euDocs bucket below. - const buckets = new Map(); - for (const d of docs) { - const t = effectiveType(d); - let arr = buckets.get(t); - if (!arr) { arr = []; buckets.set(t, arr); } - arr.push(d); - } - const propDocs = buckets.get('prop') ?? []; - const betDocs = buckets.get('bet') ?? []; - const motDocs = buckets.get('mot') ?? []; - const skrDocs = buckets.get('skr') ?? []; - const sfsDocs = buckets.get('sfs') ?? []; - const euDocs = [...(buckets.get('fpm') ?? []), ...(buckets.get('eu') ?? [])]; - const pressmDocs = buckets.get('pressm') ?? []; - const extDocs = buckets.get('ext') ?? []; - // classifiedTypes must mirror every bucket key consumed above (including both EU keys) - const classifiedTypes = new Set(['prop','bet','mot','skr','sfs','fpm','eu','pressm','ext']); - const otherDocs = [...buckets.entries()] - .filter(([k]) => !classifiedTypes.has(k)) - .flatMap(([, v]) => v); + // The deep-inspection Sankey is delegated to + // `buildLegislativeSankeySection`, which shares bucket + node/flow + // construction with the review-article path (see `SANKEY_DOC_TYPE_SPEC` + // and `buildLegislativeSankeyNodesAndFlows`). No bucketing happens here. // ── 6-stakeholder SWOT ─────────────────────────────────────────────────── const stakeholders = buildAISwotStakeholders(docs, topic ?? '', lang); @@ -2001,11 +2190,6 @@ function buildDeepInspectionSections( : `Multi-stakeholder analysis of ${docs.length} parliamentary documents`; const swotSection = generateStakeholderSwotSection({ stakeholders, lang, strategicContext }); - // ── Localised names for mindmap/sankey labels - const govName = AI_STAKEHOLDER_NAMES['government-coalition'][lang] ?? AI_STAKEHOLDER_NAMES['government-coalition'].en; - const oppName = AI_STAKEHOLDER_NAMES['opposition'][lang] ?? AI_STAKEHOLDER_NAMES['opposition'].en; - const privateName = AI_STAKEHOLDER_NAMES['private-sector'][lang] ?? AI_STAKEHOLDER_NAMES['private-sector'].en; - // ── Multi-chart dashboard ───────────────────────────────────────────────── // Produces 3 chart types (radar, scatter, bar) with accessible data tables. const dashboardAnalysis = analyzeDashboardData(docs, topic ?? '', lang); @@ -2079,6 +2263,7 @@ function buildDeepInspectionSections( ); // ── Sankey: party/doc-type flow → legislative outcome ───────────────────── + // Shared with the review-article builder via `buildLegislativeSankeySection`. // The sankey uses three primary legislative actor groups as source nodes: // - government: initiates propositions, laws, gov. communications, press releases, // and EU position papers (fpm) — these originate from government ministries @@ -2087,61 +2272,10 @@ function buildDeepInspectionSections( // and other document types // Additional SWOT stakeholders (civil society, citizens, etc.) are // analysis perspectives rather than document-originating actors. - const sankeyNodes: SankeyNode[] = [ - { id: 'gov', label: govName, color: 'cyan' }, - { id: 'opp', label: oppName, color: 'magenta' }, - { id: 'pvt', label: privateName, color: 'purple' }, - ]; - - // Add document type nodes and target outcome nodes - const sankeyFlows: SankeyFlow[] = []; - if (propDocs.length > 0) { - sankeyNodes.push({ id: 'prop', label: 'Propositions', color: 'orange' }); - sankeyFlows.push({ source: 'gov', target: 'prop', value: propDocs.length, label: `${propDocs.length}` }); - } - if (betDocs.length > 0) { - sankeyNodes.push({ id: 'bet', label: 'Committee Reports', color: 'blue' }); - sankeyFlows.push({ source: 'opp', target: 'bet', value: betDocs.length, label: `${betDocs.length}` }); - } - if (motDocs.length > 0) { - sankeyNodes.push({ id: 'mot', label: 'Motions', color: 'yellow' }); - sankeyFlows.push({ source: 'opp', target: 'mot', value: motDocs.length, label: `${motDocs.length}` }); - } - if (sfsDocs.length > 0) { - sankeyNodes.push({ id: 'sfs', label: 'Laws (SFS)', color: 'green' }); - sankeyFlows.push({ source: 'gov', target: 'sfs', value: sfsDocs.length, label: `${sfsDocs.length}` }); - } - if (skrDocs.length > 0) { - sankeyNodes.push({ id: 'skr', label: deepLabel('govCommunications', lang), color: 'green' }); - sankeyFlows.push({ source: 'gov', target: 'skr', value: skrDocs.length, label: `${skrDocs.length}` }); - } - if (euDocs.length > 0) { - sankeyNodes.push({ id: 'eu', label: 'EU Positions', color: 'blue' }); - sankeyFlows.push({ source: 'gov', target: 'eu', value: euDocs.length, label: `${euDocs.length}` }); - } - if (pressmDocs.length > 0) { - sankeyNodes.push({ id: 'pressm', label: 'Press Releases', color: 'orange' }); - sankeyFlows.push({ source: 'gov', target: 'pressm', value: pressmDocs.length, label: `${pressmDocs.length}` }); - } - if (extDocs.length > 0) { - sankeyNodes.push({ id: 'ext', label: 'External / Reference', color: 'purple' }); - sankeyFlows.push({ source: 'pvt', target: 'ext', value: extDocs.length, label: `${extDocs.length}` }); - } - if (otherDocs.length > 0) { - sankeyNodes.push({ id: 'other', label: 'Other Docs', color: 'purple' }); - sankeyFlows.push({ source: 'pvt', target: 'other', value: otherDocs.length, label: `${otherDocs.length}` }); - } - - // Only include Sankey when there is more than one non-trivial flow (otherwise uninformative) - const sankeySection: TemplateSection | null = sankeyFlows.length >= 2 - ? generateSankeySection({ - nodes: sankeyNodes, - flows: sankeyFlows, - lang, - title: topic ? `Legislative Flow — ${topic}` : 'Legislative Flow', - summary: `Flow of ${docs.length} parliamentary documents from initiating actors to document types`, - }) - : null; + // Deep-inspection keeps the legacy "require at least 2 flows" guard so + // single-doc-type articles don't show an uninformative Sankey (the + // review-article call site opts into `alwaysEmit: true`). + const sankeySection: TemplateSection | null = buildLegislativeSankeySection(docs, topic, lang); // ── World Bank / Economic Dashboard ────────────────────────────────────── // Prefer the agentic-workflow-supplied `economic-data.json` artefact so