+ `;
+}
+
/**
* Generate the full site footer matching index.html structure.
*
diff --git a/scripts/article-template/index.ts b/scripts/article-template/index.ts
index c2567abbb1..3ee1b24b77 100644
--- a/scripts/article-template/index.ts
+++ b/scripts/article-template/index.ts
@@ -17,19 +17,22 @@
*/
export { generateArticleHTML } from './template.js';
-export { generateEventCalendar, generateWatchSection, generateArticleLanguageSwitcher, generateSiteFooter, fixHtmlNesting } from './helpers.js';
+export { generateEventCalendar, generateWatchSection, generateWhatHappensNextSection, generateWinnersLosersSection, generateFaqSection, generateArticleLanguageSwitcher, generateSiteFooter, fixHtmlNesting } from './helpers.js';
export { getTemplate, getStyleClass, getAIDirectives, getLayout, listRegisteredTypes } from './registry.js';
export type { ArticleTemplate, LayoutConfig, AIStyleDirective, ContentTone, ColumnCount, BreadcrumbStyle } from './types.js';
export { GLOBAL_STYLE_RUBRIC, ARTICLE_TYPE_NAMES } from './types.js';
import { generateArticleHTML } from './template.js';
-import { generateEventCalendar, generateWatchSection, generateArticleLanguageSwitcher, generateSiteFooter } from './helpers.js';
+import { generateEventCalendar, generateWatchSection, generateWhatHappensNextSection, generateWinnersLosersSection, generateFaqSection, generateArticleLanguageSwitcher, generateSiteFooter } from './helpers.js';
import { getTemplate, getStyleClass, getAIDirectives, getLayout, listRegisteredTypes } from './registry.js';
export default {
generateArticleHTML,
generateEventCalendar,
generateWatchSection,
+ generateWhatHappensNextSection,
+ generateWinnersLosersSection,
+ generateFaqSection,
generateArticleLanguageSwitcher,
generateSiteFooter,
getTemplate,
diff --git a/scripts/article-template/registry.ts b/scripts/article-template/registry.ts
index e9cdc83c7b..0057f19102 100644
--- a/scripts/article-template/registry.ts
+++ b/scripts/article-template/registry.ts
@@ -97,6 +97,48 @@ function makeSwotDirective(subject: string): AIStyleDirective {
};
}
+/**
+ * Shared AI directive for the "What Happens Next" timeline section.
+ * Lists upcoming legislative milestones with significance ratings.
+ */
+function makeWhatHappensNextDirective(articleLabel: string): AIStyleDirective {
+ return {
+ section: 'what-happens-next',
+ tone: 'informational',
+ maxWords: 250,
+ requiresSubheadings: false,
+ stakeholderFocus: ['Parliament', 'Government', 'Committees', 'Citizens'],
+ rubric: [
+ ...GLOBAL_STYLE_RUBRIC,
+ `Provide 3–6 ordered upcoming events for: ${articleLabel}`,
+ 'Each entry: exact ISO date (YYYY-MM-DD), one-sentence event description, and significance (high/medium/low).',
+ 'Use only dates that can be sourced from the Riksdag calendar or the bill text.',
+ 'Do not speculate; omit dates that are not confirmed.',
+ ],
+ };
+}
+
+/**
+ * Shared AI directive for the "Winners & Losers" analysis section.
+ * Names political actors with evidence-backed outcome assessments.
+ */
+function makeWinnersLosersDirective(articleLabel: string): AIStyleDirective {
+ return {
+ section: 'winners-losers',
+ tone: 'analytical',
+ maxWords: 300,
+ requiresSubheadings: false,
+ stakeholderFocus: ['Parties', 'Ministers', 'Interest groups', 'Citizens'],
+ rubric: [
+ ...GLOBAL_STYLE_RUBRIC,
+ `Identify 3–6 named actors affected by: ${articleLabel}`,
+ 'Each entry: actor name, outcome (wins/loses/mixed), one-sentence evidence citing a specific document or vote.',
+ 'Balance wins and losses — do not list only one outcome type.',
+ 'Use only verifiable facts; no speculation.',
+ ],
+ };
+}
+
// ---------------------------------------------------------------------------
// Registry definition
// ---------------------------------------------------------------------------
@@ -214,6 +256,8 @@ const REGISTRY: Readonly> = {
],
},
'key-takeaways': makeKeyTakeawaysDirective(['Committees', 'Government', 'Parliament']),
+ 'what-happens-next': makeWhatHappensNextDirective('Committee Reports'),
+ 'winners-losers': makeWinnersLosersDirective('Committee Reports'),
},
},
@@ -238,6 +282,8 @@ const REGISTRY: Readonly> = {
},
swot: makeSwotDirective('Government Proposition'),
'key-takeaways': makeKeyTakeawaysDirective(['Government', 'Parliament', 'Citizens']),
+ 'what-happens-next': makeWhatHappensNextDirective('Government Proposition'),
+ 'winners-losers': makeWinnersLosersDirective('Government Proposition'),
},
},
@@ -261,6 +307,8 @@ const REGISTRY: Readonly> = {
],
},
'key-takeaways': makeKeyTakeawaysDirective(['Opposition', 'Government', 'Voters']),
+ 'what-happens-next': makeWhatHappensNextDirective('Opposition Motions'),
+ 'winners-losers': makeWinnersLosersDirective('Opposition Motions'),
},
},
@@ -295,6 +343,8 @@ const REGISTRY: Readonly> = {
'Rate response adequacy: Comprehensive / Partial / Evasive.',
],
},
+ 'what-happens-next': makeWhatHappensNextDirective('Interpellation Debate'),
+ 'winners-losers': makeWinnersLosersDirective('Interpellation Debate'),
},
},
diff --git a/scripts/article-template/template.ts b/scripts/article-template/template.ts
index 1f693a1963..93864a6994 100644
--- a/scripts/article-template/template.ts
+++ b/scripts/article-template/template.ts
@@ -12,6 +12,7 @@ import { escapeHtml, decodeHtmlEntities } from '../html-utils.js';
import { CONTENT_LABELS } from '../data-transformers.js';
import type { Language } from '../types/language.js';
import type { ArticleData, EventGridItem, WatchPoint, TemplateSection } from '../types/article.js';
+import type { FAQItem } from '../types/editorial.js';
import type { ClassificationLevel } from '../analysis-reader.js';
import { SITE_TAGLINE, OG_LOCALE_MAP, TYPE_LABELS, ALL_LANG_CODES } from './constants.js';
import { getStyleClass } from './registry.js';
@@ -25,6 +26,7 @@ import {
formatDate,
generateEventCalendar,
generateWatchSection,
+ generateFaqSection,
generateArticleLanguageSwitcher,
generateSiteFooter,
hreflangCode,
@@ -159,6 +161,7 @@ export function generateArticleHTML(data: ArticleData): string {
riskLevel,
confidenceLabel,
analysisReferencesHtml = '',
+ faqItems = [],
} = data;
// Decode any HTML entities to UTF-8 to prevent double-escaping.
@@ -342,7 +345,7 @@ ${ALL_LANG_CODES.map(l => ` 0 ? `,
"mentions": [${tags.map(tag => `
{
@@ -417,6 +420,24 @@ ${ALL_LANG_CODES.map(l => `
+ ${(faqItems as FAQItem[]).length > 0 ? `
+
+ ` : ''}
@@ -473,6 +494,8 @@ ${fixedContent}
${watchPoints.length > 0 ? generateWatchSection(watchPoints as ReadonlyArray, lang) : ''}
+${(faqItems as FAQItem[]).length > 0 ? generateFaqSection(faqItems as FAQItem[], lang) : ''}
+
${(sections as TemplateSection[]).length > 0 ? (sections as TemplateSection[]).map(s => `
${s.html}
`).join('\n') : ''}
diff --git a/scripts/editorial-framework.ts b/scripts/editorial-framework.ts
index 0c2401a331..cb952c280e 100644
--- a/scripts/editorial-framework.ts
+++ b/scripts/editorial-framework.ts
@@ -47,7 +47,10 @@ export type EditorialSection =
| 'policy-mindmap'
| 'deep-analysis'
| 'watch-points'
- | 'sources-methodology';
+ | 'sources-methodology'
+ | 'what-happens-next'
+ | 'winners-losers'
+ | 'faq';
/**
* Profile describing the editorial requirements for a single article type.
@@ -169,6 +172,8 @@ export const ARTICLE_TYPE_PROFILES: Readonly>;
+
+/**
+ * Canonical inter-section transition templates.
+ *
+ * Templates may contain the following interpolation tokens (replaced at call time):
+ * - `{topic}` — topicKeyword from context (defaults to "this legislation")
+ * - `{actor}` — actorName from context (defaults to "key stakeholders")
+ */
+const SECTION_TRANSITION_TEMPLATES: Readonly> = {
+ 'key-takeaways-what-happens-next': {
+ en: 'With {topic} now moving through committees, here is the full legislative timeline ahead:',
+ sv: 'Med {topic} som nu behandlas i utskott, se hela den kommande lagstiftningsprocessen:',
+ da: 'Med {topic} der nu behandles i udvalg, se hele den kommende lovgivningstidslinje:',
+ no: 'Med {topic} som nå behandles i komiteer, se hele den kommende lovgivningstidslinjen:',
+ fi: 'Kun {topic} etenee valiokunnissa, tässä on koko tuleva lainsäädäntöaikataulu:',
+ de: 'Da {topic} nun durch die Ausschüsse geht, hier ist die vollständige Gesetzgebungszeitleiste:',
+ fr: 'Avec {topic} maintenant en cours d\'examen en commission, voici la chronologie complète:',
+ es: 'Con {topic} avanzando ahora por los comités, aquí está la cronología legislativa completa:',
+ nl: 'Nu {topic} door commissies gaat, volgt hier de volledige wetgevingstijdlijn:',
+ ar: 'مع تقدم {topic} عبر اللجان، إليك الجدول الزمني التشريعي الكامل:',
+ he: 'כש-{topic} עובר כעת בוועדות, הנה ציר הזמן המלא של תהליך החקיקה:',
+ ja: '{topic}が委員会を通過するにつれ、以下が今後の立法スケジュールです:',
+ ko: '{topic}이(가) 위원회를 통과함에 따라, 다음은 전체 입법 일정입니다:',
+ zh: '随着{topic}在委员会中推进,以下是完整的立法时间线:',
+ },
+ 'swot-winners-losers': {
+ en: 'The SWOT analysis above translates into clear gains and losses for {actor} and other actors:',
+ sv: 'SWOT-analysen ovan resulterar i tydliga vinster och förluster för {actor} och andra aktörer:',
+ da: 'SWOT-analysen ovenfor omsættes til klare gevinster og tab for {actor} og andre aktører:',
+ no: 'SWOT-analysen ovenfor gir klare gevinster og tap for {actor} og andre aktører:',
+ fi: 'Edellä esitetty SWOT-analyysi tarkoittaa selkeitä voittoja ja tappioita {actor}:lle ja muille toimijoille:',
+ de: 'Die obige SWOT-Analyse zeigt klare Gewinne und Verluste für {actor} und andere Akteure:',
+ fr: 'L\'analyse SWOT ci-dessus se traduit par des gains et pertes clairs pour {actor} et d\'autres acteurs:',
+ es: 'El análisis SWOT anterior se traduce en ganancias y pérdidas claras para {actor} y otros actores:',
+ nl: 'De bovenstaande SWOT-analyse vertaalt zich in duidelijke winsten en verliezen voor {actor} en andere actoren:',
+ ar: 'تتحول تحليل SWOT أعلاه إلى مكاسب وخسائر واضحة لـ{actor} والفاعلين الآخرين:',
+ he: 'ניתוח ה-SWOT לעיל מתורגם לרווחים והפסדים ברורים עבור {actor} ושחקנים אחרים:',
+ ja: '上記のSWOT分析は、{actor}および他のアクターにとって明確な利益と損失に変換されます:',
+ ko: '위의 SWOT 분석은 {actor} 및 다른 행위자들에게 명확한 이득과 손실로 이어집니다:',
+ zh: '上述SWOT分析转化为{actor}和其他行动者的明确得失:',
+ },
+ 'winners-losers-what-happens-next': {
+ en: 'Given this outcome for {actor}, here is what the next legislative steps mean in practice:',
+ sv: 'Med tanke på detta utfall för {actor}, är det här vad nästa lagstiftningssteg innebär i praktiken:',
+ da: 'I betragtning af dette resultat for {actor}, er det her, hvad de næste lovgivningstrin betyder i praksis:',
+ no: 'Gitt dette utfallet for {actor}, her er hva de neste lovgivningstrinnene betyr i praksis:',
+ fi: 'Ottaen huomioon tämä tulos {actor}:lle, tässä on mitä seuraavat lainsäädäntövaiheet tarkoittavat käytännössä:',
+ de: 'Angesichts dieses Ergebnisses für {actor} bedeuten die nächsten Gesetzgebungsschritte in der Praxis:',
+ fr: 'Compte tenu de ce résultat pour {actor}, voici ce que signifient les prochaines étapes législatives en pratique:',
+ es: 'Dado este resultado para {actor}, esto es lo que significan los próximos pasos legislativos en la práctica:',
+ nl: 'Gezien dit resultaat voor {actor}, is dit wat de volgende wetgevingsstappen in de praktijk betekenen:',
+ ar: 'بالنظر إلى هذه النتيجة لـ{actor}، إليك ما تعنيه الخطوات التشريعية التالية عملياً:',
+ he: 'בהתחשב בתוצאה זו עבור {actor}, הנה מה שמשמעות שלבי החקיקה הבאים מעשית:',
+ ja: '{actor}にとってのこの結果を踏まえ、次の立法ステップが実際に意味することは:',
+ ko: '{actor}에 대한 이 결과를 감안할 때, 다음 입법 단계가 실제로 의미하는 바는:',
+ zh: '鉴于{actor}的这一结果,以下是下一步立法步骤实际意味着什么:',
+ },
+} as const;
+
+/**
+ * Generate a context-aware inter-section transition sentence.
+ *
+ * Returns an empty string when no template is registered for the given
+ * `fromSection → toSection` pair (allowing callers to skip rendering).
+ * The returned string is **plain text** (not HTML) — wrap it in a `
` tag
+ * with appropriate CSS class at the call site.
+ *
+ * @param lang - Article language code
+ * @param fromSection - CSS class / identifier of the preceding section
+ * @param toSection - CSS class / identifier of the following section
+ * @param context - Optional topic and actor to interpolate into the template
+ * @returns Localized transition sentence or empty string
+ *
+ * @example
+ * ```typescript
+ * const trans = generateSectionTransition('en', 'swot', 'winners-losers', {
+ * actorName: 'Socialdemokraterna',
+ * });
+ * // → "The SWOT analysis above translates into clear gains and losses for Socialdemokraterna and other actors:"
+ * ```
+ */
+export function generateSectionTransition(
+ lang: Language | string,
+ fromSection: string,
+ toSection: string,
+ context: TransitionContext = {},
+): string {
+ const key = `${fromSection}-${toSection}`;
+ const templates = SECTION_TRANSITION_TEMPLATES[key];
+ if (!templates) return '';
+
+ const template: string =
+ templates[lang as Language] ?? templates.en ?? '';
+ if (!template) return '';
+
+ const topic = context.topicKeyword ?? 'this legislation';
+ const actor = context.actorName ?? 'key stakeholders';
+
+ return template
+ .replace(/\{topic\}/g, topic)
+ .replace(/\{actor\}/g, actor);
+}
diff --git a/scripts/types/article.ts b/scripts/types/article.ts
index 9c6b77f9f4..54e37718fe 100644
--- a/scripts/types/article.ts
+++ b/scripts/types/article.ts
@@ -5,6 +5,7 @@
import type { Language } from './language.js';
import type { ClassificationLevel, RiskLevel, ConfidenceLabel } from '../analysis-reader.js';
+import type { FAQItem } from './editorial.js';
/* ── Inlined types (formerly from deleted ai-analysis modules) ── */
@@ -170,6 +171,12 @@ export interface ArticleData {
* When present, it is injected between the article content and the footer.
*/
analysisReferencesHtml?: string;
+ /**
+ * Optional FAQ items rendered as an HTML section and emitted as Schema.org
+ * `FAQPage` structured data for rich SERP snippets and voice assistants.
+ * Each item contains a plain-text question and a concise answer.
+ */
+ faqItems?: FAQItem[];
}
/** A single generated article (language variant) */
diff --git a/scripts/types/editorial.ts b/scripts/types/editorial.ts
index 33a91aa990..8096d47529 100644
--- a/scripts/types/editorial.ts
+++ b/scripts/types/editorial.ts
@@ -1,6 +1,8 @@
/**
* @module Types/Editorial
- * @description Editorial pillar types for the five-pillar analysis framework.
+ * @description Editorial pillar types for the five-pillar analysis framework,
+ * plus structured data types for new rich article sections (What Happens Next,
+ * Winners & Losers, FAQ).
*/
import type { Language } from './language.js';
@@ -22,3 +24,54 @@ export interface PillarHeadings {
/** Full set of pillar headings for all 14 supported languages */
export type LocalizedPillarHeadings = Record;
+
+// ---------------------------------------------------------------------------
+// What Happens Next — legislative timeline entries
+// ---------------------------------------------------------------------------
+
+/**
+ * A single step in the legislative pipeline rendered in the
+ * "What Happens Next" timeline section.
+ */
+export interface WhatHappensNextItem {
+ /** ISO date string for the event (YYYY-MM-DD) */
+ date: string;
+ /** Short description of the event, e.g. "Second reading vote" */
+ event: string;
+ /** Relative political significance of this step */
+ significance: 'high' | 'medium' | 'low';
+}
+
+// ---------------------------------------------------------------------------
+// Winners & Losers — political actor outcome entries
+// ---------------------------------------------------------------------------
+
+/**
+ * A single actor entry in the "Winners & Losers" analysis section.
+ */
+export interface WinnersLosersEntry {
+ /** Named political actor (party, minister, interest group) */
+ actor: string;
+ /** Net outcome for this actor given the legislative action */
+ outcome: 'wins' | 'loses' | 'mixed';
+ /**
+ * One-sentence evidence statement justifying the outcome classification.
+ * Must cite a specific document, vote, or statement.
+ */
+ evidence: string;
+}
+
+// ---------------------------------------------------------------------------
+// FAQ — structured Q&A for Schema.org FAQPage + reader engagement
+// ---------------------------------------------------------------------------
+
+/**
+ * A single question/answer pair for the FAQ section.
+ * Rendered both as HTML and as Schema.org FAQPage structured data.
+ */
+export interface FAQItem {
+ /** Reader-facing question */
+ question: string;
+ /** Concise, factual answer (1–3 sentences) */
+ answer: string;
+}
diff --git a/scripts/types/validation.ts b/scripts/types/validation.ts
index 6cab76a865..67b1b26a51 100644
--- a/scripts/types/validation.ts
+++ b/scripts/types/validation.ts
@@ -70,6 +70,12 @@ export interface QualityThresholds {
recommendEconomicContext?: boolean;
/** Recommend Swedish statistical context from SCB (non-blocking) */
recommendSCBContext?: boolean;
+ /** Recommend "What Happens Next" timeline section (non-blocking) */
+ recommendWhatHappensNext?: boolean;
+ /** Recommend "Winners & Losers" section (non-blocking) */
+ recommendWinnersLosers?: boolean;
+ /** Minimum number of specific verifiable claims required (blocking if > 0) */
+ minSpecificClaims?: number;
}
/** Measured quality metrics for a single article */
@@ -92,6 +98,14 @@ export interface QualityMetrics {
hasArticleTopNav?: boolean;
/** Whether the article has a back-to-news link (in top nav or footer) */
hasBackToNews?: boolean;
+ /** Whether the article contains a "What Happens Next" legislative timeline section */
+ hasWhatHappensNext?: boolean;
+ /** Whether the article contains a "Winners & Losers" political analysis section */
+ hasWinnersLosers?: boolean;
+ /** Number of specific, verifiable factual claims detected (documents, percentages, named actors) */
+ specificClaimsCount?: number;
+ /** Whether the lede paragraph has at least 30 words (not a stub) */
+ hasSubstantialLede?: boolean;
}
/** Quality assessment result for a single article */
From aa7739f2f635ea4e6cf446fed64ba865375bb010 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 10 Apr 2026 09:33:51 +0000
Subject: [PATCH 03/11] fix: add faq-section to speakable selectors, document
claim count caps and context requirements
Agent-Logs-Url: https://github.com/Hack23/riksdagsmonitor/sessions/0ddc5fe4-4563-49ee-963e-b7e378213172
Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
---
scripts/article-quality-enhancer.ts | 5 +++++
scripts/article-template/template.ts | 2 +-
scripts/editorial-pillars.ts | 15 ++++++++++++++-
3 files changed, 20 insertions(+), 2 deletions(-)
diff --git a/scripts/article-quality-enhancer.ts b/scripts/article-quality-enhancer.ts
index fafa7ffc05..5cbbc8bd70 100644
--- a/scripts/article-quality-enhancer.ts
+++ b/scripts/article-quality-enhancer.ts
@@ -282,10 +282,15 @@ function countSpecificClaims(content: string): number {
});
// Percentage figures with surrounding context (e.g. "increased by 12%")
+ // Cap at 5 to prevent a heavily statistics-driven article from
+ // single-handedly satisfying the minSpecificClaims threshold via
+ // repetitive figures alone (e.g. budget tables with 20+ percentages).
const percentMatches = text.match(/\b\d+(?:\.\d+)?%/g);
count += percentMatches ? Math.min(percentMatches.length, 5) : 0;
// Named MPs or ministers (Swedish name pattern: "Firstname Lastname (Party)")
+ // Capped at 5 for the same reason as percentage matches: prevents a roster
+ // of names without substantive claims from satisfying the threshold.
const namedActors = text.match(/[A-ZÅÄÖ][a-zåäö]+\s+[A-ZÅÄÖ][a-zåäö]+\s*\([A-ZÅÄÖ]{1,3}\)/g);
count += namedActors ? Math.min(namedActors.length, 5) : 0;
diff --git a/scripts/article-template/template.ts b/scripts/article-template/template.ts
index 93864a6994..a3b1ad3487 100644
--- a/scripts/article-template/template.ts
+++ b/scripts/article-template/template.ts
@@ -345,7 +345,7 @@ ${ALL_LANG_CODES.map(l => ` 0 ? `,
"mentions": [${tags.map(tag => `
{
diff --git a/scripts/editorial-pillars.ts b/scripts/editorial-pillars.ts
index cb1b04ef59..7c4f13c9b8 100644
--- a/scripts/editorial-pillars.ts
+++ b/scripts/editorial-pillars.ts
@@ -307,10 +307,18 @@ const SECTION_TRANSITION_TEMPLATES: Readonly
* The returned string is **plain text** (not HTML) — wrap it in a `
` tag
* with appropriate CSS class at the call site.
*
+ * **Important**: The default context values (`'this legislation'` and
+ * `'key stakeholders'`) are English strings. For non-English articles,
+ * callers **must** supply language-appropriate `context.topicKeyword` and
+ * `context.actorName` values so that the interpolated tokens match the
+ * article's language. Failing to do so will result in English text inside
+ * a non-English article.
+ *
* @param lang - Article language code
* @param fromSection - CSS class / identifier of the preceding section
* @param toSection - CSS class / identifier of the following section
- * @param context - Optional topic and actor to interpolate into the template
+ * @param context - Optional topic and actor to interpolate into the template.
+ * **Required for non-English articles.**
* @returns Localized transition sentence or empty string
*
* @example
@@ -319,6 +327,11 @@ const SECTION_TRANSITION_TEMPLATES: Readonly
* actorName: 'Socialdemokraterna',
* });
* // → "The SWOT analysis above translates into clear gains and losses for Socialdemokraterna and other actors:"
+ *
+ * // Swedish article — supply Swedish context values:
+ * const transSv = generateSectionTransition('sv', 'swot', 'winners-losers', {
+ * actorName: 'Socialdemokraterna',
+ * });
* ```
*/
export function generateSectionTransition(
From bf2575f74400da26ffd71c9f8725bdfa92e89cd1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 10 Apr 2026 11:34:04 +0000
Subject: [PATCH 04/11] =?UTF-8?q?fix:=20apply=20review=20feedback=20?=
=?UTF-8?q?=E2=80=94=20regex=20precision,=20JSON.stringify=20for=20FAQPage?=
=?UTF-8?q?=20JSON-LD,=20add=20faq=20to=20requiredSections?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Agent-Logs-Url: https://github.com/Hack23/riksdagsmonitor/sessions/a8a68e49-c93b-4008-aaae-b4b1fb9d9150
Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
---
scripts/article-quality-enhancer.ts | 2 +-
scripts/article-template/template.ts | 28 ++++++++++++----------------
scripts/editorial-framework.ts | 4 ++++
3 files changed, 17 insertions(+), 17 deletions(-)
diff --git a/scripts/article-quality-enhancer.ts b/scripts/article-quality-enhancer.ts
index 5cbbc8bd70..da0ebbac4f 100644
--- a/scripts/article-quality-enhancer.ts
+++ b/scripts/article-quality-enhancer.ts
@@ -214,7 +214,7 @@ function hasWhatHappensNext(content: string): boolean {
function hasWinnersLosers(content: string): boolean {
const patterns: readonly RegExp[] = [
/class=["'][^"']*\bwinners-losers\b/,
- /winners\s*[&and]+\s*losers/i,
+ /winners\s*(?:&|and)\s*losers/i,
/vinnare\s+och\s+förlorare/i,
/gewinner\s+und\s+verlierer/i,
/gagnants\s+et\s+perdants/i,
diff --git a/scripts/article-template/template.ts b/scripts/article-template/template.ts
index a3b1ad3487..4b9d66bb11 100644
--- a/scripts/article-template/template.ts
+++ b/scripts/article-template/template.ts
@@ -422,22 +422,18 @@ ${ALL_LANG_CODES.map(l => `
${(faqItems as FAQItem[]).length > 0 ? `
- ` : ''}
+ ` : ''}
diff --git a/scripts/editorial-framework.ts b/scripts/editorial-framework.ts
index cb952c280e..d218f70933 100644
--- a/scripts/editorial-framework.ts
+++ b/scripts/editorial-framework.ts
@@ -174,6 +174,7 @@ export const ARTICLE_TYPE_PROFILES: Readonly
Date: Fri, 10 Apr 2026 12:13:21 +0000
Subject: [PATCH 05/11] =?UTF-8?q?fix:=20apply=20second=20review=20round=20?=
=?UTF-8?q?=E2=80=94=20dedupe=20doc=20IDs,=20full=2014-lang=20patterns,=20?=
=?UTF-8?q?per-language=20transition=20defaults,=20fix=20docstrings?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Agent-Logs-Url: https://github.com/Hack23/riksdagsmonitor/sessions/e78433e6-d1f9-451f-a2bd-a38096ab9fd6
Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
---
scripts/article-quality-enhancer.ts | 47 ++++++++++++++++++++++-------
scripts/editorial-pillars.ts | 36 +++++++++++++++++-----
2 files changed, 64 insertions(+), 19 deletions(-)
diff --git a/scripts/article-quality-enhancer.ts b/scripts/article-quality-enhancer.ts
index da0ebbac4f..12f8dcbe5d 100644
--- a/scripts/article-quality-enhancer.ts
+++ b/scripts/article-quality-enhancer.ts
@@ -184,7 +184,8 @@ function hasWhyThisMatters(content: string): boolean {
/**
* Detect "What Happens Next" timeline section.
- * Looks for the rendered section class or common heading variants in 14 languages.
+ * Looks for the rendered section class or heading variants across supported languages.
+ * Covers: en, sv, da, no, fi, de, fr, es, nl, ar, he, ja, ko, zh.
*
* @param content - HTML content of article
* @returns True if the section is present
@@ -194,9 +195,15 @@ function hasWhatHappensNext(content: string): boolean {
/class=["'][^"']*\bwhat-happens-next\b/,
/what\s+happens\s+next/i,
/vad\s+händer\s+härnäst/i,
- /la\s+suite\s+des\s+événements/i,
+ /hvad\s+sker\s+der\s+nu/i,
+ /hva\s+skjer\s+videre/i,
+ /mitä\s+tapahtuu\s+seuraavaksi/i,
/was\s+passiert\s+als\s+nächstes/i,
+ /la\s+suite\s+des\s+événements/i,
/qué\s+sucede\s+a\s+continuación/i,
+ /wat\s+gebeurt\s+er\s+nu/i,
+ /ماذا يحدث بعد ذلك/,
+ /מה קורה בהמשך/,
/次のステップ/,
/다음\s+단계/,
/下一步/,
@@ -206,7 +213,8 @@ function hasWhatHappensNext(content: string): boolean {
/**
* Detect "Winners & Losers" analysis section.
- * Looks for the rendered section class or common heading variants in 14 languages.
+ * Looks for the rendered section class or heading variants across supported languages.
+ * Covers: en, sv, da, no, fi, de, fr, es, nl, ar, he, ja, ko, zh.
*
* @param content - HTML content of article
* @returns True if the section is present
@@ -216,9 +224,15 @@ function hasWinnersLosers(content: string): boolean {
/class=["'][^"']*\bwinners-losers\b/,
/winners\s*(?:&|and)\s*losers/i,
/vinnare\s+och\s+förlorare/i,
+ /vindere\s+og\s+tabere/i,
+ /vinnere\s+og\s+tapere/i,
+ /voittajat\s+ja\s+häviäjät/i,
/gewinner\s+und\s+verlierer/i,
/gagnants\s+et\s+perdants/i,
/ganadores\s+y\s+perdedores/i,
+ /winnaars\s+en\s+verliezers/i,
+ /الرابحون والخاسرون/,
+ /מנצחים ומפסידים/,
/勝者と敗者/,
/승자와\s+패자/,
/赢家与输家/,
@@ -262,24 +276,35 @@ function hasSubstantialLede(content: string): boolean {
}
/**
- * Count specific factual claims with cited evidence.
- * Looks for patterns that indicate a verifiable, specific claim:
+ * Count specific claim indicators in article text.
+ * Looks for patterns that indicate potentially verifiable, specific content:
* - Explicit document references (Prop., Bet., Mot., IP)
- * - Percentage or currency figures with context
- * - Named actors with attributed statements
+ * - Percentage figures
+ * - Named MPs or ministers matching the pattern "Firstname Lastname (Party)"
*
* @param content - HTML content of article
- * @returns Number of detected specific claims
+ * @returns Number of detected specific claim indicators
*/
function countSpecificClaims(content: string): number {
const text = stripHtml(content);
let count = 0;
- // Document references
+ // Count unique normalized document IDs rather than every occurrence to
+ // prevent repeated mentions of the same citation from inflating the score.
+ // Cap at 5, consistent with other claim signals below.
+ const uniqueDocumentReferences = new Set();
DOCUMENT_ID_PATTERNS.forEach((pattern: RegExp) => {
- const matches = text.match(pattern);
- count += matches ? matches.length : 0;
+ const flags = pattern.global ? pattern.flags : `${pattern.flags}g`;
+ const globalPattern = new RegExp(pattern.source, flags);
+
+ for (const match of text.matchAll(globalPattern)) {
+ const documentReference = match[0]?.trim();
+ if (documentReference) {
+ uniqueDocumentReferences.add(documentReference.replace(/\s+/g, ' ').toLowerCase());
+ }
+ }
});
+ count += Math.min(uniqueDocumentReferences.size, 5);
// Percentage figures with surrounding context (e.g. "increased by 12%")
// Cap at 5 to prevent a heavily statistics-driven article from
diff --git a/scripts/editorial-pillars.ts b/scripts/editorial-pillars.ts
index 7c4f13c9b8..71056bb665 100644
--- a/scripts/editorial-pillars.ts
+++ b/scripts/editorial-pillars.ts
@@ -299,6 +299,27 @@ const SECTION_TRANSITION_TEMPLATES: Readonly
},
} as const;
+/**
+ * Per-language default context values for section transition interpolation.
+ * Prevents mixed-language output when callers omit context for non-English articles.
+ */
+const DEFAULT_TRANSITION_CONTEXT: Readonly> = {
+ en: { topic: 'this legislation', actor: 'key stakeholders' },
+ sv: { topic: 'denna lagstiftning', actor: 'centrala aktörer' },
+ da: { topic: 'denne lovgivning', actor: 'centrale aktører' },
+ no: { topic: 'denne lovgivningen', actor: 'sentrale aktører' },
+ fi: { topic: 'tämä lainsäädäntö', actor: 'keskeiset toimijat' },
+ de: { topic: 'diese Gesetzgebung', actor: 'zentrale Akteure' },
+ fr: { topic: 'cette législation', actor: 'les acteurs clés' },
+ es: { topic: 'esta legislación', actor: 'los actores clave' },
+ nl: { topic: 'deze wetgeving', actor: 'belangrijke actoren' },
+ ar: { topic: 'هذا التشريع', actor: 'الأطراف الرئيسية' },
+ he: { topic: 'חקיקה זו', actor: 'בעלי העניין המרכזיים' },
+ ja: { topic: 'この法案', actor: '主要関係者' },
+ ko: { topic: '이 법안', actor: '주요 이해관계자' },
+ zh: { topic: '该立法', actor: '关键利益相关者' },
+} as const;
+
/**
* Generate a context-aware inter-section transition sentence.
*
@@ -307,12 +328,10 @@ const SECTION_TRANSITION_TEMPLATES: Readonly
* The returned string is **plain text** (not HTML) — wrap it in a `
` tag
* with appropriate CSS class at the call site.
*
- * **Important**: The default context values (`'this legislation'` and
- * `'key stakeholders'`) are English strings. For non-English articles,
- * callers **must** supply language-appropriate `context.topicKeyword` and
- * `context.actorName` values so that the interpolated tokens match the
- * article's language. Failing to do so will result in English text inside
- * a non-English article.
+ * **Important**: Default context values (`topicKeyword` and `actorName`) are
+ * provided in each of the 14 supported languages via `DEFAULT_TRANSITION_CONTEXT`.
+ * However, for best results callers should supply specific `context.topicKeyword`
+ * and `context.actorName` values that match the article's actual subject matter.
*
* @param lang - Article language code
* @param fromSection - CSS class / identifier of the preceding section
@@ -348,8 +367,9 @@ export function generateSectionTransition(
templates[lang as Language] ?? templates.en ?? '';
if (!template) return '';
- const topic = context.topicKeyword ?? 'this legislation';
- const actor = context.actorName ?? 'key stakeholders';
+ const defaults = DEFAULT_TRANSITION_CONTEXT[lang as Language] ?? DEFAULT_TRANSITION_CONTEXT.en;
+ const topic = context.topicKeyword ?? defaults.topic;
+ const actor = context.actorName ?? defaults.actor;
return template
.replace(/\{topic\}/g, topic)
From d6560755f0f249f07edb214b52494e14def96e25 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 10 Apr 2026 13:45:31 +0000
Subject: [PATCH 06/11] fix: update JSDoc for generateSectionTransition and
remove redundant FAQItem casts
Agent-Logs-Url: https://github.com/Hack23/riksdagsmonitor/sessions/6ba5f122-4753-4b15-bf06-955a02226c52
Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
---
scripts/article-template/template.ts | 7 +++----
scripts/editorial-pillars.ts | 9 +++++----
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/scripts/article-template/template.ts b/scripts/article-template/template.ts
index 4b9d66bb11..ee80214616 100644
--- a/scripts/article-template/template.ts
+++ b/scripts/article-template/template.ts
@@ -12,7 +12,6 @@ import { escapeHtml, decodeHtmlEntities } from '../html-utils.js';
import { CONTENT_LABELS } from '../data-transformers.js';
import type { Language } from '../types/language.js';
import type { ArticleData, EventGridItem, WatchPoint, TemplateSection } from '../types/article.js';
-import type { FAQItem } from '../types/editorial.js';
import type { ClassificationLevel } from '../analysis-reader.js';
import { SITE_TAGLINE, OG_LOCALE_MAP, TYPE_LABELS, ALL_LANG_CODES } from './constants.js';
import { getStyleClass } from './registry.js';
@@ -420,12 +419,12 @@ ${ALL_LANG_CODES.map(l => `
- ${(faqItems as FAQItem[]).length > 0 ? `
+ ${faqItems.length > 0 ? `