diff --git a/scripts/data-transformers/content-generators.ts b/scripts/data-transformers/content-generators.ts index cc5357c1..b2e8b4ac 100644 --- a/scripts/data-transformers/content-generators.ts +++ b/scripts/data-transformers/content-generators.ts @@ -1358,3 +1358,210 @@ 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 rec2 = r as Record; + const key = rec2.organ ?? rec2.committee ?? '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 domainIntroRaw = domTpl + ? domTpl(domainList, motions.length) + : `${motions.length} motions identify active policy domains: ${domainList}.`; + const domainIntro = escapeHtml(domainIntroRaw); + content += `

${domainIntro}

\n`; + } + + // Top parties by motion volume + const topParties = Object.entries(byPartyTrend) + .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 + + 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 dcba80e8..98e010e3 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 5e5bb25c..3f16b48a 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'; @@ -47,6 +48,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 +63,7 @@ export interface MonthAheadValidationResult { hasMinimumSources: boolean; hasForwardLookingTone: boolean; hasStrategicContext: boolean; + hasLegislativePipeline: boolean; passed: boolean; } @@ -102,8 +107,21 @@ 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"). + // 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. + // 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 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`); @@ -132,10 +150,39 @@ 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 (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() + .then(() => client.fetchCommitteeReports(20, currentRiksmote) as Promise) + .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.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.warn('Non-fatal: failed to fetch motions, continuing with empty list:', 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` + ); + + // 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`; @@ -145,16 +192,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 +257,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', 'betankanden', 'propositioner', 'motioner'] + : ['calendar_events', 'search_dokument', 'betankanden', 'propositioner', 'motioner'], }, }; } catch (error: unknown) { @@ -286,13 +346,15 @@ 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, - passed: hasCalendarEvents && hasMinimumSources && hasForwardLookingTone && hasStrategicContext + hasLegislativePipeline, + passed: hasCalendarEvents && hasMinimumSources && hasForwardLookingTone && hasStrategicContext && hasLegislativePipeline }; } @@ -323,3 +385,70 @@ function checkStrategicContext(article: ArticleInput): boolean { (article.content as string).toLowerCase().includes(keyword) ); } + +function checkLegislativePipeline(article: ArticleInput): boolean { + if (!article || !article.content) return false; + // 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 + 'strategic legislative outlook', + 'committee pipeline', + 'policy trends', + // Swedish + 'strategisk lagstiftningsutsikt', + 'utskottspipeline', + 'politiska trender', + // German + 'strategischer gesetzgebungsausblick', + 'ausschusspipeline', + 'politische trends', + // French + 'perspectives législatives stratégiques', + 'pipeline des commissions', + 'tendances politiques', + // Spanish + '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 + '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 pipelineSectionMarkers.some(marker => content.includes(marker)); +} diff --git a/scripts/validate-cross-references.ts b/scripts/validate-cross-references.ts index 0ebf6159..c45cc586 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 6c2f95d5..6676a091 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 | null) => Promise>; + fetchPropositions: Mock<(limit: number, rm: string | null) => Promise>; + fetchMotions: Mock<(limit: number, rm: string | null) => Promise>; } /** Validation input */ @@ -39,6 +42,7 @@ interface MonthAheadValidationResult { hasMinimumSources: boolean; hasForwardLookingTone: boolean; hasStrategicContext: boolean; + hasLegislativePipeline: boolean; passed: boolean; } @@ -73,6 +77,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 +104,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 +127,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 +151,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([]); @@ -139,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', () => { @@ -177,6 +244,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: '

Strategic Legislative Outlook

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', () => {