From 4cf2fde51f1ad75b9053cb44fdfe78c0e104afd7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:01:12 +0000 Subject: [PATCH 01/11] Initial plan From 29f15c63cb73a3844690dbee3ece8fbe5f34a5a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:16:53 +0000 Subject: [PATCH 02/11] feat(monthly-review): add multi-month trends, party rankings, and legislative efficiency metrics - Add MonthlyMetrics interface to data-transformers/types.ts - Add generateMonthlyReviewContent to content-generators.ts with 4 new sections in all 14 languages: Month in Numbers, Party Performance Rankings, Legislative Efficiency, and Strategic Outlook - Route monthly-review type to the new generator in data-transformers/index.ts - Fetch previous 2 months for 3-month rolling trend analysis in monthly-review.ts - Compute party rankings from motion/speech parti fields - Compute legislative efficiency rate (reports / propositions) - Extend MonthlyReviewValidationResult with hasPartyRankings, hasLegislativeEfficiency, hasMonthInNumbers fields and corresponding check functions - Add 6 new tests covering all new sections and features Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- .../data-transformers/content-generators.ts | 184 +++++++++++++++++- scripts/data-transformers/index.ts | 5 +- scripts/data-transformers/types.ts | 30 +++ scripts/news-types/monthly-review.ts | 94 ++++++++- tests/news-types/monthly-review.test.ts | 73 +++++++ 5 files changed, 383 insertions(+), 3 deletions(-) diff --git a/scripts/data-transformers/content-generators.ts b/scripts/data-transformers/content-generators.ts index 690926df..0895d7c0 100644 --- a/scripts/data-transformers/content-generators.ts +++ b/scripts/data-transformers/content-generators.ts @@ -11,7 +11,7 @@ import { escapeHtml } from '../html-utils.js'; import type { Language } from '../types/language.js'; -import type { ArticleContentData, WeekAheadData, RawDocument } from './types.js'; +import type { ArticleContentData, WeekAheadData, RawDocument, MonthlyMetrics } from './types.js'; import { getPillarTransition } from '../editorial-pillars.js'; import { L, @@ -1061,3 +1061,185 @@ export function generateGenericContent(data: ArticleContentData, lang: Language return content; } + +/** + * Per-language label maps for the monthly-review-specific sections. + * Covers all 14 supported languages. + */ +const MONTHLY_LABELS: Readonly<{ + monthInNumbers: Record; + partyRankings: Record; + legislativeEfficiency: Record; + strategicOutlook: Record; + totalDocuments: Record; + reports: Record; + propositions: Record; + motions: Record; + speeches: Record; + efficiencyRate: Record; + trendVsPrevMonth: Record; + trendVs2MonthsAgo: Record; + activityRank: Record; + motionsFiled: Record; + speechesDelivered: Record; + coalitionStabilityOutlook: Record; + motionDenialContext: Record; +}> = { + monthInNumbers: { en: '📊 Month in Numbers', sv: '📊 Månaden i siffror', da: '📊 Måneden i tal', no: '📊 Måneden i tall', fi: '📊 Kuukausi numeroina', de: '📊 Der Monat in Zahlen', fr: '📊 Le mois en chiffres', es: '📊 El mes en cifras', nl: '📊 De maand in cijfers', ar: '📊 الشهر بالأرقام', he: '📊 החודש במספרים', ja: '📊 今月の統計', ko: '📊 이달의 통계', zh: '📊 本月数字' }, + partyRankings: { en: '🏆 Party Performance Rankings', sv: '🏆 Partiernas prestationsrankning', da: '🏆 Partiernes præstationsrangering', no: '🏆 Partienes prestasjonsranking', fi: '🏆 Puolueiden suoritusranking', de: '🏆 Partei-Leistungsranking', fr: '🏆 Classement des performances des partis', es: '🏆 Clasificación de rendimiento de partidos', nl: '🏆 Partijprestaties ranking', ar: '🏆 تصنيف أداء الأحزاب', he: '🏆 דירוג ביצועי מפלגות', ja: '🏆 政党パフォーマンスランキング', ko: '🏆 정당 성과 순위', zh: '🏆 政党绩效排名' }, + legislativeEfficiency: { en: '⚖️ Legislative Efficiency', sv: '⚖️ Lagstiftningseffektivitet', da: '⚖️ Lovgivningseffektivitet', no: '⚖️ Lovgivningseffektivitet', fi: '⚖️ Lainsäädäntötehokkuus', de: '⚖️ Gesetzgebungseffizienz', fr: '⚖️ Efficacité législative', es: '⚖️ Eficiencia legislativa', nl: '⚖️ Wetgevingsefficiëntie', ar: '⚖️ الكفاءة التشريعية', he: '⚖️ יעילות חקיקתית', ja: '⚖️ 立法効率', ko: '⚖️ 입법 효율성', zh: '⚖️ 立法效率' }, + strategicOutlook: { en: '🔭 Strategic Outlook', sv: '🔭 Strategisk utsikt', da: '🔭 Strategisk udsigt', no: '🔭 Strategisk utsikt', fi: '🔭 Strateginen näkymä', de: '🔭 Strategischer Ausblick', fr: '🔭 Perspectives stratégiques', es: '🔭 Perspectiva estratégica', nl: '🔭 Strategisch vooruitzicht', ar: '🔭 التوقعات الاستراتيجية', he: '🔭 סיכוי אסטרטגי', ja: '🔭 戦略的展望', ko: '🔭 전략적 전망', zh: '🔭 战略展望' }, + totalDocuments: { en: 'Total documents', sv: 'Totalt antal dokument', da: 'Dokumenter i alt', no: 'Totalt antall dokumenter', fi: 'Asiakirjoja yhteensä', de: 'Dokumente gesamt', fr: 'Total des documents', es: 'Total de documentos', nl: 'Totaal documenten', ar: 'إجمالي الوثائق', he: 'סך כל המסמכים', ja: '総文書数', ko: '총 문서 수', zh: '文件总数' }, + reports: { en: 'Committee reports', sv: 'Betänkanden', da: 'Betænkninger', no: 'Innstillinger', fi: 'Mietinnöt', de: 'Ausschussberichte', fr: 'Rapports de commission', es: 'Informes de comité', nl: 'Commissierapporten', ar: 'تقارير اللجان', he: 'דוחות ועדה', ja: '委員会報告', ko: '위원회 보고서', zh: '委员会报告' }, + propositions: { en: 'Government propositions', sv: 'Propositioner', da: 'Lovforslag', no: 'Proposisjoner', fi: 'Hallituksen esitykset', de: 'Regierungsvorlagen', fr: 'Propositions gouvernementales', es: 'Proposiciones gubernamentales', nl: 'Regeringsvoorstellen', ar: 'المقترحات الحكومية', he: 'הצעות ממשלה', ja: '政府提案', ko: '정부 법안', zh: '政府提案' }, + motions: { en: 'Opposition motions', sv: 'Motioner', da: 'Forslag', no: 'Forslag', fi: 'Aloitteet', de: 'Anträge', fr: "Motions de l'opposition", es: 'Mociones de la oposición', nl: 'Oppositiemoties', ar: 'مقترحات المعارضة', he: 'הצעות האופוזיציה', ja: '反対動議', ko: '야당 동의', zh: '反对党动议' }, + speeches: { en: 'Chamber speeches', sv: 'Anföranden', da: 'Taler', no: 'Taler', fi: 'Puheet', de: 'Parlamentsreden', fr: 'Discours parlementaires', es: 'Discursos parlamentarios', nl: 'Parlementaire toespraken', ar: 'الخطب البرلمانية', he: 'נאומים בכנסת', ja: '議会演説', ko: '의회 연설', zh: '议会演讲' }, + efficiencyRate: { en: 'Committee throughput rate', sv: 'Utskottets genomströmningsgrad', da: 'Udvalgets gennemstrømningshastighed', no: 'Komiteens gjennomstrømningshastighet', fi: 'Valiokunnan käsittelyaste', de: 'Ausschuss-Durchsatzrate', fr: 'Taux de traitement en commission', es: 'Tasa de rendimiento del comité', nl: 'Commissiedoorvoersnelheid', ar: 'معدل إنتاجية اللجنة', he: 'קצב תפוקת הוועדה', ja: '委員会処理率', ko: '위원회 처리율', zh: '委员会处理率' }, + trendVsPrevMonth: { en: 'vs. previous month', sv: 'jmf föregående månad', da: 'ift. forrige måned', no: 'vs. forrige måned', fi: 'vs. edellinen kuukausi', de: 'vs. Vormonat', fr: 'vs. mois précédent', es: 'vs. mes anterior', nl: 'vs. vorige maand', ar: 'مقارنة بالشهر السابق', he: 'לעומת החודש הקודם', ja: '前月比', ko: '전월 대비', zh: '对比上个月' }, + trendVs2MonthsAgo: { en: '3-month rolling avg', sv: '3 månaders glidande snitt', da: '3-måneders glidende gennemsnit', no: '3 måneders glidende gjennomsnitt', fi: '3 kuukauden liukuva keskiarvo', de: '3-Monats-Gleitdurchschnitt', fr: 'Moyenne mobile sur 3 mois', es: 'Promedio móvil de 3 meses', nl: '3-maands voortschrijdend gemiddelde', ar: 'متوسط متحرك 3 أشهر', he: 'ממוצע נע 3 חודשים', ja: '3ヶ月移動平均', ko: '3개월 이동 평균', zh: '3个月滚动平均' }, + activityRank: { en: 'Activity rank', sv: 'Aktivitetsrankning', da: 'Aktivitetsrangering', no: 'Aktivitetsrangering', fi: 'Aktiivisuusranking', de: 'Aktivitätsrang', fr: "Rang d'activité", es: 'Rango de actividad', nl: 'Activiteitsrang', ar: 'تصنيف النشاط', he: 'דירוג פעילות', ja: '活動ランク', ko: '활동 순위', zh: '活动排名' }, + motionsFiled: { en: 'motions', sv: 'motioner', da: 'forslag', no: 'forslag', fi: 'aloitetta', de: 'Anträge', fr: 'motions', es: 'mociones', nl: 'moties', ar: 'مقترحات', he: 'הצעות', ja: '動議', ko: '동의', zh: '动议' }, + speechesDelivered: { en: 'speeches', sv: 'anföranden', da: 'taler', no: 'taler', fi: 'puhetta', de: 'Reden', fr: 'discours', es: 'discursos', nl: 'toespraken', ar: 'خطب', he: 'נאומים', ja: '演説', ko: '연설', zh: '演讲' }, + coalitionStabilityOutlook: { en: 'Coalition stability outlook', sv: 'Koalitionsstabilitetsutsikt', da: 'Koalitionsstabilitetsudsigt', no: 'Koalisjonsstabilitetsutsikt', fi: 'Koalitiostabiliteettiarvio', de: 'Koalitionsstabilitätsausblick', fr: 'Perspectives de stabilité de la coalition', es: 'Perspectiva de estabilidad de la coalición', nl: 'Coalitie stabiliteitsoverzicht', ar: 'توقعات استقرار الائتلاف', he: 'תחזית יציבות הקואליציה', ja: '連立安定性の展望', ko: '연립 안정성 전망', zh: '联合政府稳定性展望' }, + motionDenialContext: { en: 'Opposition motions historically pass at a low rate — parliamentary majority dynamics shape legislative outcomes.', sv: 'Oppositionsmotioner har historiskt sett låg bifallsfrekvens — parlamentariska majoritetsförhållanden styr lagstiftningsutfallen.', da: 'Oppositionsforslag har historisk set lav vedtagelsesfrekvens — parlamentariske flertalsdynamikker former lovgivningsresultaterne.', no: 'Opposisjonsforslag har historisk lav vedtaksrate — parlamentariske flertallsdynamikker former lovgivningsutfall.', fi: 'Oppositioaloitteilla on historiallisesti matala hyväksymisaste — parlamentin enemmistödynamiikka muokkaa lainsäädäntötuloksia.', de: 'Oppositionsanträge haben historisch geringe Erfolgsquoten — Mehrheitsverhältnisse im Parlament prägen die Gesetzgebungsergebnisse.', fr: "Les motions de l'opposition ont historiquement un faible taux d'adoption — la dynamique des majorités parlementaires façonne les résultats législatifs.", es: 'Las mociones de la oposición tienen históricamente bajas tasas de aprobación — la dinámica de la mayoría parlamentaria da forma a los resultados legislativos.', nl: 'Oppositiemoties hebben historisch gezien lage doorvoerpercentages — meerderheidsparlementsynamiek bepaalt wetgevingsresultaten.', ar: 'تتمتع مقترحات المعارضة تاريخياً بمعدلات مرور منخفضة — تشكّل ديناميكيات أغلبية البرلمان نتائج التشريع.', he: 'להצעות האופוזיציה יש היסטורית שיעורי מעבר נמוכים — דינמיקת הרוב הפרלמנטרית מעצבת תוצאות חקיקה.', ja: '野党動議は歴史的に低い通過率を持つ — 議会の多数決ダイナミクスが立法結果を形成する。', ko: '야당 동의는 역사적으로 낮은 통과율을 보임 — 의회 다수결 역학이 입법 결과를 형성함.', zh: '反对党动议历史上通过率较低——议会多数动态决定立法结果。' }, +}; + +/** Return the label for a language key, falling back to English. */ +function ml(lang: Language | string, key: keyof typeof MONTHLY_LABELS): string { + const map = MONTHLY_LABELS[key] as Record; + return map[lang as string] ?? map['en'] ?? key; +} + +/** + * Generate the "Month in Numbers" statistical summary section. + * Renders document type counts, speech count, and month-over-month trend. + */ +function generateMonthInNumbers(metrics: MonthlyMetrics, lang: Language | string): string { + const prevDiff = metrics.totalDocuments - metrics.previousMonthDocCount; + const prevSign = prevDiff > 0 ? '+' : ''; + const rollingAvg = Math.round((metrics.totalDocuments + metrics.previousMonthDocCount + metrics.twoMonthsAgoDocCount) / 3); + + let html = `\n

${escapeHtml(ml(lang, 'monthInNumbers'))}

\n`; + html += `
\n
    \n`; + html += `
  • ${escapeHtml(ml(lang, 'totalDocuments'))}: ${metrics.totalDocuments}
  • \n`; + html += `
  • ${escapeHtml(ml(lang, 'reports'))}: ${metrics.reportCount}
  • \n`; + html += `
  • ${escapeHtml(ml(lang, 'propositions'))}: ${metrics.propositionCount}
  • \n`; + html += `
  • ${escapeHtml(ml(lang, 'motions'))}: ${metrics.motionCount}
  • \n`; + html += `
  • ${escapeHtml(ml(lang, 'speeches'))}: ${metrics.speechCount}
  • \n`; + if (metrics.previousMonthDocCount > 0) { + html += `
  • ${escapeHtml(ml(lang, 'trendVsPrevMonth'))}: ${prevSign}${prevDiff}
  • \n`; + } + if (metrics.twoMonthsAgoDocCount > 0 || metrics.previousMonthDocCount > 0) { + html += `
  • ${escapeHtml(ml(lang, 'trendVs2MonthsAgo'))}: ${rollingAvg}
  • \n`; + } + html += `
\n
\n`; + return html; +} + +/** + * Generate the "Party Performance Rankings" section. + * Ranks parties by combined legislative and debate activity. + */ +function generatePartyRankings(metrics: MonthlyMetrics, lang: Language | string): string { + if (metrics.partyRankings.length === 0) return ''; + + let html = `\n

${escapeHtml(ml(lang, 'partyRankings'))}

\n`; + html += `
\n
    \n`; + // Show top 8 parties — matches the 8 Swedish parliamentary parties and keeps the list scannable + metrics.partyRankings.slice(0, 8).forEach((entry, idx) => { + const motionLabel = ml(lang, 'motionsFiled'); + const speechLabel = ml(lang, 'speechesDelivered'); + const rankLabel = ml(lang, 'activityRank'); + html += `
  1. ${escapeHtml(entry.party)} — `; + html += `${entry.motionCount} ${escapeHtml(motionLabel)}, `; + html += `${entry.speechCount} ${escapeHtml(speechLabel)}`; + if (idx === 0) html += ` (${escapeHtml(rankLabel)} #1)`; + html += `
  2. \n`; + }); + html += `
\n
\n`; + return html; +} + +/** + * Generate the "Legislative Efficiency" metrics section. + * Shows committee throughput rate and opposition motion context. + */ +function generateLegislativeEfficiency(metrics: MonthlyMetrics, lang: Language | string): string { + let html = `\n

${escapeHtml(ml(lang, 'legislativeEfficiency'))}

\n`; + html += `
\n
    \n`; + + if (metrics.propositionCount > 0) { + const pct = Math.round(metrics.legislativeEfficiencyRate * 100); + html += `
  • ${escapeHtml(ml(lang, 'efficiencyRate'))}: ${pct}% (${metrics.reportCount} / ${metrics.propositionCount})
  • \n`; + } + + html += `
  • ${escapeHtml(ml(lang, 'motionDenialContext'))}
  • \n`; + html += `
\n
\n`; + return html; +} + +/** + * Generate the "Strategic Outlook" section. + * Connects current-month trends to coalition stability and upcoming dynamics. + */ +function generateStrategicOutlook(metrics: MonthlyMetrics, data: ArticleContentData, lang: Language | string): string { + const cia = data.ciaContext; + let html = `\n

${escapeHtml(ml(lang, 'strategicOutlook'))}

\n`; + html += `
\n
    \n`; + + // Activity trajectory based on month-over-month comparison + if (metrics.previousMonthDocCount > 0) { + const isIncreasing = metrics.totalDocuments > metrics.previousMonthDocCount; + const trajectoryTemplates: Record string> = { + en: inc => inc ? 'Legislative activity is accelerating — document volume is up month-over-month.' : 'Legislative activity is decelerating — document volume is down month-over-month.', + sv: inc => inc ? 'Riksdagsaktiviteten accelererar — dokumentvolymen ökar månad för månad.' : 'Riksdagsaktiviteten avtar — dokumentvolymen minskar månad för månad.', + da: inc => inc ? 'Den lovgivende aktivitet accelererer — dokumentmængden stiger måned for måned.' : 'Den lovgivende aktivitet aftager — dokumentmængden falder måned for måned.', + no: inc => inc ? 'Den lovgivende aktiviteten akselererer — dokumentvolumet øker måned for måned.' : 'Den lovgivende aktiviteten avtar — dokumentvolumet synker måned for måned.', + fi: inc => inc ? 'Lainsäädäntöaktiivisuus kiihtyy — asiakirjojen määrä kasvaa kuukaudesta toiseen.' : 'Lainsäädäntöaktiivisuus hidastuu — asiakirjojen määrä laskee kuukaudesta toiseen.', + de: inc => inc ? 'Die Gesetzgebungsaktivität nimmt zu — das Dokumentenvolumen steigt von Monat zu Monat.' : 'Die Gesetzgebungsaktivität nimmt ab — das Dokumentenvolumen sinkt von Monat zu Monat.', + fr: inc => inc ? "L'activité législative s'accélère — le volume documentaire augmente d'un mois sur l'autre." : "L'activité législative ralentit — le volume documentaire diminue d'un mois sur l'autre.", + es: inc => inc ? 'La actividad legislativa está acelerando — el volumen de documentos aumenta mes a mes.' : 'La actividad legislativa está desacelerando — el volumen de documentos disminuye mes a mes.', + nl: inc => inc ? 'De wetgevende activiteit neemt toe — het documentvolume stijgt maand over maand.' : 'De wetgevende activiteit neemt af — het documentvolume daalt maand over maand.', + ar: inc => inc ? 'النشاط التشريعي يتسارع — حجم الوثائق يزداد شهراً بعد شهر.' : 'النشاط التشريعي يتباطأ — حجم الوثائق يتراجع شهراً بعد شهر.', + he: inc => inc ? 'הפעילות החקיקתית מתאיצה — נפח המסמכים עולה מחודש לחודש.' : 'הפעילות החקיקתית מואטת — נפח המסמכים יורד מחודש לחודש.', + ja: inc => inc ? '立法活動が加速しています — 文書量が前月比で増加しています。' : '立法活動が減速しています — 文書量が前月比で減少しています。', + ko: inc => inc ? '입법 활동이 가속화되고 있습니다 — 문서량이 전월 대비 증가하고 있습니다.' : '입법 활동이 감속되고 있습니다 — 문서량이 전월 대비 감소하고 있습니다.', + zh: inc => inc ? '立法活动正在加速——文件数量环比增加。' : '立法活动正在减速——文件数量环比减少。', + }; + const tpl = trajectoryTemplates[lang as string]; + const trajectoryText = tpl ? tpl(isIncreasing) : (trajectoryTemplates['en'] ?? (inc => inc ? 'Legislative activity is accelerating.' : 'Legislative activity is decelerating.'))(isIncreasing); + html += `
  • ${escapeHtml(trajectoryText)}
  • \n`; + } + + // Coalition stability signal from CIA context + if (cia) { + const stabilityLabel = ml(lang, 'coalitionStabilityOutlook'); + const stabilityScore = cia.coalitionStability.stabilityScore; + const riskLevel = cia.coalitionStability.riskLevel; + html += `
  • ${escapeHtml(stabilityLabel)}: ${stabilityScore}/100 (${escapeHtml(riskLevel)})
  • \n`; + } + + html += `
\n
\n`; + return html; +} + +/** + * Generate a rich monthly review article with "Month in Numbers", party rankings, + * legislative efficiency metrics, and a strategic outlook section. + * Falls back to generic content when monthlyMetrics is absent. + */ +export function generateMonthlyReviewContent(data: ArticleContentData, lang: Language | string): string { + // Base document analysis (same as weekly review / generic) + let content = generateGenericContent(data, lang); + + const metrics = data.monthlyMetrics; + if (!metrics) return content; + + // Append monthly-specific sections + content += generateMonthInNumbers(metrics, lang); + content += generatePartyRankings(metrics, lang); + content += generateLegislativeEfficiency(metrics, lang); + content += generateStrategicOutlook(metrics, data, lang); + + return content; +} diff --git a/scripts/data-transformers/index.ts b/scripts/data-transformers/index.ts index dcba80e8..fe982e3a 100644 --- a/scripts/data-transformers/index.ts +++ b/scripts/data-transformers/index.ts @@ -25,6 +25,7 @@ export type { CIAContext, WeekAheadData, ArticleContentData, + MonthlyMetrics, } from './types.js'; // ── Re-export constants ──────────────────────────────────────────────────── @@ -65,6 +66,7 @@ import { generatePropositionsContent, generateMotionsContent, generateGenericContent, + generateMonthlyReviewContent, } from './content-generators.js'; /** @@ -92,8 +94,9 @@ export function generateArticleContent( return generatePropositionsContent(data, lang); case 'motions': return generateMotionsContent(data, lang); - case 'weekly-review': case 'monthly-review': + return generateMonthlyReviewContent(data, lang); + case 'weekly-review': case 'breaking': default: return generateGenericContent(data, lang); diff --git a/scripts/data-transformers/types.ts b/scripts/data-transformers/types.ts index 3193ec50..cbd9d81e 100644 --- a/scripts/data-transformers/types.ts +++ b/scripts/data-transformers/types.ts @@ -80,6 +80,34 @@ export interface WeekAheadData { interpellations?: RawDocument[]; } +/** + * Monthly metrics for trend analysis, party rankings, and legislative efficiency. + * Computed in monthly-review.ts and consumed by generateMonthlyReviewContent. + */ +export interface MonthlyMetrics { + /** Total documents processed this month */ + totalDocuments: number; + /** Number of committee reports (betänkanden) */ + reportCount: number; + /** Number of government propositions */ + propositionCount: number; + /** Number of parliamentary motions */ + motionCount: number; + /** Number of speeches (anföranden) */ + speechCount: number; + /** Previous month's total document count (for trend) */ + previousMonthDocCount: number; + /** Two months ago total document count (for rolling average) */ + twoMonthsAgoDocCount: number; + /** Party activity rankings sorted by total activity (motions + speeches) */ + partyRankings: Array<{ party: string; motionCount: number; speechCount: number }>; + /** + * Legislative efficiency rate: committee reports divided by propositions (0–1). + * Higher values indicate faster committee processing. + */ + legislativeEfficiencyRate: number; +} + /** Article generation data */ export interface ArticleContentData { events?: RawCalendarEvent[]; @@ -91,4 +119,6 @@ export interface ArticleContentData { context?: string; /** CIA intelligence context for enriched analysis */ ciaContext?: CIAContext; + /** Monthly metrics for trend analysis (monthly-review specific) */ + monthlyMetrics?: MonthlyMetrics; } diff --git a/scripts/news-types/monthly-review.ts b/scripts/news-types/monthly-review.ts index fbd24df6..0f2b7b1e 100644 --- a/scripts/news-types/monthly-review.ts +++ b/scripts/news-types/monthly-review.ts @@ -26,6 +26,7 @@ import { generateSources, type RawDocument, type CIAContext, + type MonthlyMetrics, } from '../data-transformers.js'; import { enrichWithFullText, @@ -58,6 +59,9 @@ export interface MonthlyReviewValidationResult { hasMinimumSources: boolean; hasRetrospectiveTone: boolean; hasTrendAnalysis: boolean; + hasPartyRankings: boolean; + hasLegislativeEfficiency: boolean; + hasMonthInNumbers: boolean; passed: boolean; } @@ -191,6 +195,64 @@ export async function generateMonthlyReview(options: GenerationOptions = {}): Pr const ciaContext: CIAContext = loadCIAContext(); console.log(` 🧠 CIA context: ${ciaContext.partyPerformance.length} parties, coalition stability ${ciaContext.coalitionStability.stabilityScore}/100, motion denial rate ${ciaContext.overallMotionDenialRate}%`); + // Step 6: Fetch previous 2 months for multi-month trend analysis + console.log(' 🔄 Step 6 — Fetching previous months for trend analysis...'); + const prevStart = new Date(startDate); + prevStart.setDate(prevStart.getDate() - lookbackDays); + const prev2Start = new Date(prevStart); + prev2Start.setDate(prev2Start.getDate() - lookbackDays); + + const [prevMonthDocs, twoMonthsDocs] = await Promise.all([ + // 50 documents per period is sufficient for trend direction; full-text enrichment is not needed here + client.searchDocuments({ from_date: formatDateForSlug(prevStart), to_date: fromStr, limit: 50 }) + .catch(() => [] as RawDocument[]), + client.searchDocuments({ from_date: formatDateForSlug(prev2Start), to_date: formatDateForSlug(prevStart), limit: 50 }) + .catch(() => [] as RawDocument[]), + ]); + mcpCalls.push({ tool: 'search_dokument', result: prevMonthDocs }); + mcpCalls.push({ tool: 'search_dokument', result: twoMonthsDocs }); + + // Compute MonthlyMetrics from current-month data + const reportCount = documents.filter(d => (d as Record).doktyp === 'bet').length; + const propositionCount = documents.filter(d => (d as Record).doktyp === 'prop').length; + const motionCount = documents.filter(d => (d as Record).doktyp === 'mot').length; + + // Party rankings: aggregate motions and speeches by party + const partyMotions: Record = {}; + const partySpeeches: Record = {}; + for (const doc of documents) { + const rec = doc as Record; + if (rec['doktyp'] === 'mot' && rec['parti']) { + const p = String(rec['parti']); + partyMotions[p] = (partyMotions[p] ?? 0) + 1; + } + } + for (const speech of speeches) { + const p = String(speech['parti'] ?? ''); + if (p) partySpeeches[p] = (partySpeeches[p] ?? 0) + 1; + } + const allParties = new Set([...Object.keys(partyMotions), ...Object.keys(partySpeeches)]); + const partyRankings = Array.from(allParties) + .map(party => ({ + party, + motionCount: partyMotions[party] ?? 0, + speechCount: partySpeeches[party] ?? 0, + })) + .sort((a, b) => (b.motionCount + b.speechCount) - (a.motionCount + a.speechCount)); + + const monthlyMetrics: MonthlyMetrics = { + totalDocuments: documents.length, + reportCount, + propositionCount, + motionCount, + speechCount: speeches.length, + previousMonthDocCount: Array.isArray(prevMonthDocs) ? prevMonthDocs.length : 0, + twoMonthsAgoDocCount: Array.isArray(twoMonthsDocs) ? twoMonthsDocs.length : 0, + partyRankings, + legislativeEfficiencyRate: propositionCount > 0 ? reportCount / propositionCount : 0, + }; + console.log(` 📈 Monthly metrics: ${documents.length} docs this month, ${monthlyMetrics.previousMonthDocCount} last month, ${partyRankings.length} active parties`); + const slug = `${formatDateForSlug(today)}-monthly-review`; const articles: GeneratedArticle[] = []; @@ -198,7 +260,7 @@ export async function generateMonthlyReview(options: GenerationOptions = {}): Pr console.log(` 🌐 Generating ${lang.toUpperCase()} version...`); const content: string = generateArticleContent( - { documents, reports: recentReports as RawDocument[], propositions: recentPropositions as RawDocument[], motions: recentMotions as RawDocument[], ciaContext }, + { documents, reports: recentReports as RawDocument[], propositions: recentPropositions as RawDocument[], motions: recentMotions as RawDocument[], ciaContext, monthlyMetrics }, 'monthly-review', lang, ); @@ -336,12 +398,18 @@ export function validateMonthlyReview(article: ArticleInput): MonthlyReviewValid const hasMinimumSources = countSources(article) >= 3; const hasRetrospectiveTone = checkRetrospectiveTone(article); const hasTrendAnalysis = checkTrendAnalysis(article); + const hasPartyRankings = checkPartyRankings(article); + const hasLegislativeEfficiency = checkLegislativeEfficiency(article); + const hasMonthInNumbers = checkMonthInNumbers(article); return { hasMonthlySummary, hasMinimumSources, hasRetrospectiveTone, hasTrendAnalysis, + hasPartyRankings, + hasLegislativeEfficiency, + hasMonthInNumbers, passed: hasMonthlySummary && hasMinimumSources && hasRetrospectiveTone && hasTrendAnalysis }; } @@ -374,3 +442,27 @@ function checkTrendAnalysis(article: ArticleInput): boolean { (article.content as string).toLowerCase().includes(keyword) ); } + +function checkPartyRankings(article: ArticleInput): boolean { + if (!article || !article.content) return false; + return article.content.includes('Party Performance Rankings') || + article.content.includes('Partiernas prestationsrankning') || + article.content.includes('partyRankings') || + article.content.toLowerCase().includes('rankings'); +} + +function checkLegislativeEfficiency(article: ArticleInput): boolean { + if (!article || !article.content) return false; + return article.content.includes('Legislative Efficiency') || + article.content.includes('Lagstiftningseffektivitet') || + article.content.toLowerCase().includes('efficiency') || + article.content.toLowerCase().includes('throughput'); +} + +function checkMonthInNumbers(article: ArticleInput): boolean { + if (!article || !article.content) return false; + return article.content.includes('Month in Numbers') || + article.content.includes('Månaden i siffror') || + article.content.toLowerCase().includes('total documents') || + article.content.toLowerCase().includes('committee reports'); +} diff --git a/tests/news-types/monthly-review.test.ts b/tests/news-types/monthly-review.test.ts index 84679376..02a8f3b4 100644 --- a/tests/news-types/monthly-review.test.ts +++ b/tests/news-types/monthly-review.test.ts @@ -43,6 +43,9 @@ interface MonthlyReviewValidationResult { hasMinimumSources: boolean; hasRetrospectiveTone: boolean; hasTrendAnalysis: boolean; + hasPartyRankings: boolean; + hasLegislativeEfficiency: boolean; + hasMonthInNumbers: boolean; passed: boolean; } @@ -246,4 +249,74 @@ describe('Monthly Review Article Generation', () => { expect(result.articles.length).toBe(1); }); }); + + describe('Monthly Enhancements', () => { + it('should include Month in Numbers section in generated articles', async () => { + const result = await monthlyReviewModule.generateMonthlyReview({ + languages: ['en'] + }); + + expect(result.success).toBe(true); + const article = result.articles[0]; + expect(article).toBeDefined(); + // Month in Numbers section should appear when documents are present + expect(article!.html).toContain('Month in Numbers'); + }); + + it('should include Legislative Efficiency section in generated articles', async () => { + const result = await monthlyReviewModule.generateMonthlyReview({ + languages: ['en'] + }); + + expect(result.success).toBe(true); + const article = result.articles[0]; + expect(article).toBeDefined(); + expect(article!.html).toContain('Legislative Efficiency'); + }); + + it('should include Strategic Outlook section in generated articles', async () => { + const result = await monthlyReviewModule.generateMonthlyReview({ + languages: ['en'] + }); + + expect(result.success).toBe(true); + const article = result.articles[0]; + expect(article).toBeDefined(); + expect(article!.html).toContain('Strategic Outlook'); + }); + + it('should generate Swedish-language monthly sections', async () => { + const result = await monthlyReviewModule.generateMonthlyReview({ + languages: ['sv'] + }); + + expect(result.success).toBe(true); + const article = result.articles[0]; + expect(article).toBeDefined(); + expect(article!.html).toContain('Månaden i siffror'); + expect(article!.html).toContain('Lagstiftningseffektivitet'); + expect(article!.html).toContain('Strategisk utsikt'); + }); + + it('should validate new section fields in validateMonthlyReview', () => { + const article: ArticleInput = { + content: 'Month in Numbers review. Committee reports throughput. Party Performance Rankings. Legislative Efficiency. Strategic Outlook trend analysis completed.', + sources: ['s1', 's2', 's3'] + }; + + const result = monthlyReviewModule.validateMonthlyReview(article); + expect(result.hasMonthInNumbers).toBe(true); + expect(result.hasLegislativeEfficiency).toBe(true); + expect(result.hasPartyRankings).toBe(true); + }); + + it('should fetch previous months for trend analysis (3 searchDocuments calls)', async () => { + await monthlyReviewModule.generateMonthlyReview({ + languages: ['en'] + }); + + // Exactly 3 calls: current month + previous month + 2-months-ago + expect(mockClientInstance.searchDocuments.mock.calls.length).toBe(3); + }); + }); }); From b990749f80d48d6a779f3802917b895d95289778 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 06:47:26 +0000 Subject: [PATCH 03/11] fix(monthly-review): address PR review comments - Log Step 6 trend-fetch errors with console.error for observability - Tighten check functions to match exact generated section headings only - Include hasPartyRankings/hasLegislativeEfficiency/hasMonthInNumbers in passed - Fix legislativeEfficiencyRate doc comment: ratio is unbounded (may exceed 1) Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/data-transformers/types.ts | 5 ++-- scripts/news-types/monthly-review.ts | 39 +++++++++++++++++++--------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/scripts/data-transformers/types.ts b/scripts/data-transformers/types.ts index cbd9d81e..a9925283 100644 --- a/scripts/data-transformers/types.ts +++ b/scripts/data-transformers/types.ts @@ -102,8 +102,9 @@ export interface MonthlyMetrics { /** Party activity rankings sorted by total activity (motions + speeches) */ partyRankings: Array<{ party: string; motionCount: number; speechCount: number }>; /** - * Legislative efficiency rate: committee reports divided by propositions (0–1). - * Higher values indicate faster committee processing. + * Legislative efficiency rate: committee reports divided by propositions. + * This is a non-negative ratio (reportCount / propositionCount) and may exceed 1 + * when there are more reports than propositions in the same period. */ legislativeEfficiencyRate: number; } diff --git a/scripts/news-types/monthly-review.ts b/scripts/news-types/monthly-review.ts index 0f2b7b1e..0b39ec4a 100644 --- a/scripts/news-types/monthly-review.ts +++ b/scripts/news-types/monthly-review.ts @@ -205,9 +205,23 @@ export async function generateMonthlyReview(options: GenerationOptions = {}): Pr const [prevMonthDocs, twoMonthsDocs] = await Promise.all([ // 50 documents per period is sufficient for trend direction; full-text enrichment is not needed here client.searchDocuments({ from_date: formatDateForSlug(prevStart), to_date: fromStr, limit: 50 }) - .catch(() => [] as RawDocument[]), + .catch((error) => { + console.error( + 'MonthlyReview Step 6 — search_dokument failed for previous month trend window', + { from_date: formatDateForSlug(prevStart), to_date: fromStr, limit: 50 }, + error, + ); + return [] as RawDocument[]; + }), client.searchDocuments({ from_date: formatDateForSlug(prev2Start), to_date: formatDateForSlug(prevStart), limit: 50 }) - .catch(() => [] as RawDocument[]), + .catch((error) => { + console.error( + 'MonthlyReview Step 6 — search_dokument failed for two-months-ago trend window', + { from_date: formatDateForSlug(prev2Start), to_date: formatDateForSlug(prevStart), limit: 50 }, + error, + ); + return [] as RawDocument[]; + }), ]); mcpCalls.push({ tool: 'search_dokument', result: prevMonthDocs }); mcpCalls.push({ tool: 'search_dokument', result: twoMonthsDocs }); @@ -410,7 +424,14 @@ export function validateMonthlyReview(article: ArticleInput): MonthlyReviewValid hasPartyRankings, hasLegislativeEfficiency, hasMonthInNumbers, - passed: hasMonthlySummary && hasMinimumSources && hasRetrospectiveTone && hasTrendAnalysis + passed: + hasMonthlySummary && + hasMinimumSources && + hasRetrospectiveTone && + hasTrendAnalysis && + hasPartyRankings && + hasLegislativeEfficiency && + hasMonthInNumbers }; } @@ -446,23 +467,17 @@ function checkTrendAnalysis(article: ArticleInput): boolean { function checkPartyRankings(article: ArticleInput): boolean { if (!article || !article.content) return false; return article.content.includes('Party Performance Rankings') || - article.content.includes('Partiernas prestationsrankning') || - article.content.includes('partyRankings') || - article.content.toLowerCase().includes('rankings'); + article.content.includes('Partiernas prestationsrankning'); } function checkLegislativeEfficiency(article: ArticleInput): boolean { if (!article || !article.content) return false; return article.content.includes('Legislative Efficiency') || - article.content.includes('Lagstiftningseffektivitet') || - article.content.toLowerCase().includes('efficiency') || - article.content.toLowerCase().includes('throughput'); + article.content.includes('Lagstiftningseffektivitet'); } function checkMonthInNumbers(article: ArticleInput): boolean { if (!article || !article.content) return false; return article.content.includes('Month in Numbers') || - article.content.includes('Månaden i siffror') || - article.content.toLowerCase().includes('total documents') || - article.content.toLowerCase().includes('committee reports'); + article.content.includes('Månaden i siffror'); } From 637b7afbb74cefa3091374298db71b3844508cec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:24:17 +0000 Subject: [PATCH 04/11] fix(monthly-review): filter empty party names in motion aggregation Mirror the speech aggregation pattern: use String(rec['parti'] ?? '') and guard with `if (p)` to prevent empty-string party names appearing in partyRankings output. Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/news-types/monthly-review.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/news-types/monthly-review.ts b/scripts/news-types/monthly-review.ts index 0b39ec4a..bea7d3de 100644 --- a/scripts/news-types/monthly-review.ts +++ b/scripts/news-types/monthly-review.ts @@ -236,9 +236,9 @@ export async function generateMonthlyReview(options: GenerationOptions = {}): Pr const partySpeeches: Record = {}; for (const doc of documents) { const rec = doc as Record; - if (rec['doktyp'] === 'mot' && rec['parti']) { - const p = String(rec['parti']); - partyMotions[p] = (partyMotions[p] ?? 0) + 1; + if (rec['doktyp'] === 'mot') { + const p = String(rec['parti'] ?? ''); + if (p) partyMotions[p] = (partyMotions[p] ?? 0) + 1; } } for (const speech of speeches) { From ab3c4aa32f6a6c3f3e00d06eb009f1c5285cc882 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:25:47 +0000 Subject: [PATCH 05/11] fix(content-generators): merge duplicate import statements into one Combine the two separate imports from './types.js' on lines 14-15 into a single import: ArticleContentData, WeekAheadData, RawDocument, MonthlyMetrics, RawCalendarEvent. Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/data-transformers/content-generators.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/data-transformers/content-generators.ts b/scripts/data-transformers/content-generators.ts index 3f9eb9c8..92c294da 100644 --- a/scripts/data-transformers/content-generators.ts +++ b/scripts/data-transformers/content-generators.ts @@ -11,8 +11,7 @@ import { escapeHtml } from '../html-utils.js'; import type { Language } from '../types/language.js'; -import type { ArticleContentData, WeekAheadData, RawDocument, MonthlyMetrics } from './types.js'; -import type { ArticleContentData, WeekAheadData, RawDocument, RawCalendarEvent } from './types.js'; +import type { ArticleContentData, WeekAheadData, RawDocument, MonthlyMetrics, RawCalendarEvent } from './types.js'; import { getPillarTransition } from '../editorial-pillars.js'; import { L, From 13f9b4e5a7768dbb276083ffd0e6166cde10654b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:58:10 +0000 Subject: [PATCH 06/11] fix(monthly-review): normalizePartyKey, limit 1000, and clarify MonthlyMetrics docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add normalizePartyKey() helper to filter empty strings and sentinel values ('Unknown'/'Okänd') from party rankings aggregation - Increase Step 6 trend-fetch limit from 50 to 1000 so volume counts are less likely to saturate for busy parliamentary months - Clarify MonthlyMetrics field docs: counts are sampled/limited and not guaranteed to be exact global totals Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/data-transformers/types.ts | 28 +++++++++++++++++++++------- scripts/news-types/monthly-review.ts | 27 ++++++++++++++++++--------- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/scripts/data-transformers/types.ts b/scripts/data-transformers/types.ts index 54be6d76..a3dd020d 100644 --- a/scripts/data-transformers/types.ts +++ b/scripts/data-transformers/types.ts @@ -85,21 +85,35 @@ export interface WeekAheadData { /** * Monthly metrics for trend analysis, party rankings, and legislative efficiency. * Computed in monthly-review.ts and consumed by generateMonthlyReviewContent. + * + * NOTE: Document count fields may be based on limited/sampled search results + * (e.g., when upstream `search_dokument` calls use a hard result limit) and + * are therefore not guaranteed to be exact global totals for the period. */ export interface MonthlyMetrics { - /** Total documents processed this month */ + /** + * Sampled document count for this month. + * Derived from the number of documents returned by upstream search and may + * be capped by search limits rather than representing an exact total. + */ totalDocuments: number; - /** Number of committee reports (betänkanden) */ + /** Number of committee reports (betänkanden) in the sampled set */ reportCount: number; - /** Number of government propositions */ + /** Number of government propositions in the sampled set */ propositionCount: number; - /** Number of parliamentary motions */ + /** Number of parliamentary motions in the sampled set */ motionCount: number; - /** Number of speeches (anföranden) */ + /** Number of speeches (anföranden) in the sampled set */ speechCount: number; - /** Previous month's total document count (for trend) */ + /** + * Previous month's sampled document count (for trend). + * Subject to the same upstream search limits as totalDocuments. + */ previousMonthDocCount: number; - /** Two months ago total document count (for rolling average) */ + /** + * Sampled document count from two months ago (for rolling average). + * Subject to the same upstream search limits as totalDocuments. + */ twoMonthsAgoDocCount: number; /** Party activity rankings sorted by total activity (motions + speeches) */ partyRankings: Array<{ party: string; motionCount: number; speechCount: number }>; diff --git a/scripts/news-types/monthly-review.ts b/scripts/news-types/monthly-review.ts index bea7d3de..e59899d3 100644 --- a/scripts/news-types/monthly-review.ts +++ b/scripts/news-types/monthly-review.ts @@ -203,21 +203,21 @@ export async function generateMonthlyReview(options: GenerationOptions = {}): Pr prev2Start.setDate(prev2Start.getDate() - lookbackDays); const [prevMonthDocs, twoMonthsDocs] = await Promise.all([ - // 50 documents per period is sufficient for trend direction; full-text enrichment is not needed here - client.searchDocuments({ from_date: formatDateForSlug(prevStart), to_date: fromStr, limit: 50 }) + // Use a higher per-period cap to better approximate total volume for trend metrics; full-text enrichment is not needed here + client.searchDocuments({ from_date: formatDateForSlug(prevStart), to_date: fromStr, limit: 1000 }) .catch((error) => { console.error( 'MonthlyReview Step 6 — search_dokument failed for previous month trend window', - { from_date: formatDateForSlug(prevStart), to_date: fromStr, limit: 50 }, + { from_date: formatDateForSlug(prevStart), to_date: fromStr, limit: 1000 }, error, ); return [] as RawDocument[]; }), - client.searchDocuments({ from_date: formatDateForSlug(prev2Start), to_date: formatDateForSlug(prevStart), limit: 50 }) + client.searchDocuments({ from_date: formatDateForSlug(prev2Start), to_date: formatDateForSlug(prevStart), limit: 1000 }) .catch((error) => { console.error( 'MonthlyReview Step 6 — search_dokument failed for two-months-ago trend window', - { from_date: formatDateForSlug(prev2Start), to_date: formatDateForSlug(prevStart), limit: 50 }, + { from_date: formatDateForSlug(prev2Start), to_date: formatDateForSlug(prevStart), limit: 1000 }, error, ); return [] as RawDocument[]; @@ -232,18 +232,27 @@ export async function generateMonthlyReview(options: GenerationOptions = {}): Pr const motionCount = documents.filter(d => (d as Record).doktyp === 'mot').length; // Party rankings: aggregate motions and speeches by party + // Normalize party keys: trim whitespace and drop unknown/empty sentinels + const normalizePartyKey = (raw: unknown): string | null => { + const value = String(raw ?? '').trim(); + if (!value) return null; + const lower = value.toLowerCase(); + if (lower === 'unknown' || lower === 'okänd') return null; + return value; + }; + const partyMotions: Record = {}; const partySpeeches: Record = {}; for (const doc of documents) { const rec = doc as Record; if (rec['doktyp'] === 'mot') { - const p = String(rec['parti'] ?? ''); - if (p) partyMotions[p] = (partyMotions[p] ?? 0) + 1; + const p = normalizePartyKey(rec['parti']); + if (p !== null) partyMotions[p] = (partyMotions[p] ?? 0) + 1; } } for (const speech of speeches) { - const p = String(speech['parti'] ?? ''); - if (p) partySpeeches[p] = (partySpeeches[p] ?? 0) + 1; + const p = normalizePartyKey(speech['parti']); + if (p !== null) partySpeeches[p] = (partySpeeches[p] ?? 0) + 1; } const allParties = new Set([...Object.keys(partyMotions), ...Object.keys(partySpeeches)]); const partyRankings = Array.from(allParties) From c6f1eae0b24cc56fecfa0f151730c423b3065c19 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:19:59 +0000 Subject: [PATCH 07/11] fix(content-generators): wrap all numeric HTML interpolations with escapeHtml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply defense-in-depth escaping to all numeric values rendered into HTML in generateMonthInNumbers, generatePartyRankings, generateLegislativeEfficiency, and generateStrategicOutlook — consistent with the rest of the codebase. Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- .../data-transformers/content-generators.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/scripts/data-transformers/content-generators.ts b/scripts/data-transformers/content-generators.ts index 08def1b1..5ea9e169 100644 --- a/scripts/data-transformers/content-generators.ts +++ b/scripts/data-transformers/content-generators.ts @@ -1418,16 +1418,16 @@ function generateMonthInNumbers(metrics: MonthlyMetrics, lang: Language | string let html = `\n

${escapeHtml(ml(lang, 'monthInNumbers'))}

\n`; html += `
\n
    \n`; - html += `
  • ${escapeHtml(ml(lang, 'totalDocuments'))}: ${metrics.totalDocuments}
  • \n`; - html += `
  • ${escapeHtml(ml(lang, 'reports'))}: ${metrics.reportCount}
  • \n`; - html += `
  • ${escapeHtml(ml(lang, 'propositions'))}: ${metrics.propositionCount}
  • \n`; - html += `
  • ${escapeHtml(ml(lang, 'motions'))}: ${metrics.motionCount}
  • \n`; - html += `
  • ${escapeHtml(ml(lang, 'speeches'))}: ${metrics.speechCount}
  • \n`; + html += `
  • ${escapeHtml(ml(lang, 'totalDocuments'))}: ${escapeHtml(metrics.totalDocuments)}
  • \n`; + html += `
  • ${escapeHtml(ml(lang, 'reports'))}: ${escapeHtml(metrics.reportCount)}
  • \n`; + html += `
  • ${escapeHtml(ml(lang, 'propositions'))}: ${escapeHtml(metrics.propositionCount)}
  • \n`; + html += `
  • ${escapeHtml(ml(lang, 'motions'))}: ${escapeHtml(metrics.motionCount)}
  • \n`; + html += `
  • ${escapeHtml(ml(lang, 'speeches'))}: ${escapeHtml(metrics.speechCount)}
  • \n`; if (metrics.previousMonthDocCount > 0) { - html += `
  • ${escapeHtml(ml(lang, 'trendVsPrevMonth'))}: ${prevSign}${prevDiff}
  • \n`; + html += `
  • ${escapeHtml(ml(lang, 'trendVsPrevMonth'))}: ${escapeHtml(prevSign)}${escapeHtml(prevDiff)}
  • \n`; } if (metrics.twoMonthsAgoDocCount > 0 || metrics.previousMonthDocCount > 0) { - html += `
  • ${escapeHtml(ml(lang, 'trendVs2MonthsAgo'))}: ${rollingAvg}
  • \n`; + html += `
  • ${escapeHtml(ml(lang, 'trendVs2MonthsAgo'))}: ${escapeHtml(rollingAvg)}
  • \n`; } html += `
\n
\n`; return html; @@ -1448,8 +1448,8 @@ function generatePartyRankings(metrics: MonthlyMetrics, lang: Language | string) const speechLabel = ml(lang, 'speechesDelivered'); const rankLabel = ml(lang, 'activityRank'); html += `
  • ${escapeHtml(entry.party)} — `; - html += `${entry.motionCount} ${escapeHtml(motionLabel)}, `; - html += `${entry.speechCount} ${escapeHtml(speechLabel)}`; + html += `${escapeHtml(entry.motionCount)} ${escapeHtml(motionLabel)}, `; + html += `${escapeHtml(entry.speechCount)} ${escapeHtml(speechLabel)}`; if (idx === 0) html += ` (${escapeHtml(rankLabel)} #1)`; html += `
  • \n`; }); @@ -1467,7 +1467,7 @@ function generateLegislativeEfficiency(metrics: MonthlyMetrics, lang: Language | if (metrics.propositionCount > 0) { const pct = Math.round(metrics.legislativeEfficiencyRate * 100); - html += `
  • ${escapeHtml(ml(lang, 'efficiencyRate'))}: ${pct}% (${metrics.reportCount} / ${metrics.propositionCount})
  • \n`; + html += `
  • ${escapeHtml(ml(lang, 'efficiencyRate'))}: ${escapeHtml(pct)}% (${escapeHtml(metrics.reportCount)} / ${escapeHtml(metrics.propositionCount)})
  • \n`; } html += `
  • ${escapeHtml(ml(lang, 'motionDenialContext'))}
  • \n`; @@ -1513,7 +1513,7 @@ function generateStrategicOutlook(metrics: MonthlyMetrics, data: ArticleContentD const stabilityLabel = ml(lang, 'coalitionStabilityOutlook'); const stabilityScore = cia.coalitionStability.stabilityScore; const riskLevel = cia.coalitionStability.riskLevel; - html += `
  • ${escapeHtml(stabilityLabel)}: ${stabilityScore}/100 (${escapeHtml(riskLevel)})
  • \n`; + html += `
  • ${escapeHtml(stabilityLabel)}: ${escapeHtml(stabilityScore)}/100 (${escapeHtml(riskLevel)})
  • \n`; } html += ` \n \n`; From 3f4f8563d03809480c9f252f15df0ed6c9ded456 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:00:10 +0000 Subject: [PATCH 08/11] fix(monthly-review): add MonthlyMetrics to barrel export; rename local normalizePartyKey to filterPartyKey Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/data-transformers.ts | 1 + scripts/news-types/monthly-review.ts | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/data-transformers.ts b/scripts/data-transformers.ts index d70ccff9..6281639c 100644 --- a/scripts/data-transformers.ts +++ b/scripts/data-transformers.ts @@ -29,6 +29,7 @@ export { type CIAContext, type WeekAheadData, type ArticleContentData, + type MonthlyMetrics, // Constants CONTENT_LABELS, diff --git a/scripts/news-types/monthly-review.ts b/scripts/news-types/monthly-review.ts index e59899d3..f517ca71 100644 --- a/scripts/news-types/monthly-review.ts +++ b/scripts/news-types/monthly-review.ts @@ -232,8 +232,9 @@ export async function generateMonthlyReview(options: GenerationOptions = {}): Pr const motionCount = documents.filter(d => (d as Record).doktyp === 'mot').length; // Party rankings: aggregate motions and speeches by party - // Normalize party keys: trim whitespace and drop unknown/empty sentinels - const normalizePartyKey = (raw: unknown): string | null => { + // Filter party keys: trim whitespace and drop unknown/empty sentinels (returns null to exclude) + // Note: distinct from the normalizePartyKey helper in helpers.ts which maps unknowns to 'other' + const filterPartyKey = (raw: unknown): string | null => { const value = String(raw ?? '').trim(); if (!value) return null; const lower = value.toLowerCase(); @@ -246,12 +247,12 @@ export async function generateMonthlyReview(options: GenerationOptions = {}): Pr for (const doc of documents) { const rec = doc as Record; if (rec['doktyp'] === 'mot') { - const p = normalizePartyKey(rec['parti']); + const p = filterPartyKey(rec['parti']); if (p !== null) partyMotions[p] = (partyMotions[p] ?? 0) + 1; } } for (const speech of speeches) { - const p = normalizePartyKey(speech['parti']); + const p = filterPartyKey(speech['parti']); if (p !== null) partySpeeches[p] = (partySpeeches[p] ?? 0) + 1; } const allParties = new Set([...Object.keys(partyMotions), ...Object.keys(partySpeeches)]); From 329a901edec53dd960905d68925ea50ae7e2c234 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:29:00 +0000 Subject: [PATCH 09/11] fix(content-generators): remove redundant escapeHtml() from numeric values Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/data-transformers/content-generators.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/data-transformers/content-generators.ts b/scripts/data-transformers/content-generators.ts index 5ea9e169..192fa867 100644 --- a/scripts/data-transformers/content-generators.ts +++ b/scripts/data-transformers/content-generators.ts @@ -1424,10 +1424,10 @@ function generateMonthInNumbers(metrics: MonthlyMetrics, lang: Language | string html += `
  • ${escapeHtml(ml(lang, 'motions'))}: ${escapeHtml(metrics.motionCount)}
  • \n`; html += `
  • ${escapeHtml(ml(lang, 'speeches'))}: ${escapeHtml(metrics.speechCount)}
  • \n`; if (metrics.previousMonthDocCount > 0) { - html += `
  • ${escapeHtml(ml(lang, 'trendVsPrevMonth'))}: ${escapeHtml(prevSign)}${escapeHtml(prevDiff)}
  • \n`; + html += `
  • ${escapeHtml(ml(lang, 'trendVsPrevMonth'))}: ${escapeHtml(prevSign)}${prevDiff}
  • \n`; } if (metrics.twoMonthsAgoDocCount > 0 || metrics.previousMonthDocCount > 0) { - html += `
  • ${escapeHtml(ml(lang, 'trendVs2MonthsAgo'))}: ${escapeHtml(rollingAvg)}
  • \n`; + html += `
  • ${escapeHtml(ml(lang, 'trendVs2MonthsAgo'))}: ${rollingAvg}
  • \n`; } html += ` \n \n`; return html; @@ -1467,7 +1467,7 @@ function generateLegislativeEfficiency(metrics: MonthlyMetrics, lang: Language | if (metrics.propositionCount > 0) { const pct = Math.round(metrics.legislativeEfficiencyRate * 100); - html += `
  • ${escapeHtml(ml(lang, 'efficiencyRate'))}: ${escapeHtml(pct)}% (${escapeHtml(metrics.reportCount)} / ${escapeHtml(metrics.propositionCount)})
  • \n`; + html += `
  • ${escapeHtml(ml(lang, 'efficiencyRate'))}: ${pct}% (${metrics.reportCount} / ${metrics.propositionCount})
  • \n`; } html += `
  • ${escapeHtml(ml(lang, 'motionDenialContext'))}
  • \n`; From 48b792397097e3323d368534e4ab4087bdeb5686 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:57:20 +0000 Subject: [PATCH 10/11] fix(monthly-review): resolve merge conflict, fix rolling avg, Hebrew label, efficiency label, add party rankings test Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- .../data-transformers/content-generators.ts | 21 ++++++++++++----- scripts/data-transformers/index.ts | 3 --- tests/news-types/monthly-review.test.ts | 23 +++++++++++++++++++ 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/scripts/data-transformers/content-generators.ts b/scripts/data-transformers/content-generators.ts index 9c745055..9ca42160 100644 --- a/scripts/data-transformers/content-generators.ts +++ b/scripts/data-transformers/content-generators.ts @@ -1360,7 +1360,6 @@ export function generateGenericContent(data: ArticleContentData, lang: Language } /** -<<<<<<< copilot/enhance-monthly-review-features * Per-language label maps for the monthly-review-specific sections. * Covers all 14 supported languages. */ @@ -1391,8 +1390,8 @@ const MONTHLY_LABELS: Readonly<{ reports: { en: 'Committee reports', sv: 'Betänkanden', da: 'Betænkninger', no: 'Innstillinger', fi: 'Mietinnöt', de: 'Ausschussberichte', fr: 'Rapports de commission', es: 'Informes de comité', nl: 'Commissierapporten', ar: 'تقارير اللجان', he: 'דוחות ועדה', ja: '委員会報告', ko: '위원회 보고서', zh: '委员会报告' }, propositions: { en: 'Government propositions', sv: 'Propositioner', da: 'Lovforslag', no: 'Proposisjoner', fi: 'Hallituksen esitykset', de: 'Regierungsvorlagen', fr: 'Propositions gouvernementales', es: 'Proposiciones gubernamentales', nl: 'Regeringsvoorstellen', ar: 'المقترحات الحكومية', he: 'הצעות ממשלה', ja: '政府提案', ko: '정부 법안', zh: '政府提案' }, motions: { en: 'Opposition motions', sv: 'Motioner', da: 'Forslag', no: 'Forslag', fi: 'Aloitteet', de: 'Anträge', fr: "Motions de l'opposition", es: 'Mociones de la oposición', nl: 'Oppositiemoties', ar: 'مقترحات المعارضة', he: 'הצעות האופוזיציה', ja: '反対動議', ko: '야당 동의', zh: '反对党动议' }, - speeches: { en: 'Chamber speeches', sv: 'Anföranden', da: 'Taler', no: 'Taler', fi: 'Puheet', de: 'Parlamentsreden', fr: 'Discours parlementaires', es: 'Discursos parlamentarios', nl: 'Parlementaire toespraken', ar: 'الخطب البرلمانية', he: 'נאומים בכנסת', ja: '議会演説', ko: '의회 연설', zh: '议会演讲' }, - efficiencyRate: { en: 'Committee throughput rate', sv: 'Utskottets genomströmningsgrad', da: 'Udvalgets gennemstrømningshastighed', no: 'Komiteens gjennomstrømningshastighet', fi: 'Valiokunnan käsittelyaste', de: 'Ausschuss-Durchsatzrate', fr: 'Taux de traitement en commission', es: 'Tasa de rendimiento del comité', nl: 'Commissiedoorvoersnelheid', ar: 'معدل إنتاجية اللجنة', he: 'קצב תפוקת הוועדה', ja: '委員会処理率', ko: '위원회 처리율', zh: '委员会处理率' }, + speeches: { en: 'Chamber speeches', sv: 'Anföranden', da: 'Taler', no: 'Taler', fi: 'Puheet', de: 'Parlamentsreden', fr: 'Discours parlementaires', es: 'Discursos parlamentarios', nl: 'Parlementaire toespraken', ar: 'الخطب البرلمانية', he: 'נאומי המליאה', ja: '議会演説', ko: '의회 연설', zh: '议会演讲' }, + efficiencyRate: { en: 'Committee reports per proposition', sv: 'Betänkanden per proposition', da: 'Betænkninger per lovforslag', no: 'Innstillinger per proposisjon', fi: 'Mietinnöt per esitys', de: 'Ausschussberichte pro Vorlage', fr: 'Rapports par proposition', es: 'Informes por proposición', nl: 'Commissierapporten per voorstel', ar: 'تقارير اللجان لكل اقتراح', he: 'דוחות ועדה לכל הצעה', ja: '提案当たり委員会報告数', ko: '제안당 위원회 보고서', zh: '每项提案的委员会报告数' }, trendVsPrevMonth: { en: 'vs. previous month', sv: 'jmf föregående månad', da: 'ift. forrige måned', no: 'vs. forrige måned', fi: 'vs. edellinen kuukausi', de: 'vs. Vormonat', fr: 'vs. mois précédent', es: 'vs. mes anterior', nl: 'vs. vorige maand', ar: 'مقارنة بالشهر السابق', he: 'לעומת החודש הקודם', ja: '前月比', ko: '전월 대비', zh: '对比上个月' }, trendVs2MonthsAgo: { en: '3-month rolling avg', sv: '3 månaders glidande snitt', da: '3-måneders glidende gennemsnit', no: '3 måneders glidende gjennomsnitt', fi: '3 kuukauden liukuva keskiarvo', de: '3-Monats-Gleitdurchschnitt', fr: 'Moyenne mobile sur 3 mois', es: 'Promedio móvil de 3 meses', nl: '3-maands voortschrijdend gemiddelde', ar: 'متوسط متحرك 3 أشهر', he: 'ממוצע נע 3 חודשים', ja: '3ヶ月移動平均', ko: '3개월 이동 평균', zh: '3个月滚动平均' }, activityRank: { en: 'Activity rank', sv: 'Aktivitetsrankning', da: 'Aktivitetsrangering', no: 'Aktivitetsrangering', fi: 'Aktiivisuusranking', de: 'Aktivitätsrang', fr: "Rang d'activité", es: 'Rango de actividad', nl: 'Activiteitsrang', ar: 'تصنيف النشاط', he: 'דירוג פעילות', ja: '活動ランク', ko: '활동 순위', zh: '活动排名' }, @@ -1415,7 +1414,14 @@ function ml(lang: Language | string, key: keyof typeof MONTHLY_LABELS): string { function generateMonthInNumbers(metrics: MonthlyMetrics, lang: Language | string): string { const prevDiff = metrics.totalDocuments - metrics.previousMonthDocCount; const prevSign = prevDiff > 0 ? '+' : ''; - const rollingAvg = Math.round((metrics.totalDocuments + metrics.previousMonthDocCount + metrics.twoMonthsAgoDocCount) / 3); + const availableMonthCounts = [ + metrics.totalDocuments, + metrics.previousMonthDocCount, + metrics.twoMonthsAgoDocCount, + ].filter(count => count > 0); + const rollingAvg = availableMonthCounts.length > 0 + ? Math.round(availableMonthCounts.reduce((a, b) => a + b, 0) / availableMonthCounts.length) + : metrics.totalDocuments; let html = `\n

    ${escapeHtml(ml(lang, 'monthInNumbers'))}

    \n`; html += `
    \n
      \n`; @@ -1538,7 +1544,11 @@ export function generateMonthlyReviewContent(data: ArticleContentData, lang: Lan content += generatePartyRankings(metrics, lang); content += generateLegislativeEfficiency(metrics, lang); content += generateStrategicOutlook(metrics, data, lang); -======= + + return content; +} + +/** * Generate Month-Ahead article content with strategic legislative forecasting. * Extends week-ahead calendar coverage with committee pipeline tracking, * propositions in pipeline, and motion trend analysis. @@ -1741,7 +1751,6 @@ export function generateMonthAheadContent(data: ArticleContentData, lang: Langua content += `
    \n`; } } ->>>>>>> main return content; } diff --git a/scripts/data-transformers/index.ts b/scripts/data-transformers/index.ts index da937744..5b31d66b 100644 --- a/scripts/data-transformers/index.ts +++ b/scripts/data-transformers/index.ts @@ -66,11 +66,8 @@ import { generatePropositionsContent, generateMotionsContent, generateGenericContent, -<<<<<<< copilot/enhance-monthly-review-features generateMonthlyReviewContent, -======= generateMonthAheadContent, ->>>>>>> main } from './content-generators.js'; /** diff --git a/tests/news-types/monthly-review.test.ts b/tests/news-types/monthly-review.test.ts index 02a8f3b4..4700d299 100644 --- a/tests/news-types/monthly-review.test.ts +++ b/tests/news-types/monthly-review.test.ts @@ -318,5 +318,28 @@ describe('Monthly Review Article Generation', () => { // Exactly 3 calls: current month + previous month + 2-months-ago expect(mockClientInstance.searchDocuments.mock.calls.length).toBe(3); }); + + it('should render Party Performance Rankings when party data is available', async () => { + // Provide documents with doktyp + parti so the party aggregation fires + const docsWithParty = [ + { id: 'mot-1', title: 'Motion om bostäder', date: '2026-02-01', type: 'mot', doktyp: 'mot', parti: 'S' }, + { id: 'mot-2', title: 'Motion om skatter', date: '2026-02-05', type: 'mot', doktyp: 'mot', parti: 'M' }, + { id: 'mot-3', title: 'Motion om klimat', date: '2026-02-10', type: 'mot', doktyp: 'mot', parti: 'S' }, + ] as unknown as SearchDocument[]; + mockClientInstance.searchDocuments.mockResolvedValue(docsWithParty); + mockClientInstance.searchSpeeches.mockResolvedValue([ + { parti: 'M', talare: 'Speaker A', anforandetext: 'Text' }, + { parti: 'SD', talare: 'Speaker B', anforandetext: 'Text' }, + ]); + + const result = await monthlyReviewModule.generateMonthlyReview({ + languages: ['en'] + }); + + expect(result.success).toBe(true); + const article = result.articles[0]; + expect(article).toBeDefined(); + expect(article!.html).toContain('Party Performance Rankings'); + }); }); }); From db25d79d9c8e5730aabe175f2710b908019fa919 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:25:34 +0000 Subject: [PATCH 11/11] fix(monthly-review): stringify numeric counts before escapeHtml, render efficiency ratio with toFixed(2) Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- .../data-transformers/content-generators.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/scripts/data-transformers/content-generators.ts b/scripts/data-transformers/content-generators.ts index 9ca42160..809f0114 100644 --- a/scripts/data-transformers/content-generators.ts +++ b/scripts/data-transformers/content-generators.ts @@ -1425,11 +1425,11 @@ function generateMonthInNumbers(metrics: MonthlyMetrics, lang: Language | string let html = `\n

    ${escapeHtml(ml(lang, 'monthInNumbers'))}

    \n`; html += `
    \n
      \n`; - html += `
    • ${escapeHtml(ml(lang, 'totalDocuments'))}: ${escapeHtml(metrics.totalDocuments)}
    • \n`; - html += `
    • ${escapeHtml(ml(lang, 'reports'))}: ${escapeHtml(metrics.reportCount)}
    • \n`; - html += `
    • ${escapeHtml(ml(lang, 'propositions'))}: ${escapeHtml(metrics.propositionCount)}
    • \n`; - html += `
    • ${escapeHtml(ml(lang, 'motions'))}: ${escapeHtml(metrics.motionCount)}
    • \n`; - html += `
    • ${escapeHtml(ml(lang, 'speeches'))}: ${escapeHtml(metrics.speechCount)}
    • \n`; + html += `
    • ${escapeHtml(ml(lang, 'totalDocuments'))}: ${escapeHtml(String(metrics.totalDocuments))}
    • \n`; + html += `
    • ${escapeHtml(ml(lang, 'reports'))}: ${escapeHtml(String(metrics.reportCount))}
    • \n`; + html += `
    • ${escapeHtml(ml(lang, 'propositions'))}: ${escapeHtml(String(metrics.propositionCount))}
    • \n`; + html += `
    • ${escapeHtml(ml(lang, 'motions'))}: ${escapeHtml(String(metrics.motionCount))}
    • \n`; + html += `
    • ${escapeHtml(ml(lang, 'speeches'))}: ${escapeHtml(String(metrics.speechCount))}
    • \n`; if (metrics.previousMonthDocCount > 0) { html += `
    • ${escapeHtml(ml(lang, 'trendVsPrevMonth'))}: ${escapeHtml(prevSign)}${prevDiff}
    • \n`; } @@ -1455,8 +1455,8 @@ function generatePartyRankings(metrics: MonthlyMetrics, lang: Language | string) const speechLabel = ml(lang, 'speechesDelivered'); const rankLabel = ml(lang, 'activityRank'); html += `
    • ${escapeHtml(entry.party)} — `; - html += `${escapeHtml(entry.motionCount)} ${escapeHtml(motionLabel)}, `; - html += `${escapeHtml(entry.speechCount)} ${escapeHtml(speechLabel)}`; + html += `${escapeHtml(String(entry.motionCount))} ${escapeHtml(motionLabel)}, `; + html += `${escapeHtml(String(entry.speechCount))} ${escapeHtml(speechLabel)}`; if (idx === 0) html += ` (${escapeHtml(rankLabel)} #1)`; html += `
    • \n`; }); @@ -1473,8 +1473,8 @@ function generateLegislativeEfficiency(metrics: MonthlyMetrics, lang: Language | html += `
      \n
        \n`; if (metrics.propositionCount > 0) { - const pct = Math.round(metrics.legislativeEfficiencyRate * 100); - html += `
      • ${escapeHtml(ml(lang, 'efficiencyRate'))}: ${pct}% (${metrics.reportCount} / ${metrics.propositionCount})
      • \n`; + const ratio = metrics.legislativeEfficiencyRate.toFixed(2); + html += `
      • ${escapeHtml(ml(lang, 'efficiencyRate'))}: ${ratio} (${metrics.reportCount} / ${metrics.propositionCount})
      • \n`; } html += `
      • ${escapeHtml(ml(lang, 'motionDenialContext'))}
      • \n`;