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/data-transformers/content-generators.ts b/scripts/data-transformers/content-generators.ts index b2e8b4ac..8d5224ec 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, RawCalendarEvent } from './types.js'; +import type { ArticleContentData, WeekAheadData, RawDocument, MonthlyMetrics, RawCalendarEvent } from './types.js'; import { getPillarTransition } from '../editorial-pillars.js'; import { L, @@ -1359,6 +1359,203 @@ 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; + insufficientData: 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 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: '活动排名' }, + 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: '反对党动议历史上通过率较低——议会多数动态决定立法结果。' }, + insufficientData: { en: 'Insufficient party activity data for this period.', sv: 'Otillräckliga partiaktivitetsdata för denna period.', da: 'Utilstrækkelige partiaktivitetsdata for denne periode.', no: 'Utilstrekkelige partiaktivitetsdata for denne perioden.', fi: 'Puoluetoimintatiedot ovat riittämättömät tälle ajanjaksolle.', de: 'Unzureichende Parteiak­tivitätsdaten für diesen Zeitraum.', fr: "Données d'activité des partis insuffisantes pour cette période.", es: 'Datos de actividad de partido insuficientes para este período.', nl: 'Onvoldoende partijactiviteitsgegevens voor deze periode.', 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 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`; + 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)}${escapeHtml(String(prevDiff))}
  • \n`; + } + if (metrics.twoMonthsAgoDocCount > 0 || metrics.previousMonthDocCount > 0) { + html += `
  • ${escapeHtml(ml(lang, 'trendVs2MonthsAgo'))}: ${escapeHtml(String(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 { + let html = `\n

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

\n`; + + if (!metrics.partyRankings || metrics.partyRankings.length === 0) { + html += `
\n`; + html += `

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

\n`; + html += `
\n`; + return html; + } + + 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 += `${escapeHtml(String(entry.motionCount))} ${escapeHtml(motionLabel)}, `; + html += `${escapeHtml(String(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 ratio = metrics.legislativeEfficiencyRate.toFixed(2); + html += `
  • ${escapeHtml(ml(lang, 'efficiencyRate'))}: ${escapeHtml(ratio)} (${escapeHtml(String(metrics.reportCount))} / ${escapeHtml(String(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)}: ${escapeHtml(String(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; +} + /** * Generate Month-Ahead article content with strategic legislative forecasting. * Extends week-ahead calendar coverage with committee pipeline tracking, diff --git a/scripts/data-transformers/index.ts b/scripts/data-transformers/index.ts index 98e010e3..5b31d66b 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, generateMonthAheadContent, } from './content-generators.js'; @@ -93,8 +95,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 d9cddff4..a3dd020d 100644 --- a/scripts/data-transformers/types.ts +++ b/scripts/data-transformers/types.ts @@ -82,6 +82,49 @@ export interface WeekAheadData { interpellations?: RawDocument[]; } +/** + * 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 { + /** + * 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) in the sampled set */ + reportCount: number; + /** Number of government propositions in the sampled set */ + propositionCount: number; + /** Number of parliamentary motions in the sampled set */ + motionCount: number; + /** Number of speeches (anföranden) in the sampled set */ + speechCount: number; + /** + * Previous month's sampled document count (for trend). + * Subject to the same upstream search limits as totalDocuments. + */ + previousMonthDocCount: number; + /** + * 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 }>; + /** + * 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; +} + /** Article generation data */ export interface ArticleContentData { events?: RawCalendarEvent[]; @@ -93,6 +136,8 @@ export interface ArticleContentData { context?: string; /** CIA intelligence context for enriched analysis */ ciaContext?: CIAContext; + /** Monthly metrics for trend analysis (monthly-review specific) */ + monthlyMetrics?: MonthlyMetrics; /** Voting records for cross-referencing committee decisions */ votes?: unknown[]; /** Parliamentary speeches for committee debate context */ diff --git a/scripts/news-types/monthly-review.ts b/scripts/news-types/monthly-review.ts index fbd24df6..23977b9f 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,88 @@ 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([ + // 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: 1000 }, + error, + ); + return [] as RawDocument[]; + }), + 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: 1000 }, + error, + ); + return [] 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 + // 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(); + 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 = filterPartyKey(rec['parti']); + if (p !== null) partyMotions[p] = (partyMotions[p] ?? 0) + 1; + } + } + for (const speech of speeches) { + const p = filterPartyKey(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) + .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 +284,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,13 +422,26 @@ 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, - passed: hasMonthlySummary && hasMinimumSources && hasRetrospectiveTone && hasTrendAnalysis + hasPartyRankings, + hasLegislativeEfficiency, + hasMonthInNumbers, + passed: + hasMonthlySummary && + hasMinimumSources && + hasRetrospectiveTone && + hasTrendAnalysis && + hasPartyRankings && + hasLegislativeEfficiency && + hasMonthInNumbers }; } @@ -374,3 +473,24 @@ 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('🏆'); +} + +function checkLegislativeEfficiency(article: ArticleInput): boolean { + if (!article || !article.content) return false; + return article.content.includes('Legislative Efficiency') || + article.content.includes('Lagstiftningseffektivitet') || + article.content.includes('⚖️'); +} + +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.includes('📊'); +} diff --git a/tests/news-types/monthly-review.test.ts b/tests/news-types/monthly-review.test.ts index 84679376..85ef9dea 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,97 @@ 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'] + }); + + // At least 3 calls: current month + previous month + 2-months-ago + expect(mockClientInstance.searchDocuments.mock.calls.length).toBeGreaterThanOrEqual(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'); + }); + }); });