diff --git a/scripts/news-types/weekly-review.ts b/scripts/news-types/weekly-review.ts index 46c1d4c2..e55f6786 100644 --- a/scripts/news-types/weekly-review.ts +++ b/scripts/news-types/weekly-review.ts @@ -35,9 +35,29 @@ import { type CIAContext } from '../data-transformers.js'; import { generateArticleHTML } from '../article-template.js'; +import { escapeHtml } from '../html-utils.js'; +import { + calculateCoalitionRiskIndex, + detectAnomalousPatterns, + generateTrendComparison, +} from '../data-transformers/risk-analysis.js'; +import type { + CoalitionRiskIndex, + AnomalyFlag, + TrendComparison, +} from '../data-transformers/risk-analysis.js'; import type { Language } from '../types/language.js'; import type { ArticleCategory, GeneratedArticle, GenerationResult, MCPCallRecord } from '../types/article.js'; +/** Swedish government coalition parties (current Tidö coalition) */ +const GOVERNMENT_PARTIES = new Set(['M', 'KD', 'L', 'SD']); + +/** Swedish opposition parties */ +const OPPOSITION_PARTIES = new Set(['S', 'V', 'MP', 'C']); + +/** Allowlist of severity values for anomaly CSS class injection */ +const VALID_SEVERITIES = new Set(['low', 'medium', 'high', 'critical']); + /** * Required MCP tools for weekly-review articles */ @@ -48,6 +68,7 @@ export const REQUIRED_TOOLS: readonly string[] = [ 'get_betankanden', 'get_propositioner', 'get_motioner', + 'search_voteringar', ]; export interface TitleSet { @@ -75,6 +96,45 @@ export interface GenerationOptions { writeArticle?: ((html: string, filename: string) => Promise) | null; } +/** Shape of a single voting record returned by search_voteringar */ +export interface VotingRecord { + parti?: string; + /** Ja | Nej | Avstår | Frånvarande */ + rost?: string; + bet?: string; + punkt?: string; + /** ISO date string (YYYY-MM-DD) used for post-query date filtering */ + datum?: string; + [key: string]: unknown; +} + +/** Coalition stress analysis result derived from voting records */ +export interface CoalitionStressResult { + /** Number of vote points where the government bloc position (Ja/Nej) matched the chamber majority */ + governmentWins: number; + /** Number of vote points where the government bloc position did not match the chamber majority */ + governmentLosses: number; + /** Vote points where opposition parties voted with the government */ + crossPartyVotes: number; + /** Vote points with internal government-bloc defections */ + defections: number; + /** Composite risk index from risk-analysis.ts */ + riskIndex: CoalitionRiskIndex; + /** Detected anomaly flags from risk-analysis.ts */ + anomalies: AnomalyFlag[]; + /** Total distinct vote-points analysed */ + totalVotes: number; +} + +/** Week-over-week comparative metrics */ +export interface WeekOverWeekMetrics { + currentDocuments: number; + currentSpeeches: number; + currentVotes: number; + trendComparison: TrendComparison; + activityChange: 'increasing' | 'stable' | 'declining'; +} + /** * Format date for article slug */ @@ -370,6 +430,338 @@ export function attachSpeechesToDocuments( } } +/** + * Normalize CIAContext so defectionProbability is in [0, 1]. + * risk-analysis.ts multiplies it by 100, so a whole-percent value (e.g. 15) + * would produce 1500 and break risk calculations. + */ +function normalizedCIAContext(ctx: CIAContext): CIAContext { + const defProb = ctx.coalitionStability?.defectionProbability; + if (typeof defProb !== 'number') return ctx; + + let normalized: number; + if (!Number.isFinite(defProb)) { + normalized = 0; + } else if (defProb > 1) { + // Treat as whole-percent and convert to fraction, then clamp + normalized = Math.min(1, Math.max(0, defProb / 100)); + } else { + // Already fraction form — clamp negatives to 0 + normalized = Math.max(0, defProb); + } + + if (normalized === defProb) return ctx; + return { + ...ctx, + coalitionStability: { + ...ctx.coalitionStability!, + defectionProbability: normalized, + }, + }; +} + +/** + * Analyse coalition stress from a list of voting records. + * + * Groups records by vote-point (bet + punkt), then counts: + * - Government wins/losses (M/KD/L/SD bloc) + * - Cross-party votes (opposition voting with government) + * - Internal defections (government parties split) + * + * Also integrates risk scoring via calculateCoalitionRiskIndex and + * detectAnomalousPatterns from scripts/data-transformers/risk-analysis.ts. + * + * @param votingRecords - Raw records from search_voteringar + * @param ciaContext - CIA intelligence context for risk scoring + */ +export function analyzeCoalitionStress( + votingRecords: VotingRecord[], + ciaContext: CIAContext, +): CoalitionStressResult { + const GOV_PARTIES = GOVERNMENT_PARTIES; + const OPP_PARTIES = OPPOSITION_PARTIES; + + // Group records by vote-point + const byPoint = new Map(); + for (const record of votingRecords) { + const key = `${record.bet ?? 'unknown'}-${record.punkt ?? '0'}`; + if (!byPoint.has(key)) byPoint.set(key, []); + byPoint.get(key)!.push(record); + } + + let governmentWins = 0; + let governmentLosses = 0; + let crossPartyVotes = 0; + let defections = 0; + + for (const records of byPoint.values()) { + const totalYes = records.filter(r => r.rost === 'Ja').length; + const totalNo = records.filter(r => r.rost === 'Nej').length; + + const govYes = records.filter(r => GOV_PARTIES.has(r.parti ?? '') && r.rost === 'Ja').length; + const govNo = records.filter(r => GOV_PARTIES.has(r.parti ?? '') && r.rost === 'Nej').length; + const oppYes = records.filter(r => OPP_PARTIES.has(r.parti ?? '') && r.rost === 'Ja').length; + const oppNo = records.filter(r => OPP_PARTIES.has(r.parti ?? '') && r.rost === 'Nej').length; + + // Determine government position — skip if the bloc is evenly split (no clear position) + const govPositionClear = govYes !== govNo; + const govPosition = govYes > govNo ? 'Ja' : 'Nej'; + + // Government wins when its position matches the chamber majority + if (govPositionClear && totalYes !== totalNo) { + const governmentWon = + (govPosition === 'Ja' && totalYes > totalNo) || + (govPosition === 'Nej' && totalNo > totalYes); + if (governmentWon) { governmentWins++; } + else { governmentLosses++; } + } + + // Cross-party: opposition aligned with government position (Ja or Nej) + const oppAlignedWithGov = govPosition === 'Ja' ? oppYes > 0 : oppNo > 0; + if (govPositionClear && oppAlignedWithGov) crossPartyVotes++; + // Defection: government bloc members split + if (govYes > 0 && govNo > 0) defections++; + } + + return { + governmentWins, + governmentLosses, + crossPartyVotes, + defections, + riskIndex: calculateCoalitionRiskIndex(normalizedCIAContext(ciaContext)), + anomalies: detectAnomalousPatterns(normalizedCIAContext(ciaContext)), + totalVotes: byPoint.size, + }; +} + +/** + * Calculate current-week activity metrics with CIA trend direction. + * + * Returns the count of documents, speeches, and distinct vote-points + * collected during the current week, together with the CIA trend comparison + * (30/90/365-day coalition stability trajectory) mapped to a simple + * increasing/stable/declining direction. + * + * NOTE: This is not a prior-week comparison — `activityChange` reflects the + * CIA coalition-stability trend direction, not a delta against last week. + * + * @param documents - Documents collected this week + * @param speeches - Speeches collected this week + * @param votingRecords - Voting records collected this week (already date-filtered) + * @param ciaContext - CIA intelligence context for trend analysis + */ +export function calculateWeekOverWeekMetrics( + documents: RawDocument[], + speeches: unknown[], + votingRecords: VotingRecord[], + ciaContext: CIAContext, +): WeekOverWeekMetrics { + const trendComparison = generateTrendComparison(ciaContext); + + const activityChange: WeekOverWeekMetrics['activityChange'] = + trendComparison.overallDirection === 'IMPROVING' + ? 'increasing' + : trendComparison.overallDirection === 'DECLINING' || trendComparison.overallDirection === 'VOLATILE' + ? 'declining' + : 'stable'; + + // Count distinct vote-points (bet + punkt) to align with coalition analysis semantics + const uniqueVotePoints = new Set(); + for (const record of votingRecords) { + if (record.bet && record.punkt) { + uniqueVotePoints.add(`${record.bet}-${record.punkt}`); + } + } + const currentVotes = uniqueVotePoints.size > 0 ? uniqueVotePoints.size : votingRecords.length; + + return { + currentDocuments: documents.length, + currentSpeeches: speeches.length, + currentVotes, + trendComparison, + activityChange, + }; +} + +/** Coalition Dynamics section labels for all 14 languages */ +const COALITION_DYNAMICS_LABELS: Readonly> = { + en: 'Coalition Dynamics', + sv: 'Koalitionsdynamik', + da: 'Koalitionsdynamik', + no: 'Koalisjonsdynamikk', + fi: 'Koalitionidynamiikka', + de: 'Koalitionsdynamik', + fr: 'Dynamique de coalition', + es: 'Dinámica de coalición', + nl: 'Coalitiedynamiek', + ar: 'ديناميكيات الائتلاف', + he: 'דינמיקת קואליציה', + ja: '連立の動向', + ko: '연립 동향', + zh: '联合政府动态', +}; + +/** Risk level labels for all 14 languages */ +const RISK_LEVEL_LABELS: Readonly>> = { + en: { LOW: 'Low', MEDIUM: 'Moderate', HIGH: 'High', CRITICAL: 'Critical' }, + sv: { LOW: 'Låg', MEDIUM: 'Måttlig', HIGH: 'Hög', CRITICAL: 'Kritisk' }, + da: { LOW: 'Lav', MEDIUM: 'Moderat', HIGH: 'Høj', CRITICAL: 'Kritisk' }, + no: { LOW: 'Lav', MEDIUM: 'Moderat', HIGH: 'Høy', CRITICAL: 'Kritisk' }, + fi: { LOW: 'Matala', MEDIUM: 'Kohtalainen', HIGH: 'Korkea', CRITICAL: 'Kriittinen' }, + de: { LOW: 'Gering', MEDIUM: 'Moderat', HIGH: 'Hoch', CRITICAL: 'Kritisch' }, + fr: { LOW: 'Faible', MEDIUM: 'Modéré', HIGH: 'Élevé', CRITICAL: 'Critique' }, + es: { LOW: 'Bajo', MEDIUM: 'Moderado', HIGH: 'Alto', CRITICAL: 'Crítico' }, + nl: { LOW: 'Laag', MEDIUM: 'Matig', HIGH: 'Hoog', CRITICAL: 'Kritiek' }, + ar: { LOW: 'منخفض', MEDIUM: 'معتدل', HIGH: 'مرتفع', CRITICAL: 'حرج' }, + he: { LOW: 'נמוך', MEDIUM: 'בינוני', HIGH: 'גבוה', CRITICAL: 'קריטי' }, + ja: { LOW: '低', MEDIUM: '中程度', HIGH: '高', CRITICAL: '危機的' }, + ko: { LOW: '낮음', MEDIUM: '보통', HIGH: '높음', CRITICAL: '위급' }, + zh: { LOW: '低', MEDIUM: '中等', HIGH: '高', CRITICAL: '危急' }, +}; + +/** + * Generate the "Coalition Dynamics" HTML section for the given language. + * Shows risk index, government wins/losses, cross-party votes, defections, + * and any anomaly flags detected this week. + */ +export function generateCoalitionDynamicsSection( + stress: CoalitionStressResult, + lang: Language, +): string { + const heading = COALITION_DYNAMICS_LABELS[lang] ?? COALITION_DYNAMICS_LABELS.en; + const riskLabels = RISK_LEVEL_LABELS[lang] ?? RISK_LEVEL_LABELS.en; + const riskLevelLabel = riskLabels[stress.riskIndex.level] ?? stress.riskIndex.level; + + const statsLabels: Record = { + en: { score: 'Risk score', level: 'Risk level', wins: 'Government wins', losses: 'Government losses', cross: 'Cross-party votes', defections: 'Internal defections', votes: 'Vote points analysed' }, + sv: { score: 'Riskpoäng', level: 'Risknivå', wins: 'Regeringsvinster', losses: 'Regeringsförluster', cross: 'Partiöverskridande röster', defections: 'Interna avhopp', votes: 'Analyserade röstpunkter' }, + da: { score: 'Risikoscore', level: 'Risikoniveau', wins: 'Regeringsgevinster', losses: 'Regeringstab', cross: 'Tværpartilige stemmer', defections: 'Interne afhopp', votes: 'Analyserede afstemningspunkter' }, + no: { score: 'Risikoscore', level: 'Risikonivå', wins: 'Regjeringsseire', losses: 'Regjeringstap', cross: 'Tverrpartistemmer', defections: 'Interne avhopp', votes: 'Analyserte voteringspunkter' }, + fi: { score: 'Riskipisteet', level: 'Riskitaso', wins: 'Hallituksen voitot', losses: 'Hallituksen tappiot', cross: 'Puoluerajat ylittävät äänet', defections: 'Sisäiset loikkaukset', votes: 'Analysoidut äänestyskohteet' }, + de: { score: 'Risikowert', level: 'Risikoniveau', wins: 'Regierungssiege', losses: 'Regierungsniederlagen', cross: 'Überparteiliche Abstimmungen', defections: 'Interne Abweichungen', votes: 'Analysierte Abstimmungspunkte' }, + fr: { score: 'Score de risque', level: 'Niveau de risque', wins: 'Victoires gouvernementales', losses: 'Défaites gouvernementales', cross: 'Votes transpartisans', defections: 'Défections internes', votes: 'Points de vote analysés' }, + es: { score: 'Puntuación de riesgo', level: 'Nivel de riesgo', wins: 'Victorias gubernamentales', losses: 'Derrotas gubernamentales', cross: 'Votos transversales', defections: 'Defecciones internas', votes: 'Puntos de votación analizados' }, + nl: { score: 'Risicoscore', level: 'Risiconiveau', wins: 'Regeringsoverwinningen', losses: 'Regeringsnederlagen', cross: 'Stemmen over partijgrenzen', defections: 'Interne defecties', votes: 'Geanalyseerde stemmentpunten' }, + ar: { score: 'درجة الخطر', level: 'مستوى الخطر', wins: 'انتصارات الحكومة', losses: 'خسائر الحكومة', cross: 'تصويتات متعددة الأحزاب', defections: 'الانشقاقات الداخلية', votes: 'نقاط التصويت المحللة' }, + he: { score: 'ציון סיכון', level: 'רמת סיכון', wins: 'ניצחונות ממשלתיים', losses: 'הפסדים ממשלתיים', cross: 'הצבעות חוצות-מפלגות', defections: 'עריקות פנימיות', votes: 'נקודות הצבעה שנותחו' }, + ja: { score: 'リスクスコア', level: 'リスクレベル', wins: '政府の勝利', losses: '政府の敗北', cross: '超党派投票', defections: '内部離反', votes: '分析された投票点' }, + ko: { score: '위험 점수', level: '위험 수준', wins: '정부 승리', losses: '정부 패배', cross: '초당파 표결', defections: '내부 이탈', votes: '분석된 표결 항목' }, + zh: { score: '风险评分', level: '风险等级', wins: '政府获胜', losses: '政府失败', cross: '跨党派投票', defections: '内部叛离', votes: '分析的表决点' }, + }; + + const lbl = statsLabels[lang] ?? statsLabels.en; + + let html = `\n

${escapeHtml(heading)}

\n`; + html += `
\n`; + html += `

${escapeHtml(stress.riskIndex.summary)}

\n`; + html += `
    \n`; + html += `
  • ${escapeHtml(lbl.score)}: ${stress.riskIndex.score}/100
  • \n`; + html += `
  • ${escapeHtml(lbl.level)}: ${escapeHtml(riskLevelLabel)}
  • \n`; + + if (stress.totalVotes > 0) { + html += `
  • ${escapeHtml(lbl.votes)}: ${stress.totalVotes}
  • \n`; + html += `
  • ${escapeHtml(lbl.wins)}: ${stress.governmentWins}
  • \n`; + html += `
  • ${escapeHtml(lbl.losses)}: ${stress.governmentLosses}
  • \n`; + if (stress.crossPartyVotes > 0) { + html += `
  • ${escapeHtml(lbl.cross)}: ${stress.crossPartyVotes}
  • \n`; + } + if (stress.defections > 0) { + html += `
  • ${escapeHtml(lbl.defections)}: ${stress.defections}
  • \n`; + } + } + + html += `
\n`; + + if (stress.anomalies.length > 0) { + html += `
    \n`; + for (const anomaly of stress.anomalies.slice(0, 3)) { + html += `
  • ${escapeHtml(anomaly.description)}
  • \n`; + } + html += `
\n`; + } + + html += `
\n`; + return html; +} + +/** Weekly Activity section labels for all 14 languages */ +const WEEK_OVER_WEEK_LABELS: Readonly> = { + en: 'Weekly Activity', + sv: 'Veckans aktivitet', + da: 'Ugentlig aktivitet', + no: 'Ukentlig aktivitet', + fi: 'Viikon toiminta', + de: 'Wöchentliche Aktivität', + fr: 'Activité hebdomadaire', + es: 'Actividad semanal', + nl: 'Wekelijkse activiteit', + ar: 'النشاط الأسبوعي', + he: 'פעילות שבועית', + ja: '今週の活動', + ko: '주간 활동', + zh: '本周活动', +}; + +/** + * Generate the "Weekly Activity" HTML section for the given language. + * Shows current-week activity counts (documents, speeches, vote-points), + * the CIA coalition-stability trend direction, and trend insights. + * + * NOTE: The "direction" field reflects the CIA stability trend (30/90/365-day), + * not a comparison against last week's counts. + */ +export function generateWeekOverWeekSection( + metrics: WeekOverWeekMetrics, + lang: Language, +): string { + const heading = WEEK_OVER_WEEK_LABELS[lang] ?? WEEK_OVER_WEEK_LABELS.en; + + const activityLabels: Record = { + en: { documents: 'Documents', speeches: 'Speeches', votes: 'Votes', trend: 'Stability trend', direction: 'CIA stability trend', insights: 'Trend insights', increasing: 'Improving ↑', stable: 'Stable →', declining: 'Declining ↓' }, + sv: { documents: 'Dokument', speeches: 'Anföranden', votes: 'Voteringar', trend: 'Stabilitetstrend', direction: 'CIA-stabilitetstrend', insights: 'Trendinsikter', increasing: 'Förbättrad ↑', stable: 'Stabilt →', declining: 'Minskande ↓' }, + da: { documents: 'Dokumenter', speeches: 'Taler', votes: 'Afstemninger', trend: 'Stabilitetstrend', direction: 'CIA-stabilitetstrend', insights: 'Trendindsigter', increasing: 'Forbedret ↑', stable: 'Stabilt →', declining: 'Faldende ↓' }, + no: { documents: 'Dokumenter', speeches: 'Taler', votes: 'Voteringer', trend: 'Stabilitetstrend', direction: 'CIA-stabilitetstrend', insights: 'Trendinnsikter', increasing: 'Forbedret ↑', stable: 'Stabilt →', declining: 'Synkende ↓' }, + fi: { documents: 'Asiakirjat', speeches: 'Puheenvuorot', votes: 'Äänestykset', trend: 'Vakaustrendit', direction: 'CIA-vakaustrendi', insights: 'Trendianalyysit', increasing: 'Paraneva ↑', stable: 'Vakaa →', declining: 'Laskeva ↓' }, + de: { documents: 'Dokumente', speeches: 'Reden', votes: 'Abstimmungen', trend: 'Stabilitätstrend', direction: 'CIA-Stabilitätstrend', insights: 'Trendeinblicke', increasing: 'Verbessernd ↑', stable: 'Stabil →', declining: 'Abnehmend ↓' }, + fr: { documents: 'Documents', speeches: 'Discours', votes: 'Votes', trend: 'Tendance de stabilité', direction: 'Tendance stabilité CIA', insights: 'Aperçus des tendances', increasing: 'En amélioration ↑', stable: 'Stable →', declining: 'En baisse ↓' }, + es: { documents: 'Documentos', speeches: 'Discursos', votes: 'Votaciones', trend: 'Tendencia de estabilidad', direction: 'Tendencia estabilidad CIA', insights: 'Perspectivas de tendencia', increasing: 'Mejorando ↑', stable: 'Estable →', declining: 'En descenso ↓' }, + nl: { documents: 'Documenten', speeches: 'Toespraken', votes: 'Stemmingen', trend: 'Stabiliteitstrend', direction: 'CIA-stabiliteitsrend', insights: 'Trendinzichten', increasing: 'Verbeterend ↑', stable: 'Stabiel →', declining: 'Afnemend ↓' }, + ar: { documents: 'وثائق', speeches: 'خطب', votes: 'عمليات التصويت', trend: 'اتجاه الاستقرار', direction: 'اتجاه استقرار CIA', insights: 'رؤى الاتجاه', increasing: 'تحسّن ↑', stable: 'مستقر →', declining: 'متناقص ↓' }, + he: { documents: 'מסמכים', speeches: 'נאומים', votes: 'הצבעות', trend: 'מגמת יציבות', direction: 'מגמת יציבות CIA', insights: 'תובנות מגמה', increasing: 'משתפר ↑', stable: 'יציב →', declining: 'יורד ↓' }, + ja: { documents: '文書', speeches: '演説', votes: '採決', trend: '安定性トレンド', direction: 'CIA安定性トレンド', insights: 'トレンド考察', increasing: '改善中 ↑', stable: '安定 →', declining: '低下中 ↓' }, + ko: { documents: '문서', speeches: '연설', votes: '표결', trend: '안정성 추세', direction: 'CIA 안정성 추세', insights: '추세 인사이트', increasing: '개선 중 ↑', stable: '안정적 →', declining: '감소 중 ↓' }, + zh: { documents: '文件', speeches: '演讲', votes: '表决', trend: '稳定性趋势', direction: 'CIA稳定性趋势', insights: '趋势洞察', increasing: '改善中 ↑', stable: '稳定 →', declining: '下降中 ↓' }, + }; + + const lbl = activityLabels[lang] ?? activityLabels.en; + const directionText = metrics.activityChange === 'increasing' + ? lbl.increasing + : metrics.activityChange === 'declining' + ? lbl.declining + : lbl.stable; + + let html = `\n

${escapeHtml(heading)}

\n`; + html += `
\n`; + html += `
    \n`; + html += `
  • ${escapeHtml(lbl.documents)}: ${metrics.currentDocuments}
  • \n`; + html += `
  • ${escapeHtml(lbl.speeches)}: ${metrics.currentSpeeches}
  • \n`; + if (metrics.currentVotes > 0) { + html += `
  • ${escapeHtml(lbl.votes)}: ${metrics.currentVotes}
  • \n`; + } + html += `
  • ${escapeHtml(lbl.direction)}: ${escapeHtml(directionText)}
  • \n`; + html += `
\n`; + + if (metrics.trendComparison.insights.length > 0) { + html += `

${escapeHtml(lbl.insights)}: ${escapeHtml(metrics.trendComparison.insights[0] ?? '')}

\n`; + } + + html += `
\n`; + return html; +} + /** * Generate Weekly Review article in specified languages */ @@ -481,6 +873,32 @@ export async function generateWeeklyReview(options: GenerationOptions = {}): Pro const ciaContext = loadCIAContext(); console.log(` 🧠 CIA context: ${ciaContext.partyPerformance.length} parties, coalition stability ${ciaContext.coalitionStability.stabilityScore}/100, motion denial rate ${ciaContext.overallMotionDenialRate}%`); + // ── Step 6: fetch voting records for coalition stress analysis ───────── + console.log(' 🔄 Step 6 — Fetching voting records for coalition stress analysis...'); + let votingRecords: unknown[] = []; + try { + // search_voteringar does not support date params; use rm+limit then filter by datum + const voteMonth = today.getMonth(); // 0-based; September = 8 + const voteStartYear = voteMonth >= 8 ? today.getFullYear() : today.getFullYear() - 1; + const currentRm = `${voteStartYear}/${String(voteStartYear + 1).slice(-2)}`; + const allVotes = (await client.fetchVotingRecords({ rm: currentRm, limit: 200 })) as VotingRecord[]; + // Post-query filter to the weekly window using the datum field + votingRecords = allVotes.filter(r => { + const d = r.datum; + return typeof d === 'string' && d >= fromStr && d <= toStr; + }); + } catch (err: unknown) { + console.error('Failed to fetch voting records:', err); + } + + mcpCalls.push({ tool: 'search_voteringar', result: votingRecords }); + console.log(` 🗳 Found ${votingRecords.length} voting records`); + + // ── Compute coalition stress and week-over-week metrics ──────────────── + const coalitionStress = analyzeCoalitionStress(votingRecords as VotingRecord[], ciaContext); + const weekMetrics = calculateWeekOverWeekMetrics(documents, speeches, votingRecords as VotingRecord[], ciaContext); + console.log(` 📈 Coalition risk: ${coalitionStress.riskIndex.level} (${coalitionStress.riskIndex.score}/100), activity: ${weekMetrics.activityChange}`); + // ── Generate articles ────────────────────────────────────────────────── const slug = `${formatDateForSlug(today)}-weekly-review`; const articles: GeneratedArticle[] = []; @@ -489,9 +907,12 @@ export async function generateWeeklyReview(options: GenerationOptions = {}): Pro console.log(` 🌐 Generating ${lang.toUpperCase()} version...`); const content: string = generateArticleContent({ documents, ciaContext }, 'weekly-review', lang); + const coalitionSection: string = generateCoalitionDynamicsSection(coalitionStress, lang); + const weekOverWeekSection: string = generateWeekOverWeekSection(weekMetrics, lang); + const fullContent: string = content + coalitionSection + weekOverWeekSection; const watchPoints = extractWatchPoints({ documents, ciaContext }, lang); const metadata = generateMetadata({ documents, ciaContext }, 'weekly-review', lang); - const readTime: string = calculateReadTime(content); + const readTime: string = calculateReadTime(fullContent); const sources: string[] = generateSources([ 'search_dokument', 'get_dokument_innehall', @@ -499,6 +920,7 @@ export async function generateWeeklyReview(options: GenerationOptions = {}): Pro 'get_betankanden', 'get_propositioner', 'get_motioner', + 'search_voteringar', ]); const titles: TitleSet = getTitles(lang, documents.length); @@ -511,7 +933,7 @@ export async function generateWeeklyReview(options: GenerationOptions = {}): Pro type: 'retrospective' as ArticleCategory, readTime, lang, - content, + content: fullContent, watchPoints, sources, keywords: metadata.keywords, @@ -540,7 +962,7 @@ export async function generateWeeklyReview(options: GenerationOptions = {}): Pro mcpCalls, crossReferences: { event: `${documents.length} documents over ${lookbackDays} days`, - sources: ['search_dokument', 'get_dokument_innehall', 'search_anforanden', 'get_betankanden', 'get_propositioner', 'get_motioner'] + sources: ['search_dokument', 'get_dokument_innehall', 'search_anforanden', 'get_betankanden', 'get_propositioner', 'get_motioner', 'search_voteringar'] } }; } catch (error: unknown) { diff --git a/tests/news-types/weekly-review.test.ts b/tests/news-types/weekly-review.test.ts index 502abd90..62855186 100644 --- a/tests/news-types/weekly-review.test.ts +++ b/tests/news-types/weekly-review.test.ts @@ -25,6 +25,10 @@ interface MockMCPClientShape { searchDocuments: Mock<(params: Record) => Promise>; fetchDocumentDetails: Mock<(dokId: string, includeFullText?: boolean) => Promise>>; searchSpeeches: Mock<(params: Record) => Promise>; + fetchVotingRecords: Mock<(filters: Record) => Promise>; + fetchCommitteeReports: Mock<(limit: number, rm: string) => Promise>; + fetchPropositions: Mock<(limit: number, rm: string) => Promise>; + fetchMotions: Mock<(limit: number, rm: string) => Promise>; } /** Validation input */ @@ -61,6 +65,32 @@ interface WeeklyReviewModule { writeArticle?: (html: string, filename: string) => void; }) => Promise; readonly validateWeeklyReview: (article: ArticleInput) => WeeklyReviewValidationResult; + readonly analyzeCoalitionStress: ( + votingRecords: Array<{ parti?: string; rost?: string; bet?: string; punkt?: string }>, + ciaContext: Record + ) => { + governmentWins: number; + governmentLosses: number; + crossPartyVotes: number; + defections: number; + totalVotes: number; + riskIndex: { score: number; level: string; summary: string }; + anomalies: Array<{ type: string; severity: string; description: string }>; + }; + readonly calculateWeekOverWeekMetrics: ( + documents: unknown[], + speeches: unknown[], + votingRecords: unknown[], + ciaContext: Record + ) => { + currentDocuments: number; + currentSpeeches: number; + currentVotes: number; + activityChange: string; + trendComparison: { overallDirection: string; insights: string[] }; + }; + readonly generateCoalitionDynamicsSection: (stress: Record, lang: Language) => string; + readonly generateWeekOverWeekSection: (metrics: Record, lang: Language) => string; } // Mock MCP client @@ -75,6 +105,10 @@ const { mockClientInstance, mockDocuments, MockMCPClient } = vi.hoisted(() => { searchDocuments: vi.fn().mockResolvedValue(mockDocuments) as MockMCPClientShape['searchDocuments'], fetchDocumentDetails: vi.fn().mockResolvedValue({ summary: 'Full document text', fullText: 'Complete analysis of the document.' }) as MockMCPClientShape['fetchDocumentDetails'], searchSpeeches: vi.fn().mockResolvedValue([]) as MockMCPClientShape['searchSpeeches'], + fetchVotingRecords: vi.fn().mockResolvedValue([]) as MockMCPClientShape['fetchVotingRecords'], + fetchCommitteeReports: vi.fn().mockResolvedValue([]) as MockMCPClientShape['fetchCommitteeReports'], + fetchPropositions: vi.fn().mockResolvedValue([]) as MockMCPClientShape['fetchPropositions'], + fetchMotions: vi.fn().mockResolvedValue([]) as MockMCPClientShape['fetchMotions'], }; function MockMCPClient(): MockMCPClientShape { @@ -100,6 +134,10 @@ describe('Weekly Review Article Generation', () => { mockClientInstance.searchDocuments.mockResolvedValue(mockDocuments); mockClientInstance.fetchDocumentDetails.mockResolvedValue({ summary: 'Full document text', fullText: 'Complete analysis.' }); mockClientInstance.searchSpeeches.mockResolvedValue([]); + mockClientInstance.fetchVotingRecords.mockResolvedValue([]); + mockClientInstance.fetchCommitteeReports.mockResolvedValue([]); + mockClientInstance.fetchPropositions.mockResolvedValue([]); + mockClientInstance.fetchMotions.mockResolvedValue([]); }); afterEach(() => { @@ -116,6 +154,10 @@ describe('Weekly Review Article Generation', () => { it('should require search_dokument tool', () => { expect(weeklyReviewModule.REQUIRED_TOOLS).toContain('search_dokument'); }); + + it('should require search_voteringar tool', () => { + expect(weeklyReviewModule.REQUIRED_TOOLS).toContain('search_voteringar'); + }); }); describe('Data Collection', () => { @@ -228,4 +270,308 @@ describe('Weekly Review Article Generation', () => { expect(result.articles.length).toBe(1); }); }); + + describe('Coalition Stress Analysis', () => { + it('should export analyzeCoalitionStress function', () => { + expect(weeklyReviewModule.analyzeCoalitionStress).toBeDefined(); + expect(typeof weeklyReviewModule.analyzeCoalitionStress).toBe('function'); + }); + + it('should return zero counts for empty voting records', () => { + const result = weeklyReviewModule.analyzeCoalitionStress([], { + coalitionStability: { stabilityScore: 75, riskLevel: 'low', defectionProbability: 0.1, majorityMargin: 5 }, + partyPerformance: [], + votingPatterns: { keyIssues: [] }, + overallMotionDenialRate: 96, + } as unknown as Record); + + expect(result.governmentWins).toBe(0); + expect(result.governmentLosses).toBe(0); + expect(result.totalVotes).toBe(0); + expect(result.riskIndex).toBeDefined(); + expect(result.riskIndex.score).toBeGreaterThanOrEqual(0); + expect(result.anomalies).toBeDefined(); + }); + + it('should detect government wins from voting records', () => { + const votingRecords = [ + { parti: 'M', rost: 'Ja', bet: 'AU10', punkt: '1' }, + { parti: 'KD', rost: 'Ja', bet: 'AU10', punkt: '1' }, + { parti: 'L', rost: 'Ja', bet: 'AU10', punkt: '1' }, + { parti: 'S', rost: 'Nej', bet: 'AU10', punkt: '1' }, + { parti: 'V', rost: 'Nej', bet: 'AU10', punkt: '1' }, + ]; + + const result = weeklyReviewModule.analyzeCoalitionStress(votingRecords, { + coalitionStability: { stabilityScore: 75, riskLevel: 'low', defectionProbability: 0.1, majorityMargin: 5 }, + partyPerformance: [], + votingPatterns: { keyIssues: [] }, + overallMotionDenialRate: 96, + } as unknown as Record); + + expect(result.governmentWins).toBe(1); + expect(result.governmentLosses).toBe(0); + expect(result.totalVotes).toBe(1); + }); + + it('should detect government wins when government votes Nej to reject opposition proposal', () => { + // Government rejects opposition motion: Nej majority = government win + const votingRecords = [ + { parti: 'M', rost: 'Nej', bet: 'SoU5', punkt: '2' }, + { parti: 'KD', rost: 'Nej', bet: 'SoU5', punkt: '2' }, + { parti: 'SD', rost: 'Nej', bet: 'SoU5', punkt: '2' }, + { parti: 'S', rost: 'Ja', bet: 'SoU5', punkt: '2' }, + { parti: 'V', rost: 'Ja', bet: 'SoU5', punkt: '2' }, + ]; + + const result = weeklyReviewModule.analyzeCoalitionStress(votingRecords, { + coalitionStability: { stabilityScore: 75, riskLevel: 'low', defectionProbability: 0.1, majorityMargin: 5 }, + partyPerformance: [], + votingPatterns: { keyIssues: [] }, + overallMotionDenialRate: 96, + } as unknown as Record); + + // Government position is Nej, Nej majority → government won + expect(result.governmentWins).toBe(1); + expect(result.governmentLosses).toBe(0); + }); + + it('should detect cross-party votes', () => { + const votingRecords = [ + { parti: 'M', rost: 'Ja', bet: 'FiU20', punkt: '1' }, + { parti: 'S', rost: 'Ja', bet: 'FiU20', punkt: '1' }, + ]; + + const result = weeklyReviewModule.analyzeCoalitionStress(votingRecords, { + coalitionStability: { stabilityScore: 70, riskLevel: 'low', defectionProbability: 0.15, majorityMargin: 4 }, + partyPerformance: [], + votingPatterns: { keyIssues: [] }, + overallMotionDenialRate: 96, + } as unknown as Record); + + expect(result.crossPartyVotes).toBeGreaterThanOrEqual(1); + }); + + it('should detect cross-party alignment when government and opposition both vote Nej', () => { + // Government position is Nej; opposition also votes Nej → cross-party alignment + const votingRecords = [ + { parti: 'M', rost: 'Nej', bet: 'CU3', punkt: '1' }, + { parti: 'S', rost: 'Nej', bet: 'CU3', punkt: '1' }, + { parti: 'V', rost: 'Ja', bet: 'CU3', punkt: '1' }, + ]; + + const result = weeklyReviewModule.analyzeCoalitionStress(votingRecords, { + coalitionStability: { stabilityScore: 70, riskLevel: 'low', defectionProbability: 0.1, majorityMargin: 4 }, + partyPerformance: [], + votingPatterns: { keyIssues: [] }, + overallMotionDenialRate: 96, + } as unknown as Record); + + expect(result.crossPartyVotes).toBeGreaterThanOrEqual(1); + }); + + it('should skip win/loss when government bloc vote is evenly split', () => { + // 1 govYes, 1 govNo → no clear position → skip win/loss + const votingRecords = [ + { parti: 'M', rost: 'Ja', bet: 'NU1', punkt: '1' }, + { parti: 'KD', rost: 'Nej', bet: 'NU1', punkt: '1' }, + { parti: 'S', rost: 'Ja', bet: 'NU1', punkt: '1' }, + ]; + + const result = weeklyReviewModule.analyzeCoalitionStress(votingRecords, { + coalitionStability: { stabilityScore: 70, riskLevel: 'low', defectionProbability: 0.1, majorityMargin: 4 }, + partyPerformance: [], + votingPatterns: { keyIssues: [] }, + overallMotionDenialRate: 96, + } as unknown as Record); + + expect(result.governmentWins).toBe(0); + expect(result.governmentLosses).toBe(0); + // Defection should still be recorded (bloc split) + expect(result.defections).toBeGreaterThanOrEqual(1); + }); + + it('should normalize whole-percent defectionProbability for risk calculations', () => { + // defectionProbability=15 (whole percent) should not produce invalid risk scores + const result = weeklyReviewModule.analyzeCoalitionStress([], { + coalitionStability: { stabilityScore: 60, riskLevel: 'medium', defectionProbability: 15, majorityMargin: 3 }, + partyPerformance: [], + votingPatterns: { keyIssues: [] }, + overallMotionDenialRate: 80, + } as unknown as Record); + + // riskIndex.score should be in [0, 100] + expect(result.riskIndex.score).toBeGreaterThanOrEqual(0); + expect(result.riskIndex.score).toBeLessThanOrEqual(100); + }); + + it('should clamp negative and non-finite defectionProbability to 0', () => { + for (const bad of [-0.5, -10, Infinity, -Infinity, NaN]) { + const result = weeklyReviewModule.analyzeCoalitionStress([], { + coalitionStability: { stabilityScore: 60, riskLevel: 'medium', defectionProbability: bad, majorityMargin: 3 }, + partyPerformance: [], + votingPatterns: { keyIssues: [] }, + overallMotionDenialRate: 80, + } as unknown as Record); + expect(result.riskIndex.score).toBeGreaterThanOrEqual(0); + expect(result.riskIndex.score).toBeLessThanOrEqual(100); + } + }); + + it('should include voting records call in mcpCalls', async () => { + mockClientInstance.fetchVotingRecords.mockResolvedValue([ + { parti: 'M', rost: 'Ja', bet: 'AU10', punkt: '1' }, + ]); + + const result = await weeklyReviewModule.generateWeeklyReview({ languages: ['en'] }); + + expect(result.mcpCalls!.some((c: MCPCallRecord) => c.tool === 'search_voteringar')).toBe(true); + }); + }); + + describe('Weekly Activity Metrics', () => { + it('should export calculateWeekOverWeekMetrics function', () => { + expect(weeklyReviewModule.calculateWeekOverWeekMetrics).toBeDefined(); + expect(typeof weeklyReviewModule.calculateWeekOverWeekMetrics).toBe('function'); + }); + + it('should return current activity counts', () => { + const docs = [{ id: '1' }, { id: '2' }] as unknown[]; + const speeches = [{ id: 'a' }]; + const votes = [{ parti: 'M', rost: 'Ja' }]; + const cia = { + coalitionStability: { stabilityScore: 75, riskLevel: 'low', defectionProbability: 0.1, majorityMargin: 5 }, + partyPerformance: [], + votingPatterns: { keyIssues: [] }, + overallMotionDenialRate: 96, + } as unknown as Record; + + const result = weeklyReviewModule.calculateWeekOverWeekMetrics(docs, speeches, votes, cia); + + expect(result.currentDocuments).toBe(2); + expect(result.currentSpeeches).toBe(1); + expect(result.currentVotes).toBe(1); + expect(['increasing', 'stable', 'declining']).toContain(result.activityChange); + expect(result.trendComparison).toBeDefined(); + expect(Array.isArray(result.trendComparison.insights)).toBe(true); + }); + + it('should count distinct vote-points not raw records', () => { + // 3 raw records but only 1 distinct vote-point + const docs: unknown[] = []; + const speeches: unknown[] = []; + const votes = [ + { parti: 'M', rost: 'Ja', bet: 'AU10', punkt: '1' }, + { parti: 'KD', rost: 'Ja', bet: 'AU10', punkt: '1' }, + { parti: 'S', rost: 'Nej', bet: 'AU10', punkt: '1' }, + ]; + const cia = { + coalitionStability: { stabilityScore: 75, riskLevel: 'low', defectionProbability: 0.1, majorityMargin: 5 }, + partyPerformance: [], + votingPatterns: { keyIssues: [] }, + overallMotionDenialRate: 96, + } as unknown as Record; + + const result = weeklyReviewModule.calculateWeekOverWeekMetrics(docs, speeches, votes, cia); + + // 3 raw records, but only 1 distinct bet-punkt → currentVotes should be 1 + expect(result.currentVotes).toBe(1); + }); + }); + + describe('Template Sections', () => { + it('should export generateCoalitionDynamicsSection function', () => { + expect(weeklyReviewModule.generateCoalitionDynamicsSection).toBeDefined(); + }); + + it('should export generateWeekOverWeekSection function', () => { + expect(weeklyReviewModule.generateWeekOverWeekSection).toBeDefined(); + }); + + it('should generate Coalition Dynamics section in English', () => { + const stress = { + governmentWins: 5, governmentLosses: 1, crossPartyVotes: 2, + defections: 0, totalVotes: 6, + riskIndex: { score: 30, level: 'LOW', summary: 'Coalition is stable.' }, + anomalies: [], + }; + const html = weeklyReviewModule.generateCoalitionDynamicsSection( + stress as unknown as Record, 'en' + ); + expect(html).toContain('Coalition Dynamics'); + expect(html).toContain('30'); + }); + + it('should generate Coalition Dynamics section in Swedish', () => { + const stress = { + governmentWins: 3, governmentLosses: 0, crossPartyVotes: 0, + defections: 1, totalVotes: 3, + riskIndex: { score: 45, level: 'MEDIUM', summary: 'Moderate risk.' }, + anomalies: [], + }; + const html = weeklyReviewModule.generateCoalitionDynamicsSection( + stress as unknown as Record, 'sv' + ); + expect(html).toContain('Koalitionsdynamik'); + }); + + it('should generate Coalition Dynamics section for all 14 languages', () => { + const stress = { + governmentWins: 2, governmentLosses: 0, crossPartyVotes: 0, + defections: 0, totalVotes: 2, + riskIndex: { score: 20, level: 'LOW', summary: 'Low risk.' }, + anomalies: [], + }; + const langs: Language[] = ['en', 'sv', 'da', 'no', 'fi', 'de', 'fr', 'es', 'nl', 'ar', 'he', 'ja', 'ko', 'zh']; + for (const lang of langs) { + const html = weeklyReviewModule.generateCoalitionDynamicsSection( + stress as unknown as Record, lang + ); + expect(html).toContain('

'); + expect(html).toContain('20'); + } + }); + + it('should generate Week-over-Week section in English', () => { + const metrics = { + currentDocuments: 10, currentSpeeches: 25, currentVotes: 8, + activityChange: 'stable', + trendComparison: { overallDirection: 'STABLE', insights: ['Coalition stable.'] }, + }; + const html = weeklyReviewModule.generateWeekOverWeekSection( + metrics as unknown as Record, 'en' + ); + expect(html).toContain('Weekly Activity'); + expect(html).toContain('10'); + expect(html).toContain('Coalition stable.'); + }); + + it('should generate Weekly Activity section for all 14 languages', () => { + const metrics = { + currentDocuments: 5, currentSpeeches: 10, currentVotes: 3, + activityChange: 'increasing', + trendComparison: { overallDirection: 'IMPROVING', insights: ['Improving trend.'] }, + }; + const langs: Language[] = ['en', 'sv', 'da', 'no', 'fi', 'de', 'fr', 'es', 'nl', 'ar', 'he', 'ja', 'ko', 'zh']; + for (const lang of langs) { + const html = weeklyReviewModule.generateWeekOverWeekSection( + metrics as unknown as Record, lang + ); + expect(html).toContain('

'); + expect(html).toContain('5'); + } + }); + + it('should include Coalition Dynamics and Weekly Activity in generated articles', async () => { + const result = await weeklyReviewModule.generateWeeklyReview({ languages: ['en', 'sv'] }); + + const enArticle = result.articles.find((a: GeneratedArticle) => a.lang === 'en'); + const svArticle = result.articles.find((a: GeneratedArticle) => a.lang === 'sv'); + + expect(enArticle!.html).toContain('Coalition Dynamics'); + expect(enArticle!.html).toContain('Weekly Activity'); + expect(svArticle!.html).toContain('Koalitionsdynamik'); + expect(svArticle!.html).toContain('Veckans aktivitet'); + }); + }); });