From 75cd005c0f2e3bb033d906d2f98bd07ad1e56dde Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:15:39 +0000 Subject: [PATCH 1/8] feat(weekly-review): add coalition analysis, risk scoring, and week-over-week metrics - Add search_voteringar to REQUIRED_TOOLS - Add analyzeCoalitionStress() with defection rate, cross-party vote detection, and risk scoring via calculateCoalitionRiskIndex/detectAnomalousPatterns from risk-analysis.ts - Add calculateWeekOverWeekMetrics() for document/speech/voting volume trends via generateTrendComparison - Add generateCoalitionDynamicsSection() with all 14 language templates - Add generateWeekOverWeekSection() with all 14 language templates - Extract GOVERNMENT_PARTIES/OPPOSITION_PARTIES as module-level constants - Add VALID_SEVERITIES allowlist for safe CSS class injection - Use try/catch pattern for non-fatal voting records fetch - Add 16 new tests covering all new functions and full 14-language coverage Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/news-types/weekly-review.ts | 360 ++++++++++++++++++++++++- tests/news-types/weekly-review.test.ts | 236 ++++++++++++++++ 2 files changed, 593 insertions(+), 3 deletions(-) diff --git a/scripts/news-types/weekly-review.ts b/scripts/news-types/weekly-review.ts index 46c1d4c2..3fdd0441 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,43 @@ 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; + [key: string]: unknown; +} + +/** Coalition stress analysis result derived from voting records */ +export interface CoalitionStressResult { + /** Number of vote points where the government majority (Ja) won */ + governmentWins: number; + /** Number of vote points where the government majority lost */ + 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 +428,281 @@ export function attachSpeechesToDocuments( } } +/** + * 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; + + // Government wins when Ja majority + if (totalYes > totalNo) { governmentWins++; } + else if (totalNo > totalYes) { governmentLosses++; } + + 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; + + // Cross-party: opposition voting with government + if (govYes > 0 && oppYes > 0) crossPartyVotes++; + // Defection: government bloc members split + if (govYes > 0 && govNo > 0) defections++; + } + + return { + governmentWins, + governmentLosses, + crossPartyVotes, + defections, + riskIndex: calculateCoalitionRiskIndex(ciaContext), + anomalies: detectAnomalousPatterns(ciaContext), + totalVotes: byPoint.size, + }; +} + +/** + * Calculate week-over-week comparative metrics. + * + * Combines current-week activity counts with the trend comparison + * from generateTrendComparison (risk-analysis.ts) to produce a + * directional activity assessment. + * + * @param documents - Documents collected this week + * @param speeches - Speeches collected this week + * @param votingRecords - Voting records collected this week + * @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'; + + return { + currentDocuments: documents.length, + currentSpeeches: speeches.length, + currentVotes: votingRecords.length, + 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; +} + +/** Week-over-Week Metrics section labels for all 14 languages */ +const WEEK_OVER_WEEK_LABELS: Readonly> = { + en: 'Week-over-Week Metrics', + sv: 'Vecka-för-vecka-mätvärden', + da: 'Uge-for-uge-målinger', + no: 'Uke-for-uke-metrikker', + fi: 'Viikko viikolta -mittarit', + de: 'Woche-für-Woche-Kennzahlen', + fr: 'Métriques semaine après semaine', + es: 'Métricas semana a semana', + nl: 'Week-over-week metrics', + ar: 'مقاييس الأسبوع بالأسبوع', + he: 'מדדים שבוע אחרי שבוע', + ja: '週次比較指標', + ko: '주간 비교 지표', + zh: '周环比指标', +}; + +/** + * Generate the "Week-over-Week Metrics" HTML section for the given language. + * Shows current-week activity counts, trend comparison from risk-analysis.ts, + * and directional activity change. + */ +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: 'Voting records', trend: 'Stability trend', direction: 'Activity direction', insights: 'Trend insights', increasing: 'Increasing ↑', stable: 'Stable →', declining: 'Declining ↓' }, + sv: { documents: 'Dokument', speeches: 'Anföranden', votes: 'Voteringsprotokoll', trend: 'Stabilitetstrend', direction: 'Aktivitetsutveckling', insights: 'Trendinsikter', increasing: 'Ökande ↑', stable: 'Stabilt →', declining: 'Minskande ↓' }, + da: { documents: 'Dokumenter', speeches: 'Taler', votes: 'Afstemningsprotokoller', trend: 'Stabilitetstrend', direction: 'Aktivitetsretning', insights: 'Trendindsigter', increasing: 'Stigende ↑', stable: 'Stabilt →', declining: 'Faldende ↓' }, + no: { documents: 'Dokumenter', speeches: 'Taler', votes: 'Voteringsprotokoller', trend: 'Stabilitetstrend', direction: 'Aktivitetsretning', insights: 'Trendinnsikter', increasing: 'Økende ↑', stable: 'Stabilt →', declining: 'Synkende ↓' }, + fi: { documents: 'Asiakirjat', speeches: 'Puheenvuorot', votes: 'Äänestystulokset', trend: 'Vakaustrendit', direction: 'Toimintasuunta', insights: 'Trendianalyysit', increasing: 'Kasvava ↑', stable: 'Vakaa →', declining: 'Laskeva ↓' }, + de: { documents: 'Dokumente', speeches: 'Reden', votes: 'Abstimmungsprotokolle', trend: 'Stabilitätstrend', direction: 'Aktivitätsentwicklung', insights: 'Trendeinblicke', increasing: 'Zunehmend ↑', stable: 'Stabil →', declining: 'Abnehmend ↓' }, + fr: { documents: 'Documents', speeches: 'Discours', votes: 'Relevés de vote', trend: 'Tendance de stabilité', direction: 'Direction de l\'activité', insights: 'Aperçus des tendances', increasing: 'En hausse ↑', stable: 'Stable →', declining: 'En baisse ↓' }, + es: { documents: 'Documentos', speeches: 'Discursos', votes: 'Registros de votación', trend: 'Tendencia de estabilidad', direction: 'Dirección de actividad', insights: 'Perspectivas de tendencia', increasing: 'En aumento ↑', stable: 'Estable →', declining: 'En descenso ↓' }, + nl: { documents: 'Documenten', speeches: 'Toespraken', votes: 'Stemregistraties', trend: 'Stabiliteitstrend', direction: 'Activiteitsrichting', insights: 'Trendinzichten', increasing: 'Toenemend ↑', stable: 'Stabiel →', declining: 'Afnemend ↓' }, + ar: { documents: 'وثائق', speeches: 'خطب', votes: 'سجلات التصويت', trend: 'اتجاه الاستقرار', direction: 'اتجاه النشاط', insights: 'رؤى الاتجاه', increasing: 'متزايد ↑', stable: 'مستقر →', declining: 'متناقص ↓' }, + he: { documents: 'מסמכים', speeches: 'נאומים', votes: 'פרוטוקולי הצבעה', trend: 'מגמת יציבות', direction: 'כיוון הפעילות', insights: 'תובנות מגמה', increasing: 'עולה ↑', stable: 'יציב →', declining: 'יורד ↓' }, + ja: { documents: '文書', speeches: '演説', votes: '投票記録', trend: '安定性トレンド', direction: '活動方向', insights: 'トレンド考察', increasing: '増加中 ↑', stable: '安定 →', declining: '減少中 ↓' }, + ko: { documents: '문서', speeches: '연설', votes: '표결 기록', trend: '안정성 추세', direction: '활동 방향', insights: '추세 인사이트', increasing: '증가 중 ↑', stable: '안정적 →', declining: '감소 중 ↓' }, + zh: { documents: '文件', speeches: '演讲', votes: '表决记录', trend: '稳定性趋势', direction: '活动方向', 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 +814,23 @@ 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 { + votingRecords = (await client.fetchVotingRecords({ rm: '2025/26', limit: 50 })) as unknown[]; + } 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 +839,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 +852,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 +865,7 @@ export async function generateWeeklyReview(options: GenerationOptions = {}): Pro type: 'retrospective' as ArticleCategory, readTime, lang, - content, + content: fullContent, watchPoints, sources, keywords: metadata.keywords, @@ -540,7 +894,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..c7217425 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,198 @@ 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 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 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('Week-over-Week 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); + }); + }); + + 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('Week-over-Week Metrics'); + expect(html).toContain('10'); + expect(html).toContain('Coalition stable.'); + }); + + it('should generate Week-over-Week 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 Week-over-Week 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('Week-over-Week Metrics'); + expect(svArticle!.html).toContain('Koalitionsdynamik'); + expect(svArticle!.html).toContain('Vecka-för-vecka-mätvärden'); + }); + }); }); From b9fbfc5f530be229ab28010083135f03cb5fe61b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:20:45 +0000 Subject: [PATCH 2/8] fix(weekly-review): use government position majority for win/loss detection Instead of assuming 'Ja' majority = government win, determine the government bloc's position (majority of M/KD/L/SD votes) then check if that position matches the chamber majority. This correctly handles the common case where the government votes 'Nej' to reject opposition proposals. Also adds a test covering the 'government votes Nej and wins' scenario. Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/news-types/weekly-review.ts | 18 +++++++++++++----- tests/news-types/weekly-review.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/scripts/news-types/weekly-review.ts b/scripts/news-types/weekly-review.ts index 3fdd0441..15369a7f 100644 --- a/scripts/news-types/weekly-review.ts +++ b/scripts/news-types/weekly-review.ts @@ -466,15 +466,23 @@ export function analyzeCoalitionStress( const totalYes = records.filter(r => r.rost === 'Ja').length; const totalNo = records.filter(r => r.rost === 'Nej').length; - // Government wins when Ja majority - if (totalYes > totalNo) { governmentWins++; } - else if (totalNo > totalYes) { governmentLosses++; } - 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; - // Cross-party: opposition voting with government + // Determine government position (what most government members voted) + const govPosition = govYes > govNo ? 'Ja' : 'Nej'; + + // Government wins when its position matches the chamber majority + if (totalYes !== totalNo) { + const governmentWon = + (govPosition === 'Ja' && totalYes > totalNo) || + (govPosition === 'Nej' && totalNo > totalYes); + if (governmentWon) { governmentWins++; } + else { governmentLosses++; } + } + + // Cross-party: opposition voting with government (both voting Ja) if (govYes > 0 && oppYes > 0) crossPartyVotes++; // Defection: government bloc members split if (govYes > 0 && govNo > 0) defections++; diff --git a/tests/news-types/weekly-review.test.ts b/tests/news-types/weekly-review.test.ts index c7217425..afea5f79 100644 --- a/tests/news-types/weekly-review.test.ts +++ b/tests/news-types/weekly-review.test.ts @@ -314,6 +314,28 @@ describe('Weekly Review Article Generation', () => { 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' }, From 4fc9afa60484278e7babc1864c1e521d7d23d5ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:53:59 +0000 Subject: [PATCH 3/8] fix(weekly-review): cross-party alignment, govBloc tie, vote-point dedup, defProb normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cross-party detection now uses govPosition direction (Nej alignment counted when opposition also votes Nej, not just Ja) - Government win/loss skipped when govYes === govNo (no clear bloc position — avoids defaulting to Nej arbitrarily) - currentVotes counts distinct bet-punkt pairs to match coalition analysis semantics and avoid raw-record inflation - normalizedCIAContext() converts whole-percent defectionProbability to 0-1 before calling calculateCoalitionRiskIndex/detectAnomalousPatterns - Four new targeted tests covering each behaviour Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/news-types/weekly-review.ts | 44 ++++++++++++--- tests/news-types/weekly-review.test.ts | 75 ++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 7 deletions(-) diff --git a/scripts/news-types/weekly-review.ts b/scripts/news-types/weekly-review.ts index 15369a7f..935fde1e 100644 --- a/scripts/news-types/weekly-review.ts +++ b/scripts/news-types/weekly-review.ts @@ -428,6 +428,24 @@ 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' || defProb <= 1) return ctx; + const clamped = Math.min(1, Math.max(0, defProb / 100)); + return { + ...ctx, + coalitionStability: { + ...ctx.coalitionStability!, + defectionProbability: clamped, + }, + }; +} + /** * Analyse coalition stress from a list of voting records. * @@ -469,12 +487,14 @@ export function analyzeCoalitionStress( 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 (what most government members voted) + // 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 (totalYes !== totalNo) { + if (govPositionClear && totalYes !== totalNo) { const governmentWon = (govPosition === 'Ja' && totalYes > totalNo) || (govPosition === 'Nej' && totalNo > totalYes); @@ -482,8 +502,9 @@ export function analyzeCoalitionStress( else { governmentLosses++; } } - // Cross-party: opposition voting with government (both voting Ja) - if (govYes > 0 && oppYes > 0) crossPartyVotes++; + // 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++; } @@ -493,8 +514,8 @@ export function analyzeCoalitionStress( governmentLosses, crossPartyVotes, defections, - riskIndex: calculateCoalitionRiskIndex(ciaContext), - anomalies: detectAnomalousPatterns(ciaContext), + riskIndex: calculateCoalitionRiskIndex(normalizedCIAContext(ciaContext)), + anomalies: detectAnomalousPatterns(normalizedCIAContext(ciaContext)), totalVotes: byPoint.size, }; } @@ -526,10 +547,19 @@ export function calculateWeekOverWeekMetrics( ? '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: votingRecords.length, + currentVotes, trendComparison, activityChange, }; diff --git a/tests/news-types/weekly-review.test.ts b/tests/news-types/weekly-review.test.ts index afea5f79..c51d6436 100644 --- a/tests/news-types/weekly-review.test.ts +++ b/tests/news-types/weekly-review.test.ts @@ -352,6 +352,59 @@ describe('Weekly Review Article Generation', () => { 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 include voting records call in mcpCalls', async () => { mockClientInstance.fetchVotingRecords.mockResolvedValue([ { parti: 'M', rost: 'Ja', bet: 'AU10', punkt: '1' }, @@ -389,6 +442,28 @@ describe('Weekly Review Article Generation', () => { 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', () => { From 439fab680f20371c6a6e5457ee81041df7a509df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:21:19 +0000 Subject: [PATCH 4/8] fix(weekly-review): JSDoc, defProb edge cases, date-range voting fetch, vote-point labels - governmentWins/governmentLosses JSDoc updated to reflect bloc-position-vs-chamber-majority semantics (not just Ja wins) - normalizedCIAContext() now handles negative and non-finite defectionProbability: negatives clamped to 0, non-finite set to 0, fraction-form values already in [0,1] clamped to that range - fetchVotingRecords uses { from: fromStr, to: toStr } instead of hardcoded rm:'2025/26', bounding to the weekly window - votes labels updated to Votes/Voteringar/Afstemninger etc. to accurately reflect distinct vote-points, not raw record counts - New edge-case test for negative and non-finite defectionProbability Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/news-types/weekly-review.ts | 52 ++++++++++++++++---------- tests/news-types/weekly-review.test.ts | 13 +++++++ 2 files changed, 45 insertions(+), 20 deletions(-) diff --git a/scripts/news-types/weekly-review.ts b/scripts/news-types/weekly-review.ts index 935fde1e..f4b7783f 100644 --- a/scripts/news-types/weekly-review.ts +++ b/scripts/news-types/weekly-review.ts @@ -108,9 +108,9 @@ export interface VotingRecord { /** Coalition stress analysis result derived from voting records */ export interface CoalitionStressResult { - /** Number of vote points where the government majority (Ja) won */ + /** Number of vote points where the government bloc position (Ja/Nej) matched the chamber majority */ governmentWins: number; - /** Number of vote points where the government majority lost */ + /** 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; @@ -435,13 +435,25 @@ export function attachSpeechesToDocuments( */ function normalizedCIAContext(ctx: CIAContext): CIAContext { const defProb = ctx.coalitionStability?.defectionProbability; - if (typeof defProb !== 'number' || defProb <= 1) return ctx; - const clamped = Math.min(1, Math.max(0, defProb / 100)); + 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: clamped, + defectionProbability: normalized, }, }; } @@ -699,20 +711,20 @@ export function generateWeekOverWeekSection( const heading = WEEK_OVER_WEEK_LABELS[lang] ?? WEEK_OVER_WEEK_LABELS.en; const activityLabels: Record = { - en: { documents: 'Documents', speeches: 'Speeches', votes: 'Voting records', trend: 'Stability trend', direction: 'Activity direction', insights: 'Trend insights', increasing: 'Increasing ↑', stable: 'Stable →', declining: 'Declining ↓' }, - sv: { documents: 'Dokument', speeches: 'Anföranden', votes: 'Voteringsprotokoll', trend: 'Stabilitetstrend', direction: 'Aktivitetsutveckling', insights: 'Trendinsikter', increasing: 'Ökande ↑', stable: 'Stabilt →', declining: 'Minskande ↓' }, - da: { documents: 'Dokumenter', speeches: 'Taler', votes: 'Afstemningsprotokoller', trend: 'Stabilitetstrend', direction: 'Aktivitetsretning', insights: 'Trendindsigter', increasing: 'Stigende ↑', stable: 'Stabilt →', declining: 'Faldende ↓' }, - no: { documents: 'Dokumenter', speeches: 'Taler', votes: 'Voteringsprotokoller', trend: 'Stabilitetstrend', direction: 'Aktivitetsretning', insights: 'Trendinnsikter', increasing: 'Økende ↑', stable: 'Stabilt →', declining: 'Synkende ↓' }, - fi: { documents: 'Asiakirjat', speeches: 'Puheenvuorot', votes: 'Äänestystulokset', trend: 'Vakaustrendit', direction: 'Toimintasuunta', insights: 'Trendianalyysit', increasing: 'Kasvava ↑', stable: 'Vakaa →', declining: 'Laskeva ↓' }, - de: { documents: 'Dokumente', speeches: 'Reden', votes: 'Abstimmungsprotokolle', trend: 'Stabilitätstrend', direction: 'Aktivitätsentwicklung', insights: 'Trendeinblicke', increasing: 'Zunehmend ↑', stable: 'Stabil →', declining: 'Abnehmend ↓' }, - fr: { documents: 'Documents', speeches: 'Discours', votes: 'Relevés de vote', trend: 'Tendance de stabilité', direction: 'Direction de l\'activité', insights: 'Aperçus des tendances', increasing: 'En hausse ↑', stable: 'Stable →', declining: 'En baisse ↓' }, - es: { documents: 'Documentos', speeches: 'Discursos', votes: 'Registros de votación', trend: 'Tendencia de estabilidad', direction: 'Dirección de actividad', insights: 'Perspectivas de tendencia', increasing: 'En aumento ↑', stable: 'Estable →', declining: 'En descenso ↓' }, - nl: { documents: 'Documenten', speeches: 'Toespraken', votes: 'Stemregistraties', trend: 'Stabiliteitstrend', direction: 'Activiteitsrichting', insights: 'Trendinzichten', increasing: 'Toenemend ↑', stable: 'Stabiel →', declining: 'Afnemend ↓' }, - ar: { documents: 'وثائق', speeches: 'خطب', votes: 'سجلات التصويت', trend: 'اتجاه الاستقرار', direction: 'اتجاه النشاط', insights: 'رؤى الاتجاه', increasing: 'متزايد ↑', stable: 'مستقر →', declining: 'متناقص ↓' }, - he: { documents: 'מסמכים', speeches: 'נאומים', votes: 'פרוטוקולי הצבעה', trend: 'מגמת יציבות', direction: 'כיוון הפעילות', insights: 'תובנות מגמה', increasing: 'עולה ↑', stable: 'יציב →', declining: 'יורד ↓' }, - ja: { documents: '文書', speeches: '演説', votes: '投票記録', trend: '安定性トレンド', direction: '活動方向', insights: 'トレンド考察', increasing: '増加中 ↑', stable: '安定 →', declining: '減少中 ↓' }, - ko: { documents: '문서', speeches: '연설', votes: '표결 기록', trend: '안정성 추세', direction: '활동 방향', insights: '추세 인사이트', increasing: '증가 중 ↑', stable: '안정적 →', declining: '감소 중 ↓' }, - zh: { documents: '文件', speeches: '演讲', votes: '表决记录', trend: '稳定性趋势', direction: '活动方向', insights: '趋势洞察', increasing: '增加中 ↑', stable: '稳定 →', declining: '减少中 ↓' }, + en: { documents: 'Documents', speeches: 'Speeches', votes: 'Votes', trend: 'Stability trend', direction: 'Activity direction', insights: 'Trend insights', increasing: 'Increasing ↑', stable: 'Stable →', declining: 'Declining ↓' }, + sv: { documents: 'Dokument', speeches: 'Anföranden', votes: 'Voteringar', trend: 'Stabilitetstrend', direction: 'Aktivitetsutveckling', insights: 'Trendinsikter', increasing: 'Ökande ↑', stable: 'Stabilt →', declining: 'Minskande ↓' }, + da: { documents: 'Dokumenter', speeches: 'Taler', votes: 'Afstemninger', trend: 'Stabilitetstrend', direction: 'Aktivitetsretning', insights: 'Trendindsigter', increasing: 'Stigende ↑', stable: 'Stabilt →', declining: 'Faldende ↓' }, + no: { documents: 'Dokumenter', speeches: 'Taler', votes: 'Voteringer', trend: 'Stabilitetstrend', direction: 'Aktivitetsretning', insights: 'Trendinnsikter', increasing: 'Økende ↑', stable: 'Stabilt →', declining: 'Synkende ↓' }, + fi: { documents: 'Asiakirjat', speeches: 'Puheenvuorot', votes: 'Äänestykset', trend: 'Vakaustrendit', direction: 'Toimintasuunta', insights: 'Trendianalyysit', increasing: 'Kasvava ↑', stable: 'Vakaa →', declining: 'Laskeva ↓' }, + de: { documents: 'Dokumente', speeches: 'Reden', votes: 'Abstimmungen', trend: 'Stabilitätstrend', direction: 'Aktivitätsentwicklung', insights: 'Trendeinblicke', increasing: 'Zunehmend ↑', stable: 'Stabil →', declining: 'Abnehmend ↓' }, + fr: { documents: 'Documents', speeches: 'Discours', votes: 'Votes', trend: 'Tendance de stabilité', direction: 'Direction de l\'activité', insights: 'Aperçus des tendances', increasing: 'En hausse ↑', stable: 'Stable →', declining: 'En baisse ↓' }, + es: { documents: 'Documentos', speeches: 'Discursos', votes: 'Votaciones', trend: 'Tendencia de estabilidad', direction: 'Dirección de actividad', insights: 'Perspectivas de tendencia', increasing: 'En aumento ↑', stable: 'Estable →', declining: 'En descenso ↓' }, + nl: { documents: 'Documenten', speeches: 'Toespraken', votes: 'Stemmingen', trend: 'Stabiliteitstrend', direction: 'Activiteitsrichting', insights: 'Trendinzichten', increasing: 'Toenemend ↑', stable: 'Stabiel →', declining: 'Afnemend ↓' }, + ar: { documents: 'وثائق', speeches: 'خطب', votes: 'عمليات التصويت', trend: 'اتجاه الاستقرار', direction: 'اتجاه النشاط', insights: 'رؤى الاتجاه', increasing: 'متزايد ↑', stable: 'مستقر →', declining: 'متناقص ↓' }, + he: { documents: 'מסמכים', speeches: 'נאומים', votes: 'הצבעות', trend: 'מגמת יציבות', direction: 'כיוון הפעילות', insights: 'תובנות מגמה', increasing: 'עולה ↑', stable: 'יציב →', declining: 'יורד ↓' }, + ja: { documents: '文書', speeches: '演説', votes: '採決', trend: '安定性トレンド', direction: '活動方向', insights: 'トレンド考察', increasing: '増加中 ↑', stable: '安定 →', declining: '減少中 ↓' }, + ko: { documents: '문서', speeches: '연설', votes: '표결', trend: '안정성 추세', direction: '활동 방향', insights: '추세 인사이트', increasing: '증가 중 ↑', stable: '안정적 →', declining: '감소 중 ↓' }, + zh: { documents: '文件', speeches: '演讲', votes: '表决', trend: '稳定性趋势', direction: '活动方向', insights: '趋势洞察', increasing: '增加中 ↑', stable: '稳定 →', declining: '减少中 ↓' }, }; const lbl = activityLabels[lang] ?? activityLabels.en; @@ -856,7 +868,7 @@ export async function generateWeeklyReview(options: GenerationOptions = {}): Pro console.log(' 🔄 Step 6 — Fetching voting records for coalition stress analysis...'); let votingRecords: unknown[] = []; try { - votingRecords = (await client.fetchVotingRecords({ rm: '2025/26', limit: 50 })) as unknown[]; + votingRecords = (await client.fetchVotingRecords({ from: fromStr, to: toStr, limit: 50 })) as unknown[]; } catch (err: unknown) { console.error('Failed to fetch voting records:', err); } diff --git a/tests/news-types/weekly-review.test.ts b/tests/news-types/weekly-review.test.ts index c51d6436..2bd06cbb 100644 --- a/tests/news-types/weekly-review.test.ts +++ b/tests/news-types/weekly-review.test.ts @@ -405,6 +405,19 @@ describe('Weekly Review Article Generation', () => { 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' }, From ef46aa17e02f3bf6f18441c67a87725e9256d476 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:09:17 +0000 Subject: [PATCH 5/8] fix(weekly-review): rm+datum filter for voting records; rename WoW to Weekly Activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetchVotingRecords now uses { rm: currentRm, limit: 200 } with dynamic riksmöte (month>=8 formula matching motions.ts), then filters records to the weekly window using the datum field (post-query, since search_voteringar does not support from/to date params) - VotingRecord interface gains explicit datum?: string field - Rename misleading 'Week-over-Week Metrics' section heading to 'Weekly Activity' in all 14 languages — the section shows current counts + CIA stability trend direction, not a delta against last week - 'Activity direction' label updated to 'CIA stability trend' to clarify the source of the direction indicator - JSDoc on calculateWeekOverWeekMetrics and generateWeekOverWeekSection updated to accurately describe the CIA-trend-based direction - Tests updated: describe block, expect strings, integration assertions Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/news-types/weekly-review.ts | 94 +++++++++++++++----------- tests/news-types/weekly-review.test.ts | 12 ++-- 2 files changed, 62 insertions(+), 44 deletions(-) diff --git a/scripts/news-types/weekly-review.ts b/scripts/news-types/weekly-review.ts index f4b7783f..e55f6786 100644 --- a/scripts/news-types/weekly-review.ts +++ b/scripts/news-types/weekly-review.ts @@ -103,6 +103,8 @@ export interface VotingRecord { rost?: string; bet?: string; punkt?: string; + /** ISO date string (YYYY-MM-DD) used for post-query date filtering */ + datum?: string; [key: string]: unknown; } @@ -533,15 +535,19 @@ export function analyzeCoalitionStress( } /** - * Calculate week-over-week comparative metrics. + * Calculate current-week activity metrics with CIA trend direction. * - * Combines current-week activity counts with the trend comparison - * from generateTrendComparison (risk-analysis.ts) to produce a - * directional activity assessment. + * 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 + * @param votingRecords - Voting records collected this week (already date-filtered) * @param ciaContext - CIA intelligence context for trend analysis */ export function calculateWeekOverWeekMetrics( @@ -681,28 +687,31 @@ export function generateCoalitionDynamicsSection( return html; } -/** Week-over-Week Metrics section labels for all 14 languages */ +/** Weekly Activity section labels for all 14 languages */ const WEEK_OVER_WEEK_LABELS: Readonly> = { - en: 'Week-over-Week Metrics', - sv: 'Vecka-för-vecka-mätvärden', - da: 'Uge-for-uge-målinger', - no: 'Uke-for-uke-metrikker', - fi: 'Viikko viikolta -mittarit', - de: 'Woche-für-Woche-Kennzahlen', - fr: 'Métriques semaine après semaine', - es: 'Métricas semana a semana', - nl: 'Week-over-week metrics', - ar: 'مقاييس الأسبوع بالأسبوع', - he: 'מדדים שבוע אחרי שבוע', - ja: '週次比較指標', - ko: '주간 비교 지표', - zh: '周环比指标', + 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 "Week-over-Week Metrics" HTML section for the given language. - * Shows current-week activity counts, trend comparison from risk-analysis.ts, - * and directional activity change. + * 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, @@ -711,20 +720,20 @@ export function generateWeekOverWeekSection( 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: 'Activity direction', insights: 'Trend insights', increasing: 'Increasing ↑', stable: 'Stable →', declining: 'Declining ↓' }, - sv: { documents: 'Dokument', speeches: 'Anföranden', votes: 'Voteringar', trend: 'Stabilitetstrend', direction: 'Aktivitetsutveckling', insights: 'Trendinsikter', increasing: 'Ökande ↑', stable: 'Stabilt →', declining: 'Minskande ↓' }, - da: { documents: 'Dokumenter', speeches: 'Taler', votes: 'Afstemninger', trend: 'Stabilitetstrend', direction: 'Aktivitetsretning', insights: 'Trendindsigter', increasing: 'Stigende ↑', stable: 'Stabilt →', declining: 'Faldende ↓' }, - no: { documents: 'Dokumenter', speeches: 'Taler', votes: 'Voteringer', trend: 'Stabilitetstrend', direction: 'Aktivitetsretning', insights: 'Trendinnsikter', increasing: 'Økende ↑', stable: 'Stabilt →', declining: 'Synkende ↓' }, - fi: { documents: 'Asiakirjat', speeches: 'Puheenvuorot', votes: 'Äänestykset', trend: 'Vakaustrendit', direction: 'Toimintasuunta', insights: 'Trendianalyysit', increasing: 'Kasvava ↑', stable: 'Vakaa →', declining: 'Laskeva ↓' }, - de: { documents: 'Dokumente', speeches: 'Reden', votes: 'Abstimmungen', trend: 'Stabilitätstrend', direction: 'Aktivitätsentwicklung', insights: 'Trendeinblicke', increasing: 'Zunehmend ↑', stable: 'Stabil →', declining: 'Abnehmend ↓' }, - fr: { documents: 'Documents', speeches: 'Discours', votes: 'Votes', trend: 'Tendance de stabilité', direction: 'Direction de l\'activité', insights: 'Aperçus des tendances', increasing: 'En hausse ↑', stable: 'Stable →', declining: 'En baisse ↓' }, - es: { documents: 'Documentos', speeches: 'Discursos', votes: 'Votaciones', trend: 'Tendencia de estabilidad', direction: 'Dirección de actividad', insights: 'Perspectivas de tendencia', increasing: 'En aumento ↑', stable: 'Estable →', declining: 'En descenso ↓' }, - nl: { documents: 'Documenten', speeches: 'Toespraken', votes: 'Stemmingen', trend: 'Stabiliteitstrend', direction: 'Activiteitsrichting', insights: 'Trendinzichten', increasing: 'Toenemend ↑', stable: 'Stabiel →', declining: 'Afnemend ↓' }, - ar: { documents: 'وثائق', speeches: 'خطب', votes: 'عمليات التصويت', trend: 'اتجاه الاستقرار', direction: 'اتجاه النشاط', insights: 'رؤى الاتجاه', increasing: 'متزايد ↑', stable: 'مستقر →', declining: 'متناقص ↓' }, - he: { documents: 'מסמכים', speeches: 'נאומים', votes: 'הצבעות', trend: 'מגמת יציבות', direction: 'כיוון הפעילות', insights: 'תובנות מגמה', increasing: 'עולה ↑', stable: 'יציב →', declining: 'יורד ↓' }, - ja: { documents: '文書', speeches: '演説', votes: '採決', trend: '安定性トレンド', direction: '活動方向', insights: 'トレンド考察', increasing: '増加中 ↑', stable: '安定 →', declining: '減少中 ↓' }, - ko: { documents: '문서', speeches: '연설', votes: '표결', trend: '안정성 추세', direction: '활동 방향', insights: '추세 인사이트', increasing: '증가 중 ↑', stable: '안정적 →', declining: '감소 중 ↓' }, - zh: { documents: '文件', speeches: '演讲', votes: '表决', trend: '稳定性趋势', direction: '活动方向', insights: '趋势洞察', increasing: '增加中 ↑', stable: '稳定 →', declining: '减少中 ↓' }, + 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; @@ -868,7 +877,16 @@ export async function generateWeeklyReview(options: GenerationOptions = {}): Pro console.log(' 🔄 Step 6 — Fetching voting records for coalition stress analysis...'); let votingRecords: unknown[] = []; try { - votingRecords = (await client.fetchVotingRecords({ from: fromStr, to: toStr, limit: 50 })) as unknown[]; + // 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); } diff --git a/tests/news-types/weekly-review.test.ts b/tests/news-types/weekly-review.test.ts index 2bd06cbb..62855186 100644 --- a/tests/news-types/weekly-review.test.ts +++ b/tests/news-types/weekly-review.test.ts @@ -429,7 +429,7 @@ describe('Weekly Review Article Generation', () => { }); }); - describe('Week-over-Week Metrics', () => { + describe('Weekly Activity Metrics', () => { it('should export calculateWeekOverWeekMetrics function', () => { expect(weeklyReviewModule.calculateWeekOverWeekMetrics).toBeDefined(); expect(typeof weeklyReviewModule.calculateWeekOverWeekMetrics).toBe('function'); @@ -541,12 +541,12 @@ describe('Weekly Review Article Generation', () => { const html = weeklyReviewModule.generateWeekOverWeekSection( metrics as unknown as Record, 'en' ); - expect(html).toContain('Week-over-Week Metrics'); + expect(html).toContain('Weekly Activity'); expect(html).toContain('10'); expect(html).toContain('Coalition stable.'); }); - it('should generate Week-over-Week section for all 14 languages', () => { + it('should generate Weekly Activity section for all 14 languages', () => { const metrics = { currentDocuments: 5, currentSpeeches: 10, currentVotes: 3, activityChange: 'increasing', @@ -562,16 +562,16 @@ describe('Weekly Review Article Generation', () => { } }); - it('should include Coalition Dynamics and Week-over-Week in generated articles', async () => { + 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('Week-over-Week Metrics'); + expect(enArticle!.html).toContain('Weekly Activity'); expect(svArticle!.html).toContain('Koalitionsdynamik'); - expect(svArticle!.html).toContain('Vecka-för-vecka-mätvärden'); + expect(svArticle!.html).toContain('Veckans aktivitet'); }); }); }); From 311d793efba8239afff4d21122447d985f2243e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:17:10 +0000 Subject: [PATCH 6/8] =?UTF-8?q?fix(weekly-review):=20datum=20normalization?= =?UTF-8?q?,=20dedup=20normCtx,=20lift=20label=20consts,=20riksm=C3=B6te?= =?UTF-8?q?=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/news-types/weekly-review.ts | 115 ++++++++++++++++------------ 1 file changed, 67 insertions(+), 48 deletions(-) diff --git a/scripts/news-types/weekly-review.ts b/scripts/news-types/weekly-review.ts index e55f6786..e5b642c7 100644 --- a/scripts/news-types/weekly-review.ts +++ b/scripts/news-types/weekly-review.ts @@ -523,13 +523,14 @@ export function analyzeCoalitionStress( if (govYes > 0 && govNo > 0) defections++; } + const normCtx = normalizedCIAContext(ciaContext); return { governmentWins, governmentLosses, crossPartyVotes, defections, - riskIndex: calculateCoalitionRiskIndex(normalizedCIAContext(ciaContext)), - anomalies: detectAnomalousPatterns(normalizedCIAContext(ciaContext)), + riskIndex: calculateCoalitionRiskIndex(normCtx), + anomalies: detectAnomalousPatterns(normCtx), totalVotes: byPoint.size, }; } @@ -619,6 +620,27 @@ const RISK_LEVEL_LABELS: Readonly>> = { zh: { LOW: '低', MEDIUM: '中等', HIGH: '高', CRITICAL: '危急' }, }; +/** Coalition Dynamics stats labels for all 14 languages */ +const COALITION_STATS_LABELS: Readonly> = { + 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: '分析的表决点' }, +}; + /** * Generate the "Coalition Dynamics" HTML section for the given language. * Shows risk index, government wins/losses, cross-party votes, defections, @@ -632,27 +654,7 @@ export function generateCoalitionDynamicsSection( 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; + const lbl = COALITION_STATS_LABELS[lang] ?? COALITION_STATS_LABELS.en; let html = `\n

${escapeHtml(heading)}

\n`; html += `
\n`; @@ -705,6 +707,27 @@ const WEEK_OVER_WEEK_LABELS: Readonly> = { zh: '本周活动', }; +/** Weekly Activity metric labels for all 14 languages */ +const WEEKLY_ACTIVITY_LABELS: Readonly> = { + 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: '下降中 ↓' }, +}; + /** * Generate the "Weekly Activity" HTML section for the given language. * Shows current-week activity counts (documents, speeches, vote-points), @@ -719,24 +742,7 @@ export function generateWeekOverWeekSection( ): 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 lbl = WEEKLY_ACTIVITY_LABELS[lang] ?? WEEKLY_ACTIVITY_LABELS.en; const directionText = metrics.activityChange === 'increasing' ? lbl.increasing : metrics.activityChange === 'declining' @@ -877,15 +883,28 @@ export async function generateWeeklyReview(options: GenerationOptions = {}): Pro 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 + // search_voteringar does not support date params; use rm+limit then filter by datum. + // Derive the riksmöte(s) from both ends of the date range in case the window crosses + // the September session boundary (e.g. late Aug → early Sep lookback). + const startRmYear = startDate.getMonth() >= 8 ? startDate.getFullYear() : startDate.getFullYear() - 1; + const endRmYear = today.getMonth() >= 8 ? today.getFullYear() : today.getFullYear() - 1; + const rmValues: string[] = []; + rmValues.push(`${startRmYear}/${String(startRmYear + 1).slice(-2)}`); + if (endRmYear !== startRmYear) { + const endRm = `${endRmYear}/${String(endRmYear + 1).slice(-2)}`; + if (!rmValues.includes(endRm)) rmValues.push(endRm); + } + const allVotesArrays = await Promise.all( + rmValues.map(rm => client.fetchVotingRecords({ rm, limit: 200 }) as Promise), + ); + const allVotes: VotingRecord[] = allVotesArrays.flat(); + // Post-query filter to the weekly window using the datum field. + // Normalize to YYYY-MM-DD in case datum includes a time component. votingRecords = allVotes.filter(r => { const d = r.datum; - return typeof d === 'string' && d >= fromStr && d <= toStr; + if (typeof d !== 'string') return false; + const dateStr = d.includes('T') ? d.split('T')[0] : d; + return dateStr >= fromStr && dateStr <= toStr; }); } catch (err: unknown) { console.error('Failed to fetch voting records:', err); From 8491dbf4548a5bdf84d0826fd19844ed174dab68 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:40:48 +0000 Subject: [PATCH 7/8] =?UTF-8?q?refactor/fix/test:=20rename=20WeekOverWeekM?= =?UTF-8?q?etrics=E2=86=92WeeklyActivityMetrics,=20fix=20nl=20typo,=20add?= =?UTF-8?q?=20rm-boundary=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/news-types/weekly-review.ts | 20 +++++------ tests/news-types/weekly-review.test.ts | 49 ++++++++++++++++++++------ 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/scripts/news-types/weekly-review.ts b/scripts/news-types/weekly-review.ts index e5b642c7..b13b1d1f 100644 --- a/scripts/news-types/weekly-review.ts +++ b/scripts/news-types/weekly-review.ts @@ -126,8 +126,8 @@ export interface CoalitionStressResult { totalVotes: number; } -/** Week-over-week comparative metrics */ -export interface WeekOverWeekMetrics { +/** Weekly activity metrics — current-week counts with CIA coalition-stability trend direction (not a prior-week comparison) */ +export interface WeeklyActivityMetrics { currentDocuments: number; currentSpeeches: number; currentVotes: number; @@ -551,15 +551,15 @@ export function analyzeCoalitionStress( * @param votingRecords - Voting records collected this week (already date-filtered) * @param ciaContext - CIA intelligence context for trend analysis */ -export function calculateWeekOverWeekMetrics( +export function calculateWeeklyActivityMetrics( documents: RawDocument[], speeches: unknown[], votingRecords: VotingRecord[], ciaContext: CIAContext, -): WeekOverWeekMetrics { +): WeeklyActivityMetrics { const trendComparison = generateTrendComparison(ciaContext); - const activityChange: WeekOverWeekMetrics['activityChange'] = + const activityChange: WeeklyActivityMetrics['activityChange'] = trendComparison.overallDirection === 'IMPROVING' ? 'increasing' : trendComparison.overallDirection === 'DECLINING' || trendComparison.overallDirection === 'VOLATILE' @@ -720,7 +720,7 @@ const WEEKLY_ACTIVITY_LABELS: Readonly; }; - readonly calculateWeekOverWeekMetrics: ( + readonly calculateWeeklyActivityMetrics: ( documents: unknown[], speeches: unknown[], votingRecords: unknown[], @@ -90,7 +90,7 @@ interface WeeklyReviewModule { trendComparison: { overallDirection: string; insights: string[] }; }; readonly generateCoalitionDynamicsSection: (stress: Record, lang: Language) => string; - readonly generateWeekOverWeekSection: (metrics: Record, lang: Language) => string; + readonly generateWeeklyActivitySection: (metrics: Record, lang: Language) => string; } // Mock MCP client @@ -430,9 +430,9 @@ describe('Weekly Review Article Generation', () => { }); describe('Weekly Activity Metrics', () => { - it('should export calculateWeekOverWeekMetrics function', () => { - expect(weeklyReviewModule.calculateWeekOverWeekMetrics).toBeDefined(); - expect(typeof weeklyReviewModule.calculateWeekOverWeekMetrics).toBe('function'); + it('should export calculateWeeklyActivityMetrics function', () => { + expect(weeklyReviewModule.calculateWeeklyActivityMetrics).toBeDefined(); + expect(typeof weeklyReviewModule.calculateWeeklyActivityMetrics).toBe('function'); }); it('should return current activity counts', () => { @@ -446,7 +446,7 @@ describe('Weekly Review Article Generation', () => { overallMotionDenialRate: 96, } as unknown as Record; - const result = weeklyReviewModule.calculateWeekOverWeekMetrics(docs, speeches, votes, cia); + const result = weeklyReviewModule.calculateWeeklyActivityMetrics(docs, speeches, votes, cia); expect(result.currentDocuments).toBe(2); expect(result.currentSpeeches).toBe(1); @@ -472,7 +472,7 @@ describe('Weekly Review Article Generation', () => { overallMotionDenialRate: 96, } as unknown as Record; - const result = weeklyReviewModule.calculateWeekOverWeekMetrics(docs, speeches, votes, cia); + const result = weeklyReviewModule.calculateWeeklyActivityMetrics(docs, speeches, votes, cia); // 3 raw records, but only 1 distinct bet-punkt → currentVotes should be 1 expect(result.currentVotes).toBe(1); @@ -484,8 +484,8 @@ describe('Weekly Review Article Generation', () => { expect(weeklyReviewModule.generateCoalitionDynamicsSection).toBeDefined(); }); - it('should export generateWeekOverWeekSection function', () => { - expect(weeklyReviewModule.generateWeekOverWeekSection).toBeDefined(); + it('should export generateWeeklyActivitySection function', () => { + expect(weeklyReviewModule.generateWeeklyActivitySection).toBeDefined(); }); it('should generate Coalition Dynamics section in English', () => { @@ -538,7 +538,7 @@ describe('Weekly Review Article Generation', () => { activityChange: 'stable', trendComparison: { overallDirection: 'STABLE', insights: ['Coalition stable.'] }, }; - const html = weeklyReviewModule.generateWeekOverWeekSection( + const html = weeklyReviewModule.generateWeeklyActivitySection( metrics as unknown as Record, 'en' ); expect(html).toContain('Weekly Activity'); @@ -554,7 +554,7 @@ describe('Weekly Review Article Generation', () => { }; 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( + const html = weeklyReviewModule.generateWeeklyActivitySection( metrics as unknown as Record, lang ); expect(html).toContain('

'); @@ -573,5 +573,32 @@ describe('Weekly Review Article Generation', () => { expect(svArticle!.html).toContain('Koalitionsdynamik'); expect(svArticle!.html).toContain('Veckans aktivitet'); }); + + it('should fetch both riksmöte sessions when the 7-day window crosses the September boundary', async () => { + // Simulate "today" = September 5, 2026 → 7-day window is 2026-08-29 .. 2026-09-05 + // August belongs to rm 2025/26, September to rm 2026/27 — both sessions must be queried. + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-05T12:00:00Z')); + + const aug28Record = { parti: 'M', rost: 'Ja', bet: 'AU1', punkt: '1', datum: '2026-08-28' }; // outside window + const aug30Record = { parti: 'S', rost: 'Nej', bet: 'AU2', punkt: '1', datum: '2026-08-30' }; // inside window (old rm) + const sep04Record = { parti: 'M', rost: 'Ja', bet: 'AU3', punkt: '1', datum: '2026-09-04' }; // inside window (new rm) + + // First call (rm=2025/26) returns august record; second (rm=2026/27) returns september record + mockClientInstance.fetchVotingRecords + .mockResolvedValueOnce([aug28Record, aug30Record]) + .mockResolvedValueOnce([sep04Record]); + + await weeklyReviewModule.generateWeeklyReview({ languages: ['en'] }); + + // Both sessions should have been fetched + const rmArgs = mockClientInstance.fetchVotingRecords.mock.calls.map( + (call: unknown[]) => (call[0] as Record).rm + ); + expect(rmArgs).toContain('2025/26'); + expect(rmArgs).toContain('2026/27'); + + vi.useRealTimers(); + }); }); }); From 2376b6a3e0e5ed92e89c330e59e8db74e308e834 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:11:22 +0000 Subject: [PATCH 8/8] fix(weekly-review): address review-3867047062 feedback Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/news-types/weekly-review.ts | 59 ++++++++++++++++---------- tests/news-types/weekly-review.test.ts | 2 +- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/scripts/news-types/weekly-review.ts b/scripts/news-types/weekly-review.ts index b13b1d1f..4cccd845 100644 --- a/scripts/news-types/weekly-review.ts +++ b/scripts/news-types/weekly-review.ts @@ -48,6 +48,7 @@ import type { } from '../data-transformers/risk-analysis.js'; import type { Language } from '../types/language.js'; import type { ArticleCategory, GeneratedArticle, GenerationResult, MCPCallRecord } from '../types/article.js'; +import { getCurrentRiksmote } from './motions.js'; /** Swedish government coalition parties (current Tidö coalition) */ const GOVERNMENT_PARTIES = new Set(['M', 'KD', 'L', 'SD']); @@ -432,22 +433,29 @@ 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. + * + * risk-analysis.ts multiplies it by 100, so out-of-range values can + * explode scores. Expected input formats: + * - (0, 1] — already a proper probability fraction; kept as-is. + * Note: exactly 1.0 is treated as 100% (not as 1% whole-percent). + * - (1, ∞) — treated as a whole-percent (loadCIAContext returns min 5, + * e.g. 50 means 50% → normalized to 0.5); clamped to 1. + * - Non-finite or ≤ 0 — coerced to 0 (no defection risk). */ function normalizedCIAContext(ctx: CIAContext): CIAContext { const defProb = ctx.coalitionStability?.defectionProbability; if (typeof defProb !== 'number') return ctx; let normalized: number; - if (!Number.isFinite(defProb)) { + if (!Number.isFinite(defProb) || defProb <= 0) { + // Non-finite or non-positive: no defection risk. 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 if (defProb <= 1) { + // Already a fraction in (0, 1]: keep as-is (1.0 = 100% probability). + normalized = defProb; } else { - // Already fraction form — clamp negatives to 0 - normalized = Math.max(0, defProb); + // Whole-percent value (e.g. loadCIAContext min 5): convert to fraction and clamp. + normalized = Math.min(1, defProb / 100); } if (normalized === defProb) return ctx; @@ -573,7 +581,7 @@ export function calculateWeeklyActivityMetrics( uniqueVotePoints.add(`${record.bet}-${record.punkt}`); } } - const currentVotes = uniqueVotePoints.size > 0 ? uniqueVotePoints.size : votingRecords.length; + const currentVotes = uniqueVotePoints.size; return { currentDocuments: documents.length, @@ -633,7 +641,7 @@ const COALITION_STATS_LABELS: Readonly 0) { html += `
    \n`; for (const anomaly of stress.anomalies.slice(0, 3)) { - html += `
  • ${escapeHtml(anomaly.description)}
  • \n`; + const severityRaw = anomaly.severity; + const severityNormalized = severityRaw.toLowerCase(); + const isValidSeverity = VALID_SEVERITIES.has(severityNormalized); + if (!isValidSeverity) { + console.warn(`WeeklyReview: Unexpected anomaly severity '${severityRaw}', falling back to 'low'.`); + } + const severityClass = isValidSeverity ? severityNormalized : 'low'; + html += `
  • ${escapeHtml(anomaly.description)}
  • \n`; } html += `
\n`; } @@ -884,26 +899,24 @@ export async function generateWeeklyReview(options: GenerationOptions = {}): Pro let votingRecords: unknown[] = []; try { // search_voteringar does not support date params; use rm+limit then filter by datum. - // Derive the riksmöte(s) from both ends of the date range in case the window crosses - // the September session boundary (e.g. late Aug → early Sep lookback). - const startRmYear = startDate.getMonth() >= 8 ? startDate.getFullYear() : startDate.getFullYear() - 1; - const endRmYear = today.getMonth() >= 8 ? today.getFullYear() : today.getFullYear() - 1; - const rmValues: string[] = []; - rmValues.push(`${startRmYear}/${String(startRmYear + 1).slice(-2)}`); - if (endRmYear !== startRmYear) { - const endRm = `${endRmYear}/${String(endRmYear + 1).slice(-2)}`; - if (!rmValues.includes(endRm)) rmValues.push(endRm); - } + // Derive the riksmöte(s) from both ends of the date range using the shared + // getCurrentRiksmote utility (Sep boundary: month >= 8 → new session). + const startRm = getCurrentRiksmote(startDate); + const endRm = getCurrentRiksmote(today); + const rmValues = startRm === endRm ? [startRm] : [startRm, endRm]; const allVotesArrays = await Promise.all( rmValues.map(rm => client.fetchVotingRecords({ rm, limit: 200 }) as Promise), ); const allVotes: VotingRecord[] = allVotesArrays.flat(); // Post-query filter to the weekly window using the datum field. - // Normalize to YYYY-MM-DD in case datum includes a time component. votingRecords = allVotes.filter(r => { const d = r.datum; if (typeof d !== 'string') return false; - const dateStr = d.includes('T') ? d.split('T')[0] : d; + // Extract YYYY-MM-DD via regex to handle ISO timestamps and timezone suffixes + // (e.g. '2026-02-10T10:00:00' or '2026-09-05+02:00'). + const match = /^\d{4}-\d{2}-\d{2}/.exec(d); + if (!match) return false; + const dateStr = match[0]; return dateStr >= fromStr && dateStr <= toStr; }); } catch (err: unknown) { diff --git a/tests/news-types/weekly-review.test.ts b/tests/news-types/weekly-review.test.ts index f38062b8..03b03bb0 100644 --- a/tests/news-types/weekly-review.test.ts +++ b/tests/news-types/weekly-review.test.ts @@ -438,7 +438,7 @@ describe('Weekly Review Article Generation', () => { 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 votes = [{ parti: 'M', rost: 'Ja', bet: 'AU1', punkt: '1' }]; const cia = { coalitionStability: { stabilityScore: 75, riskLevel: 'low', defectionProbability: 0.1, majorityMargin: 5 }, partyPerformance: [],