From d486c16e093e0f94744b8950bc83a13e1c1f52cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:13:35 +0000 Subject: [PATCH 1/8] feat: enhance month-ahead with strategic forecasting, pipeline tracking, and trend analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add get_betankanden, get_propositioner, get_motioner to REQUIRED_TOOLS - Fetch committee reports (20), propositions (15), motions (50) in parallel - Calculate riksmöte dynamically from current date (Sep-Jun parliamentary year) - Add generateMonthAheadContent with Strategic Legislative Outlook, Committee Pipeline, and Policy Trends sections (14-language support) - Update data-transformers dispatcher to use new month-ahead generator - Add hasLegislativePipeline to MonthAheadValidationResult - Extend test mock with new MCP methods; 7 new tests (20 total) Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- .../data-transformers/content-generators.ts | 205 ++++++++++++++++++ scripts/data-transformers/index.ts | 3 +- scripts/news-types/month-ahead.ts | 68 +++++- tests/news-types/month-ahead.test.ts | 59 +++++ 4 files changed, 329 insertions(+), 6 deletions(-) diff --git a/scripts/data-transformers/content-generators.ts b/scripts/data-transformers/content-generators.ts index 690926df4..880eda5ef 100644 --- a/scripts/data-transformers/content-generators.ts +++ b/scripts/data-transformers/content-generators.ts @@ -1061,3 +1061,208 @@ export function generateGenericContent(data: ArticleContentData, lang: Language return content; } + +/** + * Generate Month-Ahead article content with strategic legislative forecasting. + * Extends week-ahead calendar coverage with committee pipeline tracking, + * propositions in pipeline, and motion trend analysis. + */ +export function generateMonthAheadContent(data: ArticleContentData, lang: Language | string): string { + // Base calendar/event content (handles events + upcoming documents) + let content = generateWeekAheadContent(data as WeekAheadData, lang); + + const propositions = data.propositions ?? []; + const reports = data.reports ?? []; + const motions = data.motions ?? []; + + // ── Strategic Legislative Outlook ──────────────────────────────────────── + if (propositions.length > 0) { + const outlookLabel = lang === 'sv' ? 'Strategisk lagstiftningsutsikt' + : lang === 'de' ? 'Strategischer Gesetzgebungsausblick' + : lang === 'fr' ? 'Perspectives législatives stratégiques' + : lang === 'es' ? 'Perspectiva legislativa estratégica' + : lang === 'da' ? 'Strategisk lovgivningsmæssigt udsyn' + : lang === 'no' ? 'Strategisk lovgivningsmessig utsikt' + : lang === 'fi' ? 'Strateginen lainsäädäntönäkymä' + : lang === 'nl' ? 'Strategisch wetgevingsoverzicht' + : lang === 'ar' ? 'التوقعات التشريعية الاستراتيجية' + : lang === 'he' ? 'תחזית חקיקתית אסטרטגית' + : lang === 'ja' ? '戦略的立法見通し' + : lang === 'ko' ? '전략적 입법 전망' + : lang === 'zh' ? '战略立法展望' + : 'Strategic Legislative Outlook'; + content += `\n

${outlookLabel}

\n`; + + // Lede: how many propositions are in the pipeline + const propLedeTemplates: Record string> = { + sv: n => `${n} propositioner befinner sig i den lagstiftande processen denna månad.`, + da: n => `${n} lovforslag befinder sig i den lovgivningsmæssige proces denne måned.`, + no: n => `${n} proposisjoner er i den lovgivningsmessige prosessen denne måneden.`, + fi: n => `${n} hallituksen esitystä on lainsäädäntöprosessissa tässä kuussa.`, + de: n => `${n} Regierungsvorlagen befinden sich diesen Monat im Gesetzgebungsprozess.`, + fr: n => `${n} propositions gouvernementales sont en cours de traitement législatif ce mois-ci.`, + es: n => `${n} proposiciones gubernamentales se encuentran en el proceso legislativo este mes.`, + nl: n => `${n} regeringsvoorstellen bevinden zich deze maand in het wetgevingsproces.`, + ar: n => `${n} مقترحات حكومية في المسار التشريعي هذا الشهر.`, + he: n => `${n} הצעות ממשלה נמצאות בתהליך החקיקתי החודש.`, + ja: n => `今月は${n}件の政府提案が立法プロセスにあります。`, + ko: n => `이달 ${n}건의 정부 법안이 입법 프로세스에 있습니다.`, + zh: n => `本月${n}项政府提案处于立法审议过程中。`, + }; + const propLedeTpl = propLedeTemplates[lang as string]; + const propLede = propLedeTpl + ? propLedeTpl(propositions.length) + : `${propositions.length} government propositions are in the legislative pipeline this month.`; + content += `

${escapeHtml(propLede)}

\n`; + + propositions.slice(0, 8).forEach(prop => { // 8 propositions: readable summary without overwhelming + const rec = prop as Record; + const titleText = rec['titel'] || rec['title'] || rec['doktyp'] || 'Proposition'; + const escapedTitle = escapeHtml(titleText); + const titleHtml = (rec['titel'] && !rec['title']) ? svSpan(escapedTitle, lang) : escapedTitle; + const significance = generatePolicySignificance(prop, lang); + const dokId = rec['dok_id'] ?? rec['id'] ?? ''; + const urlBase = 'https://riksdagen.se/sv/dokument-och-lagar/dokument/'; + const safeUrl = dokId ? sanitizeUrl(`${urlBase}${encodeURIComponent(dokId)}/`) : ''; + content += `
\n`; + content += `

${safeUrl ? `` : ''}${titleHtml}${safeUrl ? '' : ''}

\n`; + if (significance) { + content += `

${escapeHtml(significance)}

\n`; + } + content += `
\n`; + }); + } + + // ── Committee Pipeline ──────────────────────────────────────────────────── + if (reports.length > 0) { + const pipelineLabel = lang === 'sv' ? 'Utskottspipeline' + : lang === 'de' ? 'Ausschusspipeline' + : lang === 'fr' ? 'Pipeline des commissions' + : lang === 'es' ? 'Proceso en comité' + : lang === 'da' ? 'Udvalgspipeline' + : lang === 'no' ? 'Komitépipeline' + : lang === 'fi' ? 'Valiokuntaputkisto' + : lang === 'nl' ? 'Commissiepijplijn' + : lang === 'ar' ? 'مسار اللجان' + : lang === 'he' ? 'צינור הוועדות' + : lang === 'ja' ? '委員会パイプライン' + : lang === 'ko' ? '위원회 파이프라인' + : lang === 'zh' ? '委员会审议流程' + : 'Committee Pipeline'; + content += `\n

${pipelineLabel}

\n`; + + // Group reports by committee + const byCommittee: Record = {}; + reports.forEach(r => { + const key = (r as Record).organ || 'unknown'; + if (!byCommittee[key]) byCommittee[key] = []; + byCommittee[key].push(r); + }); + + Object.entries(byCommittee).slice(0, 5).forEach(([committeeCode, committeeReports]) => { // up to 5 committees + const committeeName = getCommitteeName(committeeCode, lang); + content += `

${escapeHtml(committeeName)}

\n`; + committeeReports.slice(0, 3).forEach(report => { // 3 reports per committee keeps the section scannable + const rec = report as Record; + const titleText = rec['titel'] || rec['title'] || 'Report'; + const escapedTitle = escapeHtml(titleText); + const titleHtml = (rec['titel'] && !rec['title']) ? svSpan(escapedTitle, lang) : escapedTitle; + const dokId = rec['dok_id'] ?? ''; + const urlBase = 'https://riksdagen.se/sv/dokument-och-lagar/dokument/'; + const safeUrl = dokId ? sanitizeUrl(`${urlBase}${encodeURIComponent(dokId)}/`) : ''; + content += `
\n`; + content += `

${safeUrl ? `` : ''}${titleHtml}${safeUrl ? '' : ''}

\n`; + content += `
\n`; + }); + }); + } + + // ── Policy Trends ───────────────────────────────────────────────────────── + if (motions.length > 0) { + const trendsLabel = lang === 'sv' ? 'Politiska trender' + : lang === 'de' ? 'Politische Trends' + : lang === 'fr' ? 'Tendances politiques' + : lang === 'es' ? 'Tendencias políticas' + : lang === 'da' ? 'Politiske tendenser' + : lang === 'no' ? 'Politiske trender' + : lang === 'fi' ? 'Poliittiset trendit' + : lang === 'nl' ? 'Politieke trends' + : lang === 'ar' ? 'الاتجاهات السياسية' + : lang === 'he' ? 'מגמות מדיניות' + : lang === 'ja' ? '政策トレンド' + : lang === 'ko' ? '정책 트렌드' + : lang === 'zh' ? '政策趋势' + : 'Policy Trends'; + content += `\n

${trendsLabel}

\n`; + + // Identify active policy domains from motions + const allTrendDomains = new Set(); + motions.forEach(m => detectPolicyDomains(m, lang).forEach(d => allTrendDomains.add(d))); + + // Party activity breakdown + const byPartyTrend: Record = {}; + motions.forEach(m => { + const party = normalizePartyKey((m as Record).parti); + byPartyTrend[party] = (byPartyTrend[party] || 0) + 1; + }); + + if (allTrendDomains.size > 0) { + const domainList = Array.from(allTrendDomains).slice(0, 5).join(', '); // top 5 domains for readability + const domainIntroTemplates: Record string> = { + sv: (d, n) => `${n} motioner identifierar aktiva policydomäner: ${d}.`, + da: (d, n) => `${n} motioner identificerer aktive politikdomæner: ${d}.`, + no: (d, n) => `${n} forslag identifiserer aktive politikkdomener: ${d}.`, + fi: (d, n) => `${n} aloitetta tunnistaa aktiiviset politiikka-alueet: ${d}.`, + de: (d, n) => `${n} Anträge identifizieren aktive Politikbereiche: ${d}.`, + fr: (d, n) => `${n} motions identifient des domaines politiques actifs: ${d}.`, + es: (d, n) => `${n} mociones identifican áreas de política activas: ${d}.`, + nl: (d, n) => `${n} moties identificeren actieve beleidsgebieden: ${d}.`, + ar: (d, n) => `${n} اقتراحات تُحدد مجالات السياسة النشطة: ${d}.`, + he: (d, n) => `${n} הצעות מזהות תחומי מדיניות פעילים: ${d}.`, + ja: (d, n) => `${n}件の動議が活発な政策領域を特定しています: ${d}。`, + ko: (d, n) => `${n}건의 동의가 활발한 정책 분야를 식별합니다: ${d}.`, + zh: (d, n) => `${n}项动议确定了活跃的政策领域:${d}。`, + }; + const domTpl = domainIntroTemplates[lang as string]; + const domainIntro = domTpl + ? domTpl(escapeHtml(domainList), motions.length) + : `${motions.length} motions identify active policy domains: ${escapeHtml(domainList)}.`; + content += `

${domainIntro}

\n`; + } + + // Top parties by motion volume + const topParties = Object.entries(byPartyTrend) + .filter(([k]) => k !== 'unknown') + .sort((a, b) => b[1] - a[1]) + .slice(0, 4); // 4 parties: covers the typical Swedish governing+major-opposition parties + + if (topParties.length > 0) { + content += `
    \n`; + topParties.forEach(([party, count]) => { + const partyMotionTemplates: Record string> = { + sv: (p, n) => `${p}: ${n} motioner inlämnade`, + da: (p, n) => `${p}: ${n} motioner indgivet`, + no: (p, n) => `${p}: ${n} forslag innsendt`, + fi: (p, n) => `${p}: ${n} aloitetta jätetty`, + de: (p, n) => `${p}: ${n} Anträge eingereicht`, + fr: (p, n) => `${p}: ${n} motions déposées`, + es: (p, n) => `${p}: ${n} mociones presentadas`, + nl: (p, n) => `${p}: ${n} moties ingediend`, + ar: (p, n) => `${p}: ${n} اقتراحات مقدمة`, + he: (p, n) => `${p}: ${n} הצעות הוגשו`, + ja: (p, n) => `${p}: ${n}件の動議を提出`, + ko: (p, n) => `${p}: ${n}건의 동의 제출`, + zh: (p, n) => `${p}: ${n}项动议提交`, + }; + const partyTpl = partyMotionTemplates[lang as string]; + const partyEntry = partyTpl + ? partyTpl(escapeHtml(party), count) + : `${escapeHtml(party)}: ${count} motions submitted`; + content += `
  • ${partyEntry}
  • \n`; + }); + content += `
\n`; + } + } + + return content; +} diff --git a/scripts/data-transformers/index.ts b/scripts/data-transformers/index.ts index dcba80e8a..98e010e34 100644 --- a/scripts/data-transformers/index.ts +++ b/scripts/data-transformers/index.ts @@ -65,6 +65,7 @@ import { generatePropositionsContent, generateMotionsContent, generateGenericContent, + generateMonthAheadContent, } from './content-generators.js'; /** @@ -85,7 +86,7 @@ export function generateArticleContent( case 'week-ahead': return generateWeekAheadContent(data as WeekAheadData, lang); case 'month-ahead': - return generateWeekAheadContent(data as WeekAheadData, lang); + return generateMonthAheadContent(data, lang); case 'committee-reports': return generateCommitteeContent(data, lang); case 'propositions': diff --git a/scripts/news-types/month-ahead.ts b/scripts/news-types/month-ahead.ts index 5e5bb25c1..4b0188555 100644 --- a/scripts/news-types/month-ahead.ts +++ b/scripts/news-types/month-ahead.ts @@ -47,6 +47,9 @@ import type { ArticleCategory, GeneratedArticle, GenerationResult, MCPCallRecord export const REQUIRED_TOOLS: readonly string[] = [ 'get_calendar_events', 'search_dokument', // conditional: used only when calendar is empty + 'get_betankanden', + 'get_propositioner', + 'get_motioner', ]; export interface TitleSet { @@ -59,6 +62,7 @@ export interface MonthAheadValidationResult { hasMinimumSources: boolean; hasForwardLookingTone: boolean; hasStrategicContext: boolean; + hasLegislativePipeline: boolean; passed: boolean; } @@ -102,6 +106,14 @@ export async function generateMonthAhead(options: GenerationOptions = {}): Promi const fromStr = formatDateForSlug(today); const toStr = formatDateForSlug(endDate); + // Determine current riksmöte (Swedish parliamentary year: Sep 1 → Jun/Jul of next year). + // Any date in September or later belongs to the new year's session (e.g. 2025-09-01 → "2025/26"). + const year = today.getFullYear(); + const month = today.getMonth() + 1; // 1-12 + const currentRiksmote = month >= 9 + ? `${year}/${String(year + 1).slice(-2)}` + : `${year - 1}/${String(year).slice(-2)}`; + console.log(` 🔄 Fetching calendar events ${fromStr} → ${toStr}...`); const events = await client.fetchCalendarEvents(fromStr, toStr) as RawDocument[]; mcpCalls.push({ tool: 'get_calendar_events', result: events }); @@ -138,6 +150,29 @@ export async function generateMonthAhead(options: GenerationOptions = {}): Promi } } + // ── Fetch strategic legislative pipeline data ────────────────────────── + console.log(' 🔄 Fetching legislative pipeline (betankanden, propositioner, motioner)...'); + const [committeeReports, propositionDocs, motionDocs] = await Promise.all([ + Promise.resolve() + .then(() => client.fetchCommitteeReports(20, currentRiksmote) as Promise) + .catch((err: unknown) => { console.error('Failed to fetch committee reports:', err); return [] as unknown[]; }), + Promise.resolve() + .then(() => client.fetchPropositions(15, currentRiksmote) as Promise) + .catch((err: unknown) => { console.error('Failed to fetch propositions:', err); return [] as unknown[]; }), + Promise.resolve() + .then(() => client.fetchMotions(50, currentRiksmote) as Promise) + .catch((err: unknown) => { console.error('Failed to fetch motions:', err); return [] as unknown[]; }), + ]); + + mcpCalls.push({ tool: 'get_betankanden', result: committeeReports }); + mcpCalls.push({ tool: 'get_propositioner', result: propositionDocs }); + mcpCalls.push({ tool: 'get_motioner', result: motionDocs }); + + console.log( + ` 📊 Pipeline: ${committeeReports.length} reports, ` + + `${propositionDocs.length} propositions, ${motionDocs.length} motions` + ); + const slug = `${formatDateForSlug(today)}-month-ahead`; const articles: GeneratedArticle[] = []; @@ -145,16 +180,27 @@ export async function generateMonthAhead(options: GenerationOptions = {}): Promi console.log(` 🌐 Generating ${lang.toUpperCase()} version...`); const dataForContent = events.length > 0 - ? { events } - : { events: [], documents }; + ? { + events, + reports: committeeReports as RawDocument[], + propositions: propositionDocs as RawDocument[], + motions: motionDocs as RawDocument[], + } + : { + events: [], + documents, + reports: committeeReports as RawDocument[], + propositions: propositionDocs as RawDocument[], + motions: motionDocs as RawDocument[], + }; const content: string = generateArticleContent(dataForContent, 'month-ahead', lang); const watchPoints = extractWatchPoints(dataForContent, lang); const metadata = generateMetadata(dataForContent, 'month-ahead', lang); const readTime: string = calculateReadTime(content); const usedTools = events.length > 0 - ? ['get_calendar_events'] - : ['get_calendar_events', 'search_dokument']; + ? ['get_calendar_events', 'get_betankanden', 'get_propositioner', 'get_motioner'] + : ['get_calendar_events', 'search_dokument', 'get_betankanden', 'get_propositioner', 'get_motioner']; const sources: string[] = generateSources(usedTools); const itemCount = events.length > 0 ? events.length : documents.length; @@ -199,7 +245,9 @@ export async function generateMonthAhead(options: GenerationOptions = {}): Promi event: events.length > 0 ? `${events.length} events over ${daysAhead} days` : `${documents.length} upcoming documents`, - sources: events.length > 0 ? ['calendar_events'] : ['calendar_events', 'search_dokument'], + sources: events.length > 0 + ? ['calendar_events', 'get_betankanden', 'get_propositioner', 'get_motioner'] + : ['calendar_events', 'search_dokument', 'get_betankanden', 'get_propositioner', 'get_motioner'], }, }; } catch (error: unknown) { @@ -286,12 +334,14 @@ export function validateMonthAhead(article: ArticleInput): MonthAheadValidationR const hasMinimumSources = countSources(article) >= 3; const hasForwardLookingTone = checkForwardLookingTone(article); const hasStrategicContext = checkStrategicContext(article); + const hasLegislativePipeline = checkLegislativePipeline(article); return { hasCalendarEvents, hasMinimumSources, hasForwardLookingTone, hasStrategicContext, + hasLegislativePipeline, passed: hasCalendarEvents && hasMinimumSources && hasForwardLookingTone && hasStrategicContext }; } @@ -323,3 +373,11 @@ function checkStrategicContext(article: ArticleInput): boolean { (article.content as string).toLowerCase().includes(keyword) ); } + +function checkLegislativePipeline(article: ArticleInput): boolean { + if (!article || !article.content) return false; + const pipelineKeywords = ['pipeline', 'committee', 'proposition', 'motion', 'report', 'betank']; + return pipelineKeywords.some(keyword => + (article.content as string).toLowerCase().includes(keyword) + ); +} diff --git a/tests/news-types/month-ahead.test.ts b/tests/news-types/month-ahead.test.ts index 6c2f95d55..0b2df6154 100644 --- a/tests/news-types/month-ahead.test.ts +++ b/tests/news-types/month-ahead.test.ts @@ -24,6 +24,9 @@ interface CalendarEvent { interface MockMCPClientShape { fetchCalendarEvents: Mock<(from: string, tom: string) => Promise>; searchDocuments: Mock<(params: 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 */ @@ -73,6 +76,9 @@ const { mockClientInstance, mockCalendarEvents, MockMCPClient } = vi.hoisted(() const mockClientInstance: MockMCPClientShape = { fetchCalendarEvents: vi.fn().mockResolvedValue(mockCalendarEvents) as MockMCPClientShape['fetchCalendarEvents'], searchDocuments: vi.fn().mockResolvedValue([]) as MockMCPClientShape['searchDocuments'], + fetchCommitteeReports: vi.fn().mockResolvedValue([]) as MockMCPClientShape['fetchCommitteeReports'], + fetchPropositions: vi.fn().mockResolvedValue([]) as MockMCPClientShape['fetchPropositions'], + fetchMotions: vi.fn().mockResolvedValue([]) as MockMCPClientShape['fetchMotions'], }; function MockMCPClient(): MockMCPClientShape { @@ -97,6 +103,9 @@ describe('Month-Ahead Article Generation', () => { vi.clearAllMocks(); mockClientInstance.fetchCalendarEvents.mockResolvedValue(mockCalendarEvents); mockClientInstance.searchDocuments.mockResolvedValue([]); + mockClientInstance.fetchCommitteeReports.mockResolvedValue([]); + mockClientInstance.fetchPropositions.mockResolvedValue([]); + mockClientInstance.fetchMotions.mockResolvedValue([]); }); afterEach(() => { @@ -117,6 +126,18 @@ describe('Month-Ahead Article Generation', () => { it('should require search_dokument tool for document fallback', () => { expect(monthAheadModule.REQUIRED_TOOLS).toContain('search_dokument'); }); + + it('should require get_betankanden tool for committee pipeline', () => { + expect(monthAheadModule.REQUIRED_TOOLS).toContain('get_betankanden'); + }); + + it('should require get_propositioner tool for strategic outlook', () => { + expect(monthAheadModule.REQUIRED_TOOLS).toContain('get_propositioner'); + }); + + it('should require get_motioner tool for trend analysis', () => { + expect(monthAheadModule.REQUIRED_TOOLS).toContain('get_motioner'); + }); }); describe('Data Collection', () => { @@ -129,6 +150,33 @@ describe('Month-Ahead Article Generation', () => { expect(result.mcpCalls!.some((call: MCPCallRecord) => call.tool === 'get_calendar_events')).toBe(true); }); + it('should fetch committee reports from MCP', async () => { + const result = await monthAheadModule.generateMonthAhead({ + languages: ['en'] + }); + + expect(mockClientInstance.fetchCommitteeReports).toHaveBeenCalled(); + expect(result.mcpCalls!.some((call: MCPCallRecord) => call.tool === 'get_betankanden')).toBe(true); + }); + + it('should fetch propositions from MCP', async () => { + const result = await monthAheadModule.generateMonthAhead({ + languages: ['en'] + }); + + expect(mockClientInstance.fetchPropositions).toHaveBeenCalled(); + expect(result.mcpCalls!.some((call: MCPCallRecord) => call.tool === 'get_propositioner')).toBe(true); + }); + + it('should fetch motions from MCP', async () => { + const result = await monthAheadModule.generateMonthAhead({ + languages: ['en'] + }); + + expect(mockClientInstance.fetchMotions).toHaveBeenCalled(); + expect(result.mcpCalls!.some((call: MCPCallRecord) => call.tool === 'get_motioner')).toBe(true); + }); + it('should handle empty calendar events', async () => { mockClientInstance.fetchCalendarEvents.mockResolvedValue([]); @@ -177,6 +225,17 @@ describe('Month-Ahead Article Generation', () => { expect(validation.hasCalendarEvents).toBe(true); expect(validation.hasForwardLookingTone).toBe(true); }); + + it('should include hasLegislativePipeline in validation result', () => { + const article: ArticleInput = { + content: 'Committee pipeline report proposition motion scheduled for next month.', + sources: ['source1', 'source2', 'source3'] + }; + + const validation = monthAheadModule.validateMonthAhead(article); + expect(validation).toHaveProperty('hasLegislativePipeline'); + expect(validation.hasLegislativePipeline).toBe(true); + }); }); describe('Multi-Language', () => { From a7eb61d65441bf5082a277942d1f573a8778d83a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 06:42:57 +0000 Subject: [PATCH 2/8] fix: address PR review comments on month-ahead enhancements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Include hasLegislativePipeline in validateMonthAhead passed condition - Fix double-escaping in Policy Trends domain list (escape after template) - Correct mock signatures to use string | null for rm parameter - Document why dynamic riksmöte differs from hardcoded pattern in other generators Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/data-transformers/content-generators.ts | 7 ++++--- scripts/news-types/month-ahead.ts | 5 ++++- tests/news-types/month-ahead.test.ts | 6 +++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/scripts/data-transformers/content-generators.ts b/scripts/data-transformers/content-generators.ts index 880eda5ef..f7bcaa9a3 100644 --- a/scripts/data-transformers/content-generators.ts +++ b/scripts/data-transformers/content-generators.ts @@ -1224,9 +1224,10 @@ export function generateMonthAheadContent(data: ArticleContentData, lang: Langua zh: (d, n) => `${n}项动议确定了活跃的政策领域:${d}。`, }; const domTpl = domainIntroTemplates[lang as string]; - const domainIntro = domTpl - ? domTpl(escapeHtml(domainList), motions.length) - : `${motions.length} motions identify active policy domains: ${escapeHtml(domainList)}.`; + const domainIntroRaw = domTpl + ? domTpl(domainList, motions.length) + : `${motions.length} motions identify active policy domains: ${domainList}.`; + const domainIntro = escapeHtml(domainIntroRaw); content += `

${domainIntro}

\n`; } diff --git a/scripts/news-types/month-ahead.ts b/scripts/news-types/month-ahead.ts index 4b0188555..f89deec6e 100644 --- a/scripts/news-types/month-ahead.ts +++ b/scripts/news-types/month-ahead.ts @@ -108,6 +108,9 @@ export async function generateMonthAhead(options: GenerationOptions = {}): Promi // Determine current riksmöte (Swedish parliamentary year: Sep 1 → Jun/Jul of next year). // Any date in September or later belongs to the new year's session (e.g. 2025-09-01 → "2025/26"). + // Note: month-ahead uses dynamic calculation unlike weekly-review/monthly-review which hardcode + // '2025/26'. This is intentional: month-ahead must remain accurate across parliamentary years + // without requiring a code change at each new session boundary. const year = today.getFullYear(); const month = today.getMonth() + 1; // 1-12 const currentRiksmote = month >= 9 @@ -342,7 +345,7 @@ export function validateMonthAhead(article: ArticleInput): MonthAheadValidationR hasForwardLookingTone, hasStrategicContext, hasLegislativePipeline, - passed: hasCalendarEvents && hasMinimumSources && hasForwardLookingTone && hasStrategicContext + passed: hasCalendarEvents && hasMinimumSources && hasForwardLookingTone && hasStrategicContext && hasLegislativePipeline }; } diff --git a/tests/news-types/month-ahead.test.ts b/tests/news-types/month-ahead.test.ts index 0b2df6154..52e0a6102 100644 --- a/tests/news-types/month-ahead.test.ts +++ b/tests/news-types/month-ahead.test.ts @@ -24,9 +24,9 @@ interface CalendarEvent { interface MockMCPClientShape { fetchCalendarEvents: Mock<(from: string, tom: string) => Promise>; searchDocuments: Mock<(params: Record) => Promise>; - fetchCommitteeReports: Mock<(limit: number, rm: string) => Promise>; - fetchPropositions: Mock<(limit: number, rm: string) => Promise>; - fetchMotions: Mock<(limit: number, rm: string) => Promise>; + fetchCommitteeReports: Mock<(limit: number, rm: string | null) => Promise>; + fetchPropositions: Mock<(limit: number, rm: string | null) => Promise>; + fetchMotions: Mock<(limit: number, rm: string | null) => Promise>; } /** Validation input */ From d14f2e0038e4f12e40a0a817618b93f1ce674338 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:20:15 +0000 Subject: [PATCH 3/8] fix: address second-round PR review comments on month-ahead - Broaden checkLegislativePipeline to include localized terms (SV, DE, FR, ES, FI, JA, KO, ZH, AR, HE) - Add month-ahead entry to REQUIRED_TOOLS_PER_TYPE in validate-cross-references.ts - Fall back to rec.committee when rec.organ is absent in committee grouping - Filter out both 'unknown' and 'other' from top-parties list - Add hasLegislativePipeline to test-local MonthAheadValidationResult interface Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- .../data-transformers/content-generators.ts | 5 +-- scripts/news-types/month-ahead.ts | 34 ++++++++++++++++--- scripts/validate-cross-references.ts | 6 ++++ tests/news-types/month-ahead.test.ts | 1 + 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/scripts/data-transformers/content-generators.ts b/scripts/data-transformers/content-generators.ts index db232dfb7..dde5d5a72 100644 --- a/scripts/data-transformers/content-generators.ts +++ b/scripts/data-transformers/content-generators.ts @@ -1230,7 +1230,8 @@ export function generateMonthAheadContent(data: ArticleContentData, lang: Langua // Group reports by committee const byCommittee: Record = {}; reports.forEach(r => { - const key = (r as Record).organ || 'unknown'; + const rec2 = r as Record; + const key = rec2.organ ?? rec2.committee ?? 'unknown'; if (!byCommittee[key]) byCommittee[key] = []; byCommittee[key].push(r); }); @@ -1309,7 +1310,7 @@ export function generateMonthAheadContent(data: ArticleContentData, lang: Langua // Top parties by motion volume const topParties = Object.entries(byPartyTrend) - .filter(([k]) => k !== 'unknown') + .filter(([k]) => k !== 'unknown' && k !== 'other') .sort((a, b) => b[1] - a[1]) .slice(0, 4); // 4 parties: covers the typical Swedish governing+major-opposition parties diff --git a/scripts/news-types/month-ahead.ts b/scripts/news-types/month-ahead.ts index f89deec6e..a6902c78a 100644 --- a/scripts/news-types/month-ahead.ts +++ b/scripts/news-types/month-ahead.ts @@ -379,8 +379,34 @@ function checkStrategicContext(article: ArticleInput): boolean { function checkLegislativePipeline(article: ArticleInput): boolean { if (!article || !article.content) return false; - const pipelineKeywords = ['pipeline', 'committee', 'proposition', 'motion', 'report', 'betank']; - return pipelineKeywords.some(keyword => - (article.content as string).toLowerCase().includes(keyword) - ); + // Language-aware keyword set covering English plus all 13 localized terms used in generated content + // (avoids false negatives for non-English articles; uses short stems to cover inflected forms). + const pipelineKeywords = [ + // English + 'pipeline', 'committee', 'proposition', 'motion', 'report', + // Swedish (with/without diacritics) + 'betänk', 'betank', 'utskott', + // Danish / Norwegian + 'komité', 'komite', 'kommitté', 'kommission', 'komisjon', + // German + 'ausschuss', 'vorlage', 'bericht', + // French + 'commission', 'rapport', + // Spanish + 'comité', 'comite', 'informe', 'dictamen', 'propuesta', + // Finnish + 'valiokunta', + // Japanese + '委員会', + // Korean + '위원회', + // Chinese + '委员会', + // Arabic + 'لجنة', + // Hebrew + 'ועדה', + ]; + const content = (article.content as string).toLowerCase(); + return pipelineKeywords.some(keyword => content.includes(keyword)); } diff --git a/scripts/validate-cross-references.ts b/scripts/validate-cross-references.ts index 0ebf61596..c45cc5868 100644 --- a/scripts/validate-cross-references.ts +++ b/scripts/validate-cross-references.ts @@ -44,6 +44,12 @@ export const REQUIRED_TOOLS_PER_TYPE: RequiredToolsMap = { 'analyze_g0v_by_department', 'search_anforanden', ], + 'month-ahead': [ + 'get_calendar_events', + 'get_betankanden', + 'get_propositioner', + 'get_motioner', + ], breaking: [ 'search_voteringar', 'get_voting_group', diff --git a/tests/news-types/month-ahead.test.ts b/tests/news-types/month-ahead.test.ts index 52e0a6102..457f75920 100644 --- a/tests/news-types/month-ahead.test.ts +++ b/tests/news-types/month-ahead.test.ts @@ -42,6 +42,7 @@ interface MonthAheadValidationResult { hasMinimumSources: boolean; hasForwardLookingTone: boolean; hasStrategicContext: boolean; + hasLegislativePipeline: boolean; passed: boolean; } From 2e46fe9e2b33804864bf0e21a7788091f7f85857 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:21:23 +0000 Subject: [PATCH 4/8] fix: address third-round PR review comments on month-ahead - Use console.warn (not console.error) for non-fatal pipeline fetch failures - Replace broad generic keywords with specific structural section markers in checkLegislativePipeline - Type fetchCalendarEvents result as RawCalendarEvent[] instead of RawDocument[] - Update validation test to use structural section heading as content marker Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/news-types/month-ahead.ts | 75 +++++++++++++++++++--------- tests/news-types/month-ahead.test.ts | 2 +- 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/scripts/news-types/month-ahead.ts b/scripts/news-types/month-ahead.ts index a6902c78a..4583b1325 100644 --- a/scripts/news-types/month-ahead.ts +++ b/scripts/news-types/month-ahead.ts @@ -32,7 +32,8 @@ import { generateMetadata, calculateReadTime, generateSources, - type RawDocument + type RawDocument, + type RawCalendarEvent } from '../data-transformers.js'; import { generateArticleHTML } from '../article-template.js'; import type { Language } from '../types/language.js'; @@ -118,7 +119,7 @@ export async function generateMonthAhead(options: GenerationOptions = {}): Promi : `${year - 1}/${String(year).slice(-2)}`; console.log(` 🔄 Fetching calendar events ${fromStr} → ${toStr}...`); - const events = await client.fetchCalendarEvents(fromStr, toStr) as RawDocument[]; + const events = await client.fetchCalendarEvents(fromStr, toStr) as RawCalendarEvent[]; mcpCalls.push({ tool: 'get_calendar_events', result: events }); console.log(` 📊 Found ${events.length} calendar events`); @@ -158,13 +159,13 @@ export async function generateMonthAhead(options: GenerationOptions = {}): Promi const [committeeReports, propositionDocs, motionDocs] = await Promise.all([ Promise.resolve() .then(() => client.fetchCommitteeReports(20, currentRiksmote) as Promise) - .catch((err: unknown) => { console.error('Failed to fetch committee reports:', err); return [] as unknown[]; }), + .catch((err: unknown) => { console.warn('Non-fatal: failed to fetch committee reports, continuing with empty list:', err); return [] as unknown[]; }), Promise.resolve() .then(() => client.fetchPropositions(15, currentRiksmote) as Promise) - .catch((err: unknown) => { console.error('Failed to fetch propositions:', err); return [] as unknown[]; }), + .catch((err: unknown) => { console.warn('Non-fatal: failed to fetch propositions, continuing with empty list:', err); return [] as unknown[]; }), Promise.resolve() .then(() => client.fetchMotions(50, currentRiksmote) as Promise) - .catch((err: unknown) => { console.error('Failed to fetch motions:', err); return [] as unknown[]; }), + .catch((err: unknown) => { console.warn('Non-fatal: failed to fetch motions, continuing with empty list:', err); return [] as unknown[]; }), ]); mcpCalls.push({ tool: 'get_betankanden', result: committeeReports }); @@ -379,34 +380,62 @@ function checkStrategicContext(article: ArticleInput): boolean { function checkLegislativePipeline(article: ArticleInput): boolean { if (!article || !article.content) return false; - // Language-aware keyword set covering English plus all 13 localized terms used in generated content - // (avoids false negatives for non-English articles; uses short stems to cover inflected forms). - const pipelineKeywords = [ + // Match on specific

section headings inserted by generateMonthAheadContent — + // these only appear when the pipeline sections were actually generated, avoiding + // false positives from generic words that can appear in ordinary calendar text. + const pipelineSectionMarkers = [ // English - 'pipeline', 'committee', 'proposition', 'motion', 'report', - // Swedish (with/without diacritics) - 'betänk', 'betank', 'utskott', - // Danish / Norwegian - 'komité', 'komite', 'kommitté', 'kommission', 'komisjon', + 'strategic legislative outlook', + 'committee pipeline', + 'policy trends', + // Swedish + 'strategisk lagstiftningsutsikt', + 'utskottspipeline', + 'politiska trender', // German - 'ausschuss', 'vorlage', 'bericht', + 'strategischer gesetzgebungsausblick', + 'ausschusspipeline', + 'politische trends', // French - 'commission', 'rapport', + 'perspectives législatives stratégiques', + 'pipeline des commissions', + 'tendances politiques', // Spanish - 'comité', 'comite', 'informe', 'dictamen', 'propuesta', + 'perspectiva legislativa estratégica', + 'proceso en comité', + 'tendencias políticas', + // Danish + 'strategisk lovgivningsmæssigt udsyn', + 'udvalgspipeline', + 'politiske tendenser', + // Norwegian + 'strategisk lovgivningsmessig utsikt', + 'komitépipeline', + 'politiske trender', // Finnish - 'valiokunta', + 'strateginen lainsäädäntönäkymä', + 'valiokuntaputkisto', + 'poliittiset trendit', + // Dutch + 'strategisch wetgevingsoverzicht', + 'commissiepijplijn', + 'politieke trends', // Japanese - '委員会', + '戦略的立法見通し', + '委員会パイプライン', // Korean - '위원회', + '전략적 입법 전망', + '위원회 파이프라인', // Chinese - '委员会', + '战略立法展望', + '委员会审议流程', // Arabic - 'لجنة', + 'التوقعات التشريعية الاستراتيجية', + 'مسار اللجان', // Hebrew - 'ועדה', + 'תחזית חקיקתית אסטרטגית', + 'צינור הוועדות', ]; const content = (article.content as string).toLowerCase(); - return pipelineKeywords.some(keyword => content.includes(keyword)); + return pipelineSectionMarkers.some(marker => content.includes(marker)); } diff --git a/tests/news-types/month-ahead.test.ts b/tests/news-types/month-ahead.test.ts index 457f75920..796e2b4eb 100644 --- a/tests/news-types/month-ahead.test.ts +++ b/tests/news-types/month-ahead.test.ts @@ -229,7 +229,7 @@ describe('Month-Ahead Article Generation', () => { it('should include hasLegislativePipeline in validation result', () => { const article: ArticleInput = { - content: 'Committee pipeline report proposition motion scheduled for next month.', + content: '

Strategic Legislative Outlook

Committee pipeline report proposition motion scheduled for next month.

', sources: ['source1', 'source2', 'source3'] }; From 0224ef1a0c163bca22c0d4faff1f579a85515202 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:59:26 +0000 Subject: [PATCH 5/8] fix: add missing Policy Trends h2 markers for JA/KO/ZH/AR/HE in checkLegislativePipeline Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/news-types/month-ahead.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/news-types/month-ahead.ts b/scripts/news-types/month-ahead.ts index 4583b1325..202e9bb04 100644 --- a/scripts/news-types/month-ahead.ts +++ b/scripts/news-types/month-ahead.ts @@ -423,18 +423,23 @@ function checkLegislativePipeline(article: ArticleInput): boolean { // Japanese '戦略的立法見通し', '委員会パイプライン', + '政策トレンド', // Korean '전략적 입법 전망', '위원회 파이프라인', + '정책 트렌드', // Chinese '战略立法展望', '委员会审议流程', + '政策趋势', // Arabic 'التوقعات التشريعية الاستراتيجية', 'مسار اللجان', + 'الاتجاهات السياسية', // Hebrew 'תחזית חקיקתית אסטרטגית', 'צינור הוועדות', + 'מגמות מדיניות', ]; const content = (article.content as string).toLowerCase(); return pipelineSectionMarkers.some(marker => content.includes(marker)); From a24e1a60535f852099768968a7b720825dd9499f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:26:11 +0000 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20align=20riksm=C3=B6te=20month=20comp?= =?UTF-8?q?arison=20to=200-based=20pattern=20matching=20motions.ts?= 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/month-ahead.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/news-types/month-ahead.ts b/scripts/news-types/month-ahead.ts index 202e9bb04..d04b21764 100644 --- a/scripts/news-types/month-ahead.ts +++ b/scripts/news-types/month-ahead.ts @@ -113,8 +113,8 @@ export async function generateMonthAhead(options: GenerationOptions = {}): Promi // '2025/26'. This is intentional: month-ahead must remain accurate across parliamentary years // without requiring a code change at each new session boundary. const year = today.getFullYear(); - const month = today.getMonth() + 1; // 1-12 - const currentRiksmote = month >= 9 + const month = today.getMonth(); // 0-11, align with motions.ts pattern + const currentRiksmote = month >= 8 ? `${year}/${String(year + 1).slice(-2)}` : `${year - 1}/${String(year).slice(-2)}`; From 258aa6cf61d9b476eefd118cf4b09bbff1d3d33d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:01:38 +0000 Subject: [PATCH 7/8] Fix early-return blocking pipeline fetches; use noun-style crossReferences.sources Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/news-types/month-ahead.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/scripts/news-types/month-ahead.ts b/scripts/news-types/month-ahead.ts index d04b21764..f2a00ca03 100644 --- a/scripts/news-types/month-ahead.ts +++ b/scripts/news-types/month-ahead.ts @@ -148,13 +148,12 @@ export async function generateMonthAhead(options: GenerationOptions = {}): Promi console.log(` 📊 Found ${documents.length} recent pipeline documents`); } - if (documents.length === 0) { - console.log(' ℹ️ No documents found, skipping'); - return { success: true, files: 0, mcpCalls }; - } } - // ── Fetch strategic legislative pipeline data ────────────────────────── + // ── Fetch strategic legislative pipeline data (always, before early-return) ─ + // These fetches run unconditionally so that betankanden/propositioner/motioner + // data can drive the Strategic Outlook / Pipeline / Trends sections even when + // calendar events and document fallbacks both return empty results. console.log(' 🔄 Fetching legislative pipeline (betankanden, propositioner, motioner)...'); const [committeeReports, propositionDocs, motionDocs] = await Promise.all([ Promise.resolve() @@ -177,6 +176,13 @@ export async function generateMonthAhead(options: GenerationOptions = {}): Promi `${propositionDocs.length} propositions, ${motionDocs.length} motions` ); + // Only skip generation when all data sources (calendar, docs, and pipeline) are empty + if (events.length === 0 && documents.length === 0 && + committeeReports.length === 0 && propositionDocs.length === 0 && motionDocs.length === 0) { + console.log(' ℹ️ No data from any source, skipping'); + return { success: true, files: 0, mcpCalls }; + } + const slug = `${formatDateForSlug(today)}-month-ahead`; const articles: GeneratedArticle[] = []; @@ -250,8 +256,8 @@ export async function generateMonthAhead(options: GenerationOptions = {}): Promi ? `${events.length} events over ${daysAhead} days` : `${documents.length} upcoming documents`, sources: events.length > 0 - ? ['calendar_events', 'get_betankanden', 'get_propositioner', 'get_motioner'] - : ['calendar_events', 'search_dokument', 'get_betankanden', 'get_propositioner', 'get_motioner'], + ? ['calendar_events', 'betankanden', 'propositioner', 'motioner'] + : ['calendar_events', 'search_dokument', 'betankanden', 'propositioner', 'motioner'], }, }; } catch (error: unknown) { From 041437751dbaf4297069ee772d968dfbc2b9cc66 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:14:23 +0000 Subject: [PATCH 8/8] =?UTF-8?q?Fix=20riksm=C3=B6te=20to=20use=20endDate=20?= =?UTF-8?q?for=20session=20boundary=20crossing;=20add=20pipeline-only=20te?= =?UTF-8?q?st?= 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/month-ahead.ts | 12 +++++++----- tests/news-types/month-ahead.test.ts | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/scripts/news-types/month-ahead.ts b/scripts/news-types/month-ahead.ts index f2a00ca03..3f16b48a8 100644 --- a/scripts/news-types/month-ahead.ts +++ b/scripts/news-types/month-ahead.ts @@ -112,11 +112,13 @@ export async function generateMonthAhead(options: GenerationOptions = {}): Promi // Note: month-ahead uses dynamic calculation unlike weekly-review/monthly-review which hardcode // '2025/26'. This is intentional: month-ahead must remain accurate across parliamentary years // without requiring a code change at each new session boundary. - const year = today.getFullYear(); - const month = today.getMonth(); // 0-11, align with motions.ts pattern - const currentRiksmote = month >= 8 - ? `${year}/${String(year + 1).slice(-2)}` - : `${year - 1}/${String(year).slice(-2)}`; + // Use endDate (not today) as the session reference so that a late-August run covering the + // Sep-1 boundary fetches the new session's pipeline data, not the outgoing session's. + const sessionRefYear = endDate.getFullYear(); + const sessionRefMonth = endDate.getMonth(); // 0-11, align with motions.ts pattern + const currentRiksmote = sessionRefMonth >= 8 + ? `${sessionRefYear}/${String(sessionRefYear + 1).slice(-2)}` + : `${sessionRefYear - 1}/${String(sessionRefYear).slice(-2)}`; console.log(` 🔄 Fetching calendar events ${fromStr} → ${toStr}...`); const events = await client.fetchCalendarEvents(fromStr, toStr) as RawCalendarEvent[]; diff --git a/tests/news-types/month-ahead.test.ts b/tests/news-types/month-ahead.test.ts index 796e2b4eb..6676a0914 100644 --- a/tests/news-types/month-ahead.test.ts +++ b/tests/news-types/month-ahead.test.ts @@ -188,6 +188,24 @@ describe('Month-Ahead Article Generation', () => { expect(result.success).toBe(true); expect(result.files).toBe(0); }); + + it('should generate article when calendar is empty but pipeline data is present', async () => { + mockClientInstance.fetchCalendarEvents.mockResolvedValue([]); + mockClientInstance.searchDocuments.mockResolvedValue([]); + mockClientInstance.fetchPropositions.mockResolvedValue([ + { titel: 'Proposition on climate policy', organ: 'MN', parti: 'MP' }, + { titel: 'Tax reform bill', organ: 'FiU', parti: 'M' } + ]); + + const result = await monthAheadModule.generateMonthAhead({ + languages: ['en'] + }); + + expect(result.success).toBe(true); + expect(result.files).toBeGreaterThan(0); + const enArticle = result.articles.find((a: GeneratedArticle) => a.lang === 'en'); + expect(enArticle!.html).toContain('Strategic Legislative Outlook'); + }); }); describe('Article Structure', () => {