From 62ab7b80abe792b32c557a42500a6dd56b345b67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:09:39 +0000 Subject: [PATCH 01/11] Initial plan From 34596bd09af794e631f315afb360a99b80730606 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:28:28 +0000 Subject: [PATCH 02/11] feat: add What Happens Next, Winners & Losers sections, FAQPage schema, and content depth validation 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 | 143 ++++++++++++++++++++++++++ scripts/article-template/constants.ts | 103 +++++++++++++++++++ scripts/article-template/helpers.ts | 131 +++++++++++++++++++++++ scripts/article-template/index.ts | 7 +- scripts/article-template/registry.ts | 50 +++++++++ scripts/article-template/template.ts | 25 ++++- scripts/editorial-framework.ts | 13 ++- scripts/editorial-pillars.ts | 118 +++++++++++++++++++++ scripts/types/article.ts | 7 ++ scripts/types/editorial.ts | 55 +++++++++- scripts/types/validation.ts | 14 +++ 11 files changed, 661 insertions(+), 5 deletions(-) diff --git a/scripts/article-quality-enhancer.ts b/scripts/article-quality-enhancer.ts index ffe94406d9..fafa7ffc05 100644 --- a/scripts/article-quality-enhancer.ts +++ b/scripts/article-quality-enhancer.ts @@ -62,6 +62,9 @@ const DEFAULT_THRESHOLDS: QualityThresholds = { recommendInternationalComparison: false, recommendEconomicContext: true, recommendSCBContext: true, + recommendWhatHappensNext: true, + recommendWinnersLosers: true, + minSpecificClaims: 3, }; /** @@ -179,6 +182,116 @@ function hasWhyThisMatters(content: string): boolean { return patterns.some((pattern: RegExp) => pattern.test(content)); } +/** + * Detect "What Happens Next" timeline section. + * Looks for the rendered section class or common heading variants in 14 languages. + * + * @param content - HTML content of article + * @returns True if the section is present + */ +function hasWhatHappensNext(content: string): boolean { + const patterns: readonly RegExp[] = [ + /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, + /was\s+passiert\s+als\s+nächstes/i, + /qué\s+sucede\s+a\s+continuación/i, + /次のステップ/, + /다음\s+단계/, + /下一步/, + ]; + return patterns.some((p: RegExp) => p.test(content)); +} + +/** + * Detect "Winners & Losers" analysis section. + * Looks for the rendered section class or common heading variants in 14 languages. + * + * @param content - HTML content of article + * @returns True if the section is present + */ +function hasWinnersLosers(content: string): boolean { + const patterns: readonly RegExp[] = [ + /class=["'][^"']*\bwinners-losers\b/, + /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, + /ganadores\s+y\s+perdedores/i, + /勝者と敗者/, + /승자와\s+패자/, + /赢家与输家/, + ]; + return patterns.some((p: RegExp) => p.test(content)); +} + +/** + * Count approximate words in a specific HTML section identified by its CSS class. + * Returns 0 if the section is not found. + * + * @param content - Full HTML content of article + * @param sectionClass - CSS class of the target section element + * @returns Estimated word count within the section + */ +function countSectionWords(content: string, sectionClass: string): number { + // Extract content between opening tag with matching class and matching closing tag + const escapedClass = sectionClass.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const sectionPattern = new RegExp( + `<(?:section|div)[^>]*class="[^"]*${escapedClass}[^"]*"[^>]*>([\\s\\S]*?)`, + 'i', + ); + const match = content.match(sectionPattern); + if (!match?.[1]) return 0; + const text = match[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + return text.split(' ').filter((w: string) => w.length > 0).length; +} + +/** + * Validate that the lede paragraph has sufficient depth (minimum 30 words). + * A lede shorter than this is likely a stub or template placeholder. + * + * @param content - HTML content of article + * @returns True if lede meets the minimum word count + */ +function hasSubstantialLede(content: string): boolean { + const ledeMatch = content.match(/]*class=["'][^"']*\blede\b[^"']*["'][^>]*>([\s\S]*?)<\/p>/i); + if (!ledeMatch?.[1]) return false; + const text = ledeMatch[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + return text.split(' ').filter((w: string) => w.length > 0).length >= 30; +} + +/** + * Count specific factual claims with cited evidence. + * Looks for patterns that indicate a verifiable, specific claim: + * - Explicit document references (Prop., Bet., Mot., IP) + * - Percentage or currency figures with context + * - Named actors with attributed statements + * + * @param content - HTML content of article + * @returns Number of detected specific claims + */ +function countSpecificClaims(content: string): number { + const text = stripHtml(content); + let count = 0; + + // Document references + DOCUMENT_ID_PATTERNS.forEach((pattern: RegExp) => { + const matches = text.match(pattern); + count += matches ? matches.length : 0; + }); + + // Percentage figures with surrounding context (e.g. "increased by 12%") + 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)") + 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; + + return count; +} + /** * Detect historical context * @@ -337,6 +450,10 @@ export async function enhanceArticleQuality( hasLanguageSwitcher: hasLanguageSwitcher(content), hasArticleTopNav: hasArticleTopNav(content), hasBackToNews: hasBackToNews(content), + hasWhatHappensNext: hasWhatHappensNext(content), + hasWinnersLosers: hasWinnersLosers(content), + specificClaimsCount: countSpecificClaims(content), + hasSubstantialLede: hasSubstantialLede(content), }; // Calculate overall score @@ -369,6 +486,15 @@ export async function enhanceArticleQuality( issues.push('Missing required historical context (at least one historical comparison required)'); } + // Content depth validation — minimum specific claims + const minClaims = options.minSpecificClaims ?? 0; + if (minClaims > 0 && (metrics.specificClaimsCount ?? 0) < minClaims) { + issues.push( + `Only ${metrics.specificClaimsCount ?? 0} specific verifiable claims detected (need ${minClaims}). ` + + 'Add document references, percentage figures, or named actors with party attributions.', + ); + } + // Separate warnings (recommendations) from blocking failures const warnings: string[] = []; @@ -389,6 +515,18 @@ export async function enhanceArticleQuality( warnings.push('Recommended: Add Swedish statistical context (SCB official statistics)'); } + if (options.recommendWhatHappensNext && !metrics.hasWhatHappensNext) { + warnings.push('Recommended: Add "What Happens Next" timeline section with legislative pipeline dates'); + } + + if (options.recommendWinnersLosers && !metrics.hasWinnersLosers) { + warnings.push('Recommended: Add "Winners & Losers" section naming political actors with evidence'); + } + + if (!metrics.hasSubstantialLede) { + warnings.push('Lede paragraph appears thin (< 30 words) — consider expanding with the most newsworthy fact'); + } + if (metrics.hasStatisticalClaims) { warnings.push('Info: Article contains statistical claims — consider adding fact-check section'); } @@ -517,11 +655,16 @@ export { countPartyPerspectives, countCrossReferences, hasWhyThisMatters, + hasWhatHappensNext, + hasWinnersLosers, hasHistoricalContext, hasInternationalComparison, hasLanguageSwitcher, hasArticleTopNav, hasBackToNews, + countSpecificClaims, + hasSubstantialLede, + countSectionWords, calculateQualityScore, DEFAULT_THRESHOLDS, }; diff --git a/scripts/article-template/constants.ts b/scripts/article-template/constants.ts index 0ad747eac2..f401f2d0f1 100644 --- a/scripts/article-template/constants.ts +++ b/scripts/article-template/constants.ts @@ -148,6 +148,109 @@ export const WATCH_SECTION_TITLES: Record = { zh: '本周关注要点' }; +/** + * "What Happens Next" timeline section titles for all 14 languages. + * Renders the legislative pipeline with dates and significance indicators. + */ +export const WHAT_HAPPENS_NEXT_TITLES: Record = { + en: 'What Happens Next', + sv: 'Vad händer härnäst', + da: 'Hvad sker der nu', + no: 'Hva skjer videre', + fi: 'Mitä tapahtuu seuraavaksi', + de: 'Was passiert als Nächstes', + fr: 'La suite des événements', + es: 'Qué sucede a continuación', + nl: 'Wat gebeurt er nu', + ar: 'ماذا يحدث بعد ذلك', + he: 'מה קורה בהמשך', + ja: '次のステップ', + ko: '다음 단계', + zh: '下一步将会发生什么' +}; + +/** + * "Winners & Losers" analysis section titles for all 14 languages. + * Names political actors with evidence-backed outcome assessments. + */ +export const WINNERS_LOSERS_TITLES: Record = { + en: 'Winners & Losers', + sv: 'Vinnare och förlorare', + da: 'Vindere og tabere', + no: 'Vinnere og tapere', + fi: 'Voittajat ja häviäjät', + de: 'Gewinner und Verlierer', + fr: 'Gagnants et perdants', + es: 'Ganadores y perdedores', + nl: 'Winnaars en verliezers', + ar: 'الرابحون والخاسرون', + he: 'מנצחים ומפסידים', + ja: '勝者と敗者', + ko: '승자와 패자', + zh: '赢家与输家' +}; + +/** + * FAQ section titles for all 14 languages. + */ +export const FAQ_SECTION_TITLES: Record = { + en: 'Frequently Asked Questions', + sv: 'Vanliga frågor', + da: 'Ofte stillede spørgsmål', + no: 'Vanlige spørsmål', + fi: 'Usein kysytyt kysymykset', + de: 'Häufig gestellte Fragen', + fr: 'Questions fréquemment posées', + es: 'Preguntas frecuentes', + nl: 'Veelgestelde vragen', + ar: 'الأسئلة المتكررة', + he: 'שאלות נפוצות', + ja: 'よくある質問', + ko: '자주 묻는 질문', + zh: '常见问题' +}; + +/** + * Significance labels for "What Happens Next" timeline items. + * Maps significance level to a localized label (High / Medium / Low). + */ +export const SIGNIFICANCE_LABELS: Record> = { + en: { high: 'High', medium: 'Medium', low: 'Low' }, + sv: { high: 'Hög', medium: 'Medel', low: 'Låg' }, + da: { high: 'Høj', medium: 'Mellem', low: 'Lav' }, + no: { high: 'Høy', medium: 'Middels', low: 'Lav' }, + fi: { high: 'Korkea', medium: 'Keskitaso', low: 'Matala' }, + de: { high: 'Hoch', medium: 'Mittel', low: 'Niedrig' }, + fr: { high: 'Élevé', medium: 'Moyen', low: 'Faible' }, + es: { high: 'Alto', medium: 'Medio', low: 'Bajo' }, + nl: { high: 'Hoog', medium: 'Gemiddeld', low: 'Laag' }, + ar: { high: 'عالٍ', medium: 'متوسط', low: 'منخفض' }, + he: { high: 'גבוה', medium: 'בינוני', low: 'נמוך' }, + ja: { high: '高', medium: '中', low: '低' }, + ko: { high: '높음', medium: '보통', low: '낮음' }, + zh: { high: '高', medium: '中', low: '低' }, +}; + +/** + * Winners/Losers outcome labels for all 14 languages. + */ +export const OUTCOME_LABELS: Record> = { + en: { wins: 'Wins', loses: 'Loses', mixed: 'Mixed' }, + sv: { wins: 'Vinner', loses: 'Förlorar', mixed: 'Blandat' }, + da: { wins: 'Vinder', loses: 'Taber', mixed: 'Blandet' }, + no: { wins: 'Vinner', loses: 'Taper', mixed: 'Blandet' }, + fi: { wins: 'Voittaa', loses: 'Häviää', mixed: 'Sekoitettu' }, + de: { wins: 'Gewinnt', loses: 'Verliert', mixed: 'Gemischt' }, + fr: { wins: 'Gagne', loses: 'Perd', mixed: 'Mitigé' }, + es: { wins: 'Gana', loses: 'Pierde', mixed: 'Mixto' }, + nl: { wins: 'Wint', loses: 'Verliest', mixed: 'Gemengd' }, + ar: { wins: 'يفوز', loses: 'يخسر', mixed: 'مختلط' }, + he: { wins: 'מנצח', loses: 'מפסיד', mixed: 'מעורב' }, + ja: { wins: '勝ち', loses: '負け', mixed: '混在' }, + ko: { wins: '승리', loses: '패배', mixed: '혼합' }, + zh: { wins: '获益', loses: '受损', mixed: '复杂' }, +}; + /** * Locale map for Intl date formatting across all 14 languages */ diff --git a/scripts/article-template/helpers.ts b/scripts/article-template/helpers.ts index d2e1040dae..e074efe05f 100644 --- a/scripts/article-template/helpers.ts +++ b/scripts/article-template/helpers.ts @@ -10,12 +10,18 @@ import type { Language } from '../types/language.js'; import type { EventGridItem, WatchPoint } from '../types/article.js'; +import type { WhatHappensNextItem, WinnersLosersEntry, FAQItem } from '../types/editorial.js'; import type { BreadcrumbLabels, FooterLabelSet } from '../types/content.js'; import { BREADCRUMB_TRANSLATIONS, FOOTER_LABELS, EVENT_CALENDAR_TITLES, WATCH_SECTION_TITLES, + WHAT_HAPPENS_NEXT_TITLES, + WINNERS_LOSERS_TITLES, + FAQ_SECTION_TITLES, + SIGNIFICANCE_LABELS, + OUTCOME_LABELS, LOCALE_MAP, LANG_DISPLAY, SITE_FOOTER_LABELS, @@ -190,6 +196,131 @@ export function generateArticleLanguageSwitcher(baseSlug: string, currentLang: L return ` `; } +/** + * Generate the "What Happens Next" timeline section. + * + * Renders an ordered list of upcoming legislative pipeline dates with + * significance indicators (high / medium / low). Items with no `date` are + * omitted. The section has class `what-happens-next` so the quality enhancer + * and Schema.org generator can locate it. + * + * @param items - Ordered list of upcoming events + * @param lang - Article language (determines heading and label text) + * @returns HTML `
` element string + */ +export function generateWhatHappensNextSection( + items: ReadonlyArray, + lang: Language = 'en', +): string { + if (items.length === 0) return ''; + const title: string = WHAT_HAPPENS_NEXT_TITLES[lang] || WHAT_HAPPENS_NEXT_TITLES.en; + const sigLabels = SIGNIFICANCE_LABELS[lang] || SIGNIFICANCE_LABELS.en; + + const rows = items + .filter(item => item.date && item.event) + .map(item => { + const sigClass = `significance-${item.significance}`; + const sigLabel = sigLabels[item.significance]; + return `
  • + + ${escapeHtml(item.event)} + ${escapeHtml(sigLabel)} +
  • `; + }) + .join('\n'); + + if (!rows) return ''; + + return ` +
    +

    ${escapeHtml(title)}

    +
      +${rows} +
    +
    `; +} + +/** + * Generate the "Winners & Losers" political analysis section. + * + * Each entry names an actor, classifies their outcome (wins / loses / mixed), + * and provides a one-sentence evidence string. The section has class + * `winners-losers` so downstream validators can detect it. + * + * @param entries - Array of actor outcome entries + * @param lang - Article language + * @returns HTML `
    ` element string + */ +export function generateWinnersLosersSection( + entries: ReadonlyArray, + lang: Language = 'en', +): string { + if (entries.length === 0) return ''; + const title: string = WINNERS_LOSERS_TITLES[lang] || WINNERS_LOSERS_TITLES.en; + const outcomeLabels = OUTCOME_LABELS[lang] || OUTCOME_LABELS.en; + + const rows = entries + .filter(e => e.actor && e.evidence) + .map(e => { + const outcomeClass = `outcome-${e.outcome}`; + const outcomeLabel = outcomeLabels[e.outcome]; + return `
  • + ${escapeHtml(e.actor)} + ${escapeHtml(outcomeLabel)} + ${escapeHtml(e.evidence)} +
  • `; + }) + .join('\n'); + + if (!rows) return ''; + + return ` +
    +

    ${escapeHtml(title)}

    +
      +${rows} +
    +
    `; +} + +/** + * Generate the FAQ section HTML. + * + * Renders a `
    ` with question/answer pairs in a + * definition-list structure. This HTML is used for in-page display; the + * matching Schema.org FAQPage structured data is emitted separately in + * `generateArticleHTML`. + * + * @param items - Array of FAQ items + * @param lang - Article language + * @returns HTML `
    ` element string (empty string if no items) + */ +export function generateFaqSection( + items: ReadonlyArray, + lang: Language = 'en', +): string { + if (items.length === 0) return ''; + const title: string = FAQ_SECTION_TITLES[lang] || FAQ_SECTION_TITLES.en; + + const pairs = items + .filter(item => item.question && item.answer) + .map(item => `
    +
    ${escapeHtml(item.question)}
    +
    ${escapeHtml(item.answer)}
    +
    `) + .join('\n'); + + if (!pairs) return ''; + + return ` +
    +

    ${escapeHtml(title)}

    +
    +${pairs} +
    +
    `; +} + /** * 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 ? `