Skip to content
Draft
Show file tree
Hide file tree
Changes from 8 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
798 changes: 746 additions & 52 deletions news/index.html

Large diffs are not rendered by default.

942 changes: 935 additions & 7 deletions news/index_ar.html

Large diffs are not rendered by default.

934 changes: 931 additions & 3 deletions news/index_da.html

Large diffs are not rendered by default.

942 changes: 935 additions & 7 deletions news/index_de.html

Large diffs are not rendered by default.

942 changes: 935 additions & 7 deletions news/index_es.html

Large diffs are not rendered by default.

942 changes: 935 additions & 7 deletions news/index_fi.html

Large diffs are not rendered by default.

942 changes: 935 additions & 7 deletions news/index_fr.html

Large diffs are not rendered by default.

942 changes: 935 additions & 7 deletions news/index_he.html

Large diffs are not rendered by default.

942 changes: 935 additions & 7 deletions news/index_ja.html

Large diffs are not rendered by default.

942 changes: 935 additions & 7 deletions news/index_ko.html

Large diffs are not rendered by default.

942 changes: 935 additions & 7 deletions news/index_nl.html

Large diffs are not rendered by default.

934 changes: 931 additions & 3 deletions news/index_no.html

Large diffs are not rendered by default.

817 changes: 756 additions & 61 deletions news/index_sv.html

Large diffs are not rendered by default.

942 changes: 935 additions & 7 deletions news/index_zh.html

Large diffs are not rendered by default.

369 changes: 369 additions & 0 deletions scripts/article-quality-enhancer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,369 @@
#!/usr/bin/env node

/**
* Article Quality Enhancer - Economist-Style Quality Framework
*
* Implements The Economist-style quality standards for political journalism:
* - Analytical depth assessment (min 0.6)
* - Source quality validation (min 3 cross-references)
* - Party perspective counting (min 4 parties)
* - "Why This Matters" section detection
* - Historical context validation
* - Quality score calculation (0.0-1.0, min 0.75)
*
* Usage:
* import { enhanceArticleQuality } from './article-quality-enhancer.js';
* const result = await enhanceArticleQuality(articlePath, options);
*
* @see Issue #150 (News Realtime Monitor Enhancement)
*/

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

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

path, fileURLToPath, __filename, and __dirname are declared but not used in this module. Removing unused imports/variables will avoid lint noise and keeps the script focused.

Suggested change
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed in commit 849b756. Removed unused imports (path, fileURLToPath, __filename, __dirname), kept only fs.

/**
* Default quality thresholds based on The Economist standards
*/
const DEFAULT_THRESHOLDS = {
minQualityScore: 0.75,
minAnalyticalDepth: 0.6,
minPartySources: 4,
minCrossReferences: 3,
requireWhyThisMatters: true,
recommendHistoricalContext: true,
recommendInternationalComparison: false
};

/**
* Swedish political parties for perspective validation
*/
const SWEDISH_PARTIES = [
'Socialdemokraterna', 'S', 'Social Democrats',
'Moderaterna', 'M', 'Moderate',
'Sverigedemokraterna', 'SD', 'Sweden Democrats',
'Centerpartiet', 'C', 'Centre Party',
'VΓ€nsterpartiet', 'V', 'Left Party',
'Kristdemokraterna', 'KD', 'Christian Democrats',
'Liberalerna', 'L', 'Liberals',
'MiljΓΆpartiet', 'MP', 'Green Party'
];

/**
* Riksdag/Regering document ID patterns
*/
const DOCUMENT_ID_PATTERNS = [
/\b[A-Z]{1,3}\d{1,4}\/\d{2}:\d+\b/g, // Committee reports: AU10/24:1
/\bProp\.\s*\d{4}\/\d{2}:\d+\b/gi, // Propositions: Prop. 2024/25:1
/\bBet\.\s*\d{4}\/\d{2}:[A-Z]{1,3}\d+\b/gi, // Committee reports: Bet. 2024/25:FiU10
/\bMot\.\s*\d{4}\/\d{2}:\d+\b/gi, // Motions: Mot. 2024/25:123
/\bIP\s*\d{4}\/\d{2}:\d+\b/gi, // Interpellations: IP 2024/25:45
/\bFr\.\s*\d{4}\/\d{2}:\d+\b/gi // Questions: Fr. 2024/25:67
];

/**
* Assess analytical depth of article content
*
* Looks for:
* - Causal reasoning ("because", "therefore", "as a result")
* - Comparative analysis ("compared to", "in contrast", "while")
* - Trend analysis ("trend", "pattern", "shift")
* - Evidence-based claims (references to data, studies, reports)
* - Multiple perspectives (quotes from different actors)
*
* @param {string} content - HTML content of article
* @returns {number} Score 0.0-1.0
*/
function assessAnalyticalDepth(content) {
const text = stripHtml(content).toLowerCase();
let score = 0.0;

// Causal reasoning indicators (0.2 max)
const causalWords = ['because', 'therefore', 'as a result', 'consequently', 'due to', 'leads to', 'caused by'];
const causalCount = causalWords.filter(word => text.includes(word)).length;
score += Math.min(causalCount * 0.04, 0.2);

// Comparative analysis (0.2 max)
const comparativeWords = ['compared to', 'in contrast', 'while', 'whereas', 'on the other hand', 'however'];
const comparativeCount = comparativeWords.filter(word => text.includes(word)).length;
score += Math.min(comparativeCount * 0.04, 0.2);

// Trend/pattern analysis (0.2 max)
const trendWords = ['trend', 'pattern', 'shift', 'change', 'evolution', 'development'];
const trendCount = trendWords.filter(word => text.includes(word)).length;
score += Math.min(trendCount * 0.04, 0.2);

// Evidence-based claims (0.2 max)
const evidenceWords = ['data shows', 'according to', 'study', 'report', 'statistics', 'evidence'];
const evidenceCount = evidenceWords.filter(word => text.includes(word)).length;
score += Math.min(evidenceCount * 0.04, 0.2);

// Multiple perspectives (0.2 max) - count quotes
const quoteCount = (content.match(/<blockquote>/gi) || []).length +
(text.match(/"\w/g) || []).length / 2;
score += Math.min(quoteCount * 0.04, 0.2);

return Math.min(score, 1.0);
}

/**
* Count unique party perspectives mentioned in article
*
* @param {string} content - HTML content of article
* @returns {number} Number of unique parties mentioned
*/
function countPartyPerspectives(content) {
const text = content;
const partiesFound = new Set();

SWEDISH_PARTIES.forEach(party => {
const regex = new RegExp(`\\b${party}\\b`, 'gi');
if (regex.test(text)) {
// Normalize to party abbreviation
if (['Socialdemokraterna', 'Social Democrats'].includes(party)) partiesFound.add('S');
else if (['Moderaterna', 'Moderate'].includes(party)) partiesFound.add('M');
else if (['Sverigedemokraterna', 'Sweden Democrats'].includes(party)) partiesFound.add('SD');
else if (['Centerpartiet', 'Centre Party'].includes(party)) partiesFound.add('C');
else if (['VΓ€nsterpartiet', 'Left Party'].includes(party)) partiesFound.add('V');
else if (['Kristdemokraterna', 'Christian Democrats'].includes(party)) partiesFound.add('KD');
else if (['Liberalerna', 'Liberals'].includes(party)) partiesFound.add('L');
else if (['MiljΓΆpartiet', 'Green Party'].includes(party)) partiesFound.add('MP');
else partiesFound.add(party);
}
});

return partiesFound.size;
}

/**
* Count cross-referenced Riksdag/Regering documents
*
* @param {string} content - HTML content of article
* @returns {number} Number of unique document IDs found
*/
function countCrossReferences(content) {
const documentIds = new Set();

DOCUMENT_ID_PATTERNS.forEach(pattern => {
const matches = content.match(pattern) || [];
matches.forEach(match => documentIds.add(match));
});

return documentIds.size;
}

/**
* Detect "Why This Matters" section
*
* @param {string} content - HTML content of article
* @returns {boolean} True if section exists
*/
function hasWhyThisMatters(content) {
const patterns = [
/why\s+this\s+matters/i,
/varfΓΆr\s+detta\s+betyder/i,
/betydelse/i,
/implications/i,
/konsekvenser/i
];

return patterns.some(pattern => pattern.test(content));
}

/**
* Detect historical context
*
* @param {string} content - HTML content of article
* @returns {boolean} True if historical context present
*/
function hasHistoricalContext(content) {
const text = stripHtml(content).toLowerCase();
const patterns = [
/historically/i,
/in \d{4}/,
/since \d{4}/,
/tidigare/i,
/historiskt/i
];

return patterns.some(pattern => pattern.test(text));
}

/**
* Detect international comparison
*
* @param {string} content - HTML content of article
* @returns {boolean} True if international comparison present
*/
function hasInternationalComparison(content) {
const text = stripHtml(content).toLowerCase();
const patterns = [
/compared to.*country/i,
/international.*comparison/i,
/other.*countries/i,
/jΓ€mfΓΆrt med.*lΓ€nder/i,
/internationell.*jΓ€mfΓΆrelse/i
];

return patterns.some(pattern => pattern.test(text));
}

/**
* Strip HTML tags from content
*
* @param {string} html - HTML content
* @returns {string} Plain text
*/
function stripHtml(html) {
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}

/**
* Calculate overall quality score
*
* Weights:
* - Analytical depth: 30%
* - Party perspectives: 25%
* - Cross-references: 25%
* - "Why This Matters": 10%
* - Historical context: 5%
* - International comparison: 5%
*
* @param {Object} metrics - Individual quality metrics
* @returns {number} Overall score 0.0-1.0
*/
function calculateQualityScore(metrics) {
const weights = {
analyticalDepth: 0.30,
partyPerspectives: 0.25,
crossReferences: 0.25,
whyThisMatters: 0.10,
historicalContext: 0.05,
internationalComparison: 0.05
};

let score = 0.0;

// Analytical depth (already 0-1)
score += metrics.analyticalDepth * weights.analyticalDepth;

// Party perspectives (normalize: 4+ parties = 1.0)
score += Math.min(metrics.partyCount / 4, 1.0) * weights.partyPerspectives;

// Cross-references (normalize: 3+ refs = 1.0)
score += Math.min(metrics.crossReferences / 3, 1.0) * weights.crossReferences;

// Binary checks
score += (metrics.hasWhyThisMatters ? 1.0 : 0.0) * weights.whyThisMatters;
score += (metrics.hasHistoricalContext ? 1.0 : 0.0) * weights.historicalContext;
score += (metrics.hasInternationalComparison ? 1.0 : 0.0) * weights.internationalComparison;

return Math.min(score, 1.0);
}

/**
* Enhance article quality and validate against thresholds
*
* @param {string} articlePath - Path to article HTML file
* @param {Object} thresholds - Quality thresholds (optional)
* @returns {Object} Quality assessment result
*/
export async function enhanceArticleQuality(articlePath, thresholds = {}) {
const options = { ...DEFAULT_THRESHOLDS, ...thresholds };

if (!fs.existsSync(articlePath)) {
return {
passed: false,
error: 'Article file not found',
articlePath
};
}

const content = fs.readFileSync(articlePath, 'utf-8');

// Collect metrics
const metrics = {
analyticalDepth: assessAnalyticalDepth(content),
partyCount: countPartyPerspectives(content),
crossReferences: countCrossReferences(content),
hasWhyThisMatters: hasWhyThisMatters(content),
hasHistoricalContext: hasHistoricalContext(content),
hasInternationalComparison: hasInternationalComparison(content)
};

// Calculate overall score
const qualityScore = calculateQualityScore(metrics);

// Validate against thresholds
const issues = [];

if (qualityScore < options.minQualityScore) {
issues.push(`Quality score ${qualityScore.toFixed(2)} below threshold ${options.minQualityScore}`);
}

if (metrics.analyticalDepth < options.minAnalyticalDepth) {
issues.push(`Analytical depth ${metrics.analyticalDepth.toFixed(2)} below threshold ${options.minAnalyticalDepth}`);
}

if (metrics.partyCount < options.minPartySources) {
issues.push(`Only ${metrics.partyCount} party perspectives (need ${options.minPartySources})`);
}

if (metrics.crossReferences < options.minCrossReferences) {
issues.push(`Only ${metrics.crossReferences} cross-references (need ${options.minCrossReferences})`);
}

if (options.requireWhyThisMatters && !metrics.hasWhyThisMatters) {
issues.push('Missing "Why This Matters" section');
}

if (options.recommendHistoricalContext && !metrics.hasHistoricalContext) {
issues.push('Recommended: Add historical context');
}

if (options.recommendInternationalComparison && !metrics.hasInternationalComparison) {
issues.push('Recommended: Add international comparison');
}

return {
passed: issues.length === 0,
qualityScore,
metrics,
issues,
thresholds: options,
articlePath
};
}

/**
* Batch enhance multiple articles
*
* @param {string[]} articlePaths - Array of article paths
* @param {Object} thresholds - Quality thresholds
* @returns {Object[]} Array of quality results
*/
export async function batchEnhanceQuality(articlePaths, thresholds = {}) {
const results = [];

for (const articlePath of articlePaths) {
const result = await enhanceArticleQuality(articlePath, thresholds);
results.push(result);
}

return results;
}

// Export individual assessment functions for testing
export {
assessAnalyticalDepth,
countPartyPerspectives,
countCrossReferences,
hasWhyThisMatters,
hasHistoricalContext,
hasInternationalComparison,
calculateQualityScore,
DEFAULT_THRESHOLDS
};
6 changes: 4 additions & 2 deletions scripts/article-template.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ ${ALL_LANG_CODES.map(l => ` <link rel="alternate" hreflang="${l === 'no' ? 'nb'
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Orbitron:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">

<!-- Main stylesheet - contains all article styles -->
<link rel="stylesheet" href="../styles.css">

<!-- Schema.org NewsArticle structured data -->
Expand Down Expand Up @@ -316,12 +317,13 @@ ${ALL_LANG_CODES.map(l => ` <link rel="alternate" hreflang="${l === 'no' ? 'nb'
}
}
</script>

${generateArticleCSS()}
</head>
<body>
<!-- Article styles are now in styles.css under .news-article namespace.
No embedded CSS needed - promotes consistency and maintainability. -->
<article class="news-article">
<header class="article-header">
<div class="site-tagline">Latest news and analysis from Sweden's Riksdag. The Economist-style political journalism covering parliament, government, and agencies with systematic transparency.</div>
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

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

In non-English article pages, the new tagline is still an English sentence but isn’t marked as such. For accessibility (screen readers) and correct language metadata, consider adding lang="en" to the tagline element or providing per-language translations.

Suggested change
<div class="site-tagline">Latest news and analysis from Sweden's Riksdag. The Economist-style political journalism covering parliament, government, and agencies with systematic transparency.</div>
<div class="site-tagline"${lang !== 'en' ? ' lang="en"' : ''}>Latest news and analysis from Sweden's Riksdag. The Economist-style political journalism covering parliament, government, and agencies with systematic transparency.</div>

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed in commit 849b756. Added conditional lang="en" attribute for accessibility:

<div class="site-tagline"${lang !== 'en' ? ' lang="en"' : ''}>

<h1>${title}</h1>
<div class="article-meta">
<time datetime="${isoDate}">${formattedDate}</time>
Expand Down
Loading
Loading