Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
56c30c5
Initial plan
Copilot Mar 7, 2026
f09a8e5
Add stakeholder SWOT, economic dashboard, newsworthiness scoring, and…
Copilot Mar 7, 2026
5ba775a
Add comprehensive tests for stakeholder SWOT, economic dashboard, and…
Copilot Mar 7, 2026
a62319c
Address code review feedback: extract helpers, fix duplication, impro…
Copilot Mar 7, 2026
721d3f1
Merge branch 'main' into copilot/add-deep-inspection-analysis
pethers Mar 7, 2026
1f7ef7f
Address PR review: fix URL parsing, localize table headers, determini…
Copilot Mar 7, 2026
32c9060
Merge branch 'main' into copilot/add-deep-inspection-analysis
pethers Mar 7, 2026
c5f8320
Use parseArgValue() consistently for all CLI args; make newsworthines…
Copilot Mar 7, 2026
671f395
Address review round 3: deduplicate parseArgValue, sort chart/table d…
Copilot Mar 7, 2026
9c2630a
Fix false-positive 'val' keyword, wire deep-inspection inputs through…
Copilot Mar 7, 2026
eceea2c
Normalize economic dashboard section id/className across data and pla…
Copilot Mar 7, 2026
0fd2b6f
Add deep-inspection to Valid article types list in workflow doc
Copilot Mar 7, 2026
313a154
Add deep-inspection generator path and filter invalid articleTypes
Copilot Mar 7, 2026
a68b47b
Address code review: use 'deep-inspection' content type, deduplicate …
Copilot Mar 7, 2026
38f1b7f
Implement URL-to-dok_id resolution for documentUrls, sanitize focusTo…
Copilot Mar 7, 2026
9ca6a1b
Narrow file extension regex, export sanitizePlainText, add XSS preven…
Copilot Mar 7, 2026
ec97e78
Fix CodeQL alert: multi-pass tag stripping to prevent nested tag reco…
Copilot Mar 7, 2026
7a09060
Fix slug generation: use allDocIds fallback for URL-only targeting, e…
Copilot Mar 7, 2026
fb687ea
Simplify slug doc-id fallback: use RawDocument.dok_id directly instea…
Copilot Mar 7, 2026
471e4b6
fix: localize default topic label and narrow document_urls description
Copilot Mar 7, 2026
978c86c
Throw on all-invalid --types; move defaultTopicLabels/titles outside …
Copilot Mar 7, 2026
e2f89ad
Address review round 12: fix contentData.documents, sanitizePlainText…
Copilot Mar 7, 2026
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
11 changes: 10 additions & 1 deletion .github/workflows/news-article-generator.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 15 additions & 1 deletion .github/workflows/news-article-generator.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
workflow_dispatch:
inputs:
article_types:
description: Comma-separated article types (week-ahead,month-ahead,weekly-review,monthly-review,committee-reports,propositions,motions,breaking). Leave empty for day-of-week schedule.
description: Comma-separated article types (week-ahead,month-ahead,weekly-review,monthly-review,committee-reports,propositions,motions,breaking,deep-inspection). Leave empty for day-of-week schedule.

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The workflow input description now includes deep-inspection, but the later “Valid article types …” line still omits deep-inspection (it currently lists only week-ahead/month-ahead/weekly-review/monthly-review/committee-reports/propositions/motions/breaking). This makes the workflow doc internally inconsistent—please update that later list to include deep-inspection as well (to match scripts/generate-news-enhanced/config.ts:VALID_ARTICLE_TYPES).

Suggested change
description: Comma-separated article types (week-ahead,month-ahead,weekly-review,monthly-review,committee-reports,propositions,motions,breaking,deep-inspection). Leave empty for day-of-week schedule.
description: Comma-separated article types (week-ahead,month-ahead,weekly-review,monthly-review,committee-reports,propositions,motions,breaking). Leave empty for day-of-week schedule.

Copilot uses AI. Check for mistakes.
required: false
force_generation:
description: Force generation even if recent articles exist
Expand All @@ -17,6 +17,15 @@ on:
description: 'Languages to generate (en,sv | nordic | eu-core | all | custom comma-separated)'
required: false
default: all
document_ids:
description: 'Comma-separated Riksdag document IDs for deep-inspection analysis (e.g. H901FiU1,H901JuU25)'
required: false
document_urls:
description: 'Comma-separated URLs to specific documents, press releases, or events for deep analysis'

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

document_urls input is described as supporting “press releases, or events”, but the deep-inspection implementation only resolves dok_id from riksdagen.se / data.riksdagen.se document URL patterns. Either narrow the workflow input description to “document URLs” or extend URL resolution + MCP fetching to actually support press releases/events.

Suggested change
description: 'Comma-separated URLs to specific documents, press releases, or events for deep analysis'
description: 'Comma-separated URLs to specific Riksdag documents for deep analysis'

Copilot uses AI. Check for mistakes.
required: false
focus_topic:
description: 'Specific topic or policy area to focus deep-inspection analysis on (e.g. defence, migration, budget)'
required: false

permissions:
contents: read
Expand Down Expand Up @@ -100,12 +109,17 @@ You are the **News Journalist Agent** for Riksdagsmonitor. Generate high-quality
- **article_types** = `${{ github.event.inputs.article_types }}`
- **force_generation** = `${{ github.event.inputs.force_generation }}`
- **languages** = `${{ github.event.inputs.languages }}`
- **document_ids** = `${{ github.event.inputs.document_ids }}`
- **document_urls** = `${{ github.event.inputs.document_urls }}`
- **focus_topic** = `${{ github.event.inputs.focus_topic }}`

**Rules:**
1. If **article_types** is non-empty, generate ONLY those types. Do NOT fall back to day-of-week schedule.
2. If **article_types** is empty/blank, use day-of-week schedule (see Step 2).
3. If **force_generation** is `true`, generate even if recent articles exist.
4. If **languages** is empty/blank, default to `all` (14 languages).
5. If **article_types** includes `deep-inspection`, use **document_ids**, **document_urls**, and **focus_topic** for targeted deep analysis.
6. For `deep-inspection` type: pass `--document-ids` and `--focus-topic` flags to the generation script.

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deep-inspection workflow instructions mention using document_ids, document_urls, and focus_topic (Rule 5), but Rule 6 only mentions passing --document-ids and --focus-topic to the script. This leaves out --document-urls and may cause users to think URLs are unsupported even though config parsing was added. Update the instructions to include --document-urls (and keep flag formatting consistent with the script’s --flag=value parsing).

Suggested change
6. For `deep-inspection` type: pass `--document-ids` and `--focus-topic` flags to the generation script.
6. For `deep-inspection` type: pass `--document-ids=<value>`, `--document-urls=<value>`, and `--focus-topic=<value>` flags to the generation script.

Copilot uses AI. Check for mistakes.

## ⚠️ NON-NEGOTIABLE RULES

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
/**
* @module data-transformers/content-generators/economic-dashboard-section
* @description Generates an embeddable economic dashboard section powered by
* World Bank data indicators. Automatically selects relevant indicators based
* on detected policy domains in the article content, creating Chart.js
* dashboards with Nordic country comparisons.
*
* Used by agentic workflows and deep inspection analysis to add economic
* context dashboards to articles when policy areas match World Bank indicators.
*
* @author Hack23 AB
* @license Apache-2.0
*/

import { escapeHtml } from '../../html-utils.js';
import type { Language } from '../../types/language.js';
import type {
TemplateSection,
DashboardChartConfig,
DashboardTableConfig,
} from '../../types/article.js';
import {
type EconomicIndicatorContext,
findRelevantIndicators,
getEconomicHeading,
NORDIC_COMPARISON,
} from '../../world-bank-context.js';
import { generateDashboardSection } from './dashboard-section.js';

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/** A World Bank data point for dashboard rendering */
export interface EconomicDataPoint {
/** Country code (e.g. 'SWE', 'DNK') */
countryCode: string;
/** Country name */
countryName: string;
/** Indicator ID */
indicatorId: string;
/** Year or date string */
date: string;
/** Numeric value */
value: number;
}

/** Options for the economic dashboard section generator */
export interface EconomicDashboardOptions {
/** Policy domains detected in the article (e.g. ['fiscal', 'defence']) */
policyDomains: string[];
/** Optional pre-fetched World Bank data points */
dataPoints?: EconomicDataPoint[];
/** Target language */
lang: Language | string;
/** Custom title override */
title?: string;
/** Optional narrative summary */
summary?: string;
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** Sanitize an indicator ID into a safe HTML element id */
function sanitizeChartId(indicatorId: string): string {
return indicatorId.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase();
}

const COUNTRY_COLORS: Readonly<Record<string, string>> = {
SWE: '#00d9ff', // Cyan (primary)
DNK: '#ff006e', // Magenta
NOR: '#ffbe0b', // Yellow
FIN: '#83cf39', // Green
DEU: '#9d4edd', // Purple
};

const COUNTRY_BORDER_COLORS: Readonly<Record<string, string>> = {
SWE: '#00b8d4',
DNK: '#d4004e',
NOR: '#d4a00b',
FIN: '#6daf2d',
DEU: '#7d3ebd',
};

// ---------------------------------------------------------------------------
// Helper functions
// ---------------------------------------------------------------------------

/**
* Find relevant World Bank indicators for a set of policy domains.
*/
export function findIndicatorsForDomains(domains: string[]): EconomicIndicatorContext[] {
const seen = new Set<string>();
const result: EconomicIndicatorContext[] = [];
for (const domain of domains) {
for (const indicator of findRelevantIndicators(domain)) {
if (!seen.has(indicator.indicatorId)) {
seen.add(indicator.indicatorId);
result.push(indicator);
}
}
}
return result;
}

/**
* Build Chart.js chart configurations from economic data points.
* Groups data by indicator and creates bar charts comparing Nordic countries.
*/
export function buildEconomicCharts(
indicators: EconomicIndicatorContext[],
dataPoints: EconomicDataPoint[],
): DashboardChartConfig[] {
const charts: DashboardChartConfig[] = [];

for (const indicator of indicators) {
const points = dataPoints.filter(p => p.indicatorId === indicator.indicatorId);
if (points.length === 0) continue;

// Group by country for comparison chart
const byCountry = new Map<string, EconomicDataPoint[]>();
for (const p of points) {
if (!byCountry.has(p.countryCode)) byCountry.set(p.countryCode, []);
byCountry.get(p.countryCode)!.push(p);
}

// Get latest year's data for comparison bar chart
const latestYear = points.reduce((max, p) => p.date > max ? p.date : max, '');
const latestPoints = points.filter(p => p.date === latestYear);

if (latestPoints.length > 0) {
const labels = latestPoints.map(p =>
NORDIC_COMPARISON.countryNames[p.countryCode] ?? p.countryName
);
const data = latestPoints.map(p => p.value);
const bgColors = latestPoints.map(p => COUNTRY_COLORS[p.countryCode] ?? '#888');
const borderColors = latestPoints.map(p => COUNTRY_BORDER_COLORS[p.countryCode] ?? '#666');

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

latestPoints preserves whatever ordering dataPoints happens to have, so chart labels/bars can appear in an inconsistent order between runs (depending on API response/order of inputs). Consider sorting latestPoints into a stable order (e.g., NORDIC_COMPARISON.countries, or by country name) before building labels/data/colors.

Suggested change
const labels = latestPoints.map(p =>
NORDIC_COMPARISON.countryNames[p.countryCode] ?? p.countryName
);
const data = latestPoints.map(p => p.value);
const bgColors = latestPoints.map(p => COUNTRY_COLORS[p.countryCode] ?? '#888');
const borderColors = latestPoints.map(p => COUNTRY_BORDER_COLORS[p.countryCode] ?? '#666');
// Sort latest points into a stable order for consistent chart rendering
const sortedLatestPoints = [...latestPoints].sort((a, b) => {
const order = NORDIC_COMPARISON.countries;
const indexA = order.indexOf(a.countryCode);
const indexB = order.indexOf(b.countryCode);
const aInOrder = indexA !== -1;
const bInOrder = indexB !== -1;
if (aInOrder && bInOrder) {
return indexA - indexB;
}
if (aInOrder && !bInOrder) {
return -1;
}
if (!aInOrder && bInOrder) {
return 1;
}
return a.countryName.localeCompare(b.countryName);
});
const labels = sortedLatestPoints.map(p =>
NORDIC_COMPARISON.countryNames[p.countryCode] ?? p.countryName
);
const data = sortedLatestPoints.map(p => p.value);
const bgColors = sortedLatestPoints.map(p => COUNTRY_COLORS[p.countryCode] ?? '#888');
const borderColors = sortedLatestPoints.map(p => COUNTRY_BORDER_COLORS[p.countryCode] ?? '#666');

Copilot uses AI. Check for mistakes.

const idBase = sanitizeChartId(indicator.indicatorId);
charts.push({
id: `econ-${idBase}`,
type: 'bar',
title: `${indicator.name} (${latestYear})`,
labels,
datasets: [{
label: `${indicator.name} (${indicator.unit})`,
data,
backgroundColor: bgColors,
borderColor: borderColors,
borderWidth: 1,
}],
});
}

// If we have multi-year data, add a trend line chart
const swedenPoints = (byCountry.get('SWE') ?? []).sort((a, b) => a.date.localeCompare(b.date));
if (swedenPoints.length >= 3) {
const trendLabels = swedenPoints.map(p => p.date);
const trendData = swedenPoints.map(p => p.value);
const idBase = sanitizeChartId(indicator.indicatorId);
charts.push({
id: `econ-trend-${idBase}`,
type: 'line',
title: `Sweden: ${indicator.name} Trend`,
labels: trendLabels,
datasets: [{
label: `Sweden ${indicator.name} (${indicator.unit})`,
data: trendData,
backgroundColor: 'rgba(0, 217, 255, 0.2)',
borderColor: '#00d9ff',
borderWidth: 2,
}],
});
}
}

return charts;
}

/**
* Build data tables from economic data points for accessibility fallback.
*/
export function buildEconomicTables(
indicators: EconomicIndicatorContext[],
dataPoints: EconomicDataPoint[],
): DashboardTableConfig[] {
const tables: DashboardTableConfig[] = [];

for (const indicator of indicators) {
const points = dataPoints.filter(p => p.indicatorId === indicator.indicatorId);
if (points.length === 0) continue;

const latestYear = points.reduce((max, p) => p.date > max ? p.date : max, '');
const latestPoints = points.filter(p => p.date === latestYear);

if (latestPoints.length > 0) {
tables.push({
caption: `${indicator.name} (${latestYear}) — ${indicator.unit}`,
headers: ['Country', indicator.name, 'Unit'],
rows: latestPoints.map(p => [
NORDIC_COMPARISON.countryNames[p.countryCode] ?? p.countryName,
p.value.toFixed(2),
indicator.unit,
]),
Comment on lines +219 to +228

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildEconomicTables() hard-codes English column headers ('Country', 'Unit'). When generating non-English articles, the accessibility table fallback will remain English, which undermines the module’s stated 14-language support. Consider localizing these headers (e.g., via L(lang, ...) with new label keys) or otherwise deriving translated header labels based on lang.

Copilot uses AI. Check for mistakes.
});
Comment on lines +216 to +229

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tables use latestPoints in input order as well, which can produce inconsistent row ordering across runs. Sort latestPoints into a stable order (e.g. NORDIC_COMPARISON.countries or localized country name) before mapping to rows so the table is predictable and easier to scan.

Copilot uses AI. Check for mistakes.
}
}

return tables;
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/**
* Generate an economic dashboard section based on detected policy domains.
*
* Automatically finds relevant World Bank indicators for the given policy
* domains, builds Chart.js charts (comparison + trend), and generates
* accessible data tables.
*
* If no data points are provided but indicators are found, returns a
* placeholder section describing the relevant indicators (agentic workflows
* should fetch the actual data from World Bank API).
*
* @example
* ```ts
* const section = generateEconomicDashboardSection({
* policyDomains: ['fiscal policy', 'defense'],
* dataPoints: worldBankData,
* lang: 'en',
* });
* if (section) {
* articleData.sections = [...(articleData.sections ?? []), section];
* }
* ```
*/
export function generateEconomicDashboardSection(opts: EconomicDashboardOptions): TemplateSection | null {
const { policyDomains, dataPoints, lang } = opts;

// Find relevant indicators
const indicators = findIndicatorsForDomains(policyDomains);
if (indicators.length === 0) return null;

const headingText = opts.title?.trim() || getEconomicHeading(lang, 'economicContext');

// If we have actual data points, build charts
if (dataPoints && dataPoints.length > 0) {
const charts = buildEconomicCharts(indicators, dataPoints);
const tables = buildEconomicTables(indicators, dataPoints);

if (charts.length === 0 && tables.length === 0) return null;

return generateDashboardSection({
data: {
title: headingText,
summary: opts.summary?.trim() || undefined,
charts,
tables: tables.length > 0 ? tables : undefined,
},
lang,
});
}
Comment on lines +263 to +296

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generateEconomicDashboardSection() returns different TemplateSection.id/className depending on whether dataPoints are provided: placeholder mode returns economic-dashboard / economic-dashboard-section, but data mode delegates to generateDashboardSection() which returns article-dashboard / article-dashboard-section. This makes the API surprising and can cause DOM id collisions or styling/anchor inconsistencies. Consider normalizing the returned section metadata (e.g., wrap or clone the delegated section and override id/className consistently for the economic dashboard).

Copilot uses AI. Check for mistakes.

// No data points: generate a placeholder section describing relevant indicators
const indicatorItems = indicators.map(ind =>
` <li><strong>${escapeHtml(ind.name)}</strong> (${escapeHtml(ind.unit)}): ${escapeHtml(ind.description)}</li>`
).join('\n');

const policyLabel = getEconomicHeading(lang, 'policyImplications');
const html = `<section class="economic-dashboard-placeholder" aria-label="${escapeHtml(headingText)}">
<h2>${escapeHtml(headingText)}</h2>
<p>${escapeHtml(policyLabel)}</p>

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The placeholder section renders policyImplications heading text inside a <p> (e.g. just “Policy Implications”), which reads like a dangling label rather than explanatory text. Consider replacing it with a short localized explanatory sentence (or render it as a subheading) so the placeholder is meaningful to readers.

Suggested change
<p>${escapeHtml(policyLabel)}</p>
<h3>${escapeHtml(policyLabel)}</h3>

Copilot uses AI. Check for mistakes.
<ul class="economic-indicators-list">
${indicatorItems}
</ul>
</section>`;

return {
id: 'economic-dashboard',
html,
className: 'economic-dashboard-section',
};
}
9 changes: 9 additions & 0 deletions scripts/data-transformers/content-generators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
* - **month-ahead** — generateMonthAheadContent
* - **swot-section** — generateSwotSection (embeddable SWOT analysis)
* - **dashboard-section** — generateDashboardSection (embeddable Chart.js dashboard)
* - **stakeholder-swot-section** — generateStakeholderSwotSection (multi-stakeholder SWOT)
* - **economic-dashboard-section** — generateEconomicDashboardSection (World Bank dashboards)
* - **newsworthiness** — scoreNewsworthiness (strategic content detection)
*
* @author Hack23 AB
* @license Apache-2.0
Expand All @@ -30,3 +33,9 @@ export { generateSwotSection } from './swot-section.js';
export type { SwotSectionOptions } from './swot-section.js';
export { generateDashboardSection } from './dashboard-section.js';
export type { DashboardSectionOptions } from './dashboard-section.js';
export { generateStakeholderSwotSection } from './stakeholder-swot-section.js';
export type { StakeholderSwotSectionOptions, StakeholderSwot } from './stakeholder-swot-section.js';
export { generateEconomicDashboardSection, findIndicatorsForDomains, buildEconomicCharts, buildEconomicTables } from './economic-dashboard-section.js';
export type { EconomicDashboardOptions, EconomicDataPoint } from './economic-dashboard-section.js';
export { scoreNewsworthiness } from './newsworthiness.js';
export type { NewsworthinessScore, NewsworthinessDimension } from './newsworthiness.js';
Comment on lines +36 to +41

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file exports the new generators/types, but the stable barrel (scripts/data-transformers/content-generators.ts, which is what scripts/data-transformers/index.ts re-exports) still only re-exports generateSwotSection/generateDashboardSection. As a result, consumers importing from ../data-transformers.js or ./content-generators.js won’t be able to access generateStakeholderSwotSection, generateEconomicDashboardSection, or scoreNewsworthiness. Add the corresponding exports/types to the stable barrel so the public API matches the documentation here.

Copilot uses AI. Check for mistakes.
Loading
Loading