diff --git a/scripts/data-transformers/content-generators.ts b/scripts/data-transformers/content-generators.ts index 4b376685..cc5357c1 100644 --- a/scripts/data-transformers/content-generators.ts +++ b/scripts/data-transformers/content-generators.ts @@ -558,6 +558,114 @@ export function generateCommitteeContent(data: ArticleContentData, lang: Languag content += ` \n \n`; + // ── Optional: Voting Results section ───────────────────────────────────── + const votes = (data.votes ?? []) as unknown[]; + if (votes.length > 0) { + const votingSectionHeaders: Record = { + sv: 'Röstningsresultat', da: 'Afstemningsresultater', no: 'Voteringsresultater', + fi: 'Äänestystulokset', de: 'Abstimmungsergebnisse', fr: 'Résultats du vote', + es: 'Resultados de la votación', nl: 'Stemresultaten', ar: 'نتائج التصويت', + he: 'תוצאות ההצבעה', ja: '投票結果', ko: '투표 결과', zh: '投票结果', + }; + const votingCountTemplates: Record string> = { + sv: (n) => `${n} röstningsprotokoll visar hur partierna röstade i utskottsbeslut denna period.`, + da: (n) => `${n} afstemningsprotokoller viser, hvordan partierne stemte om udvalgets beslutninger.`, + no: (n) => `${n} voteringsprotokoll viser hvordan partiene stemte i komitévedtak.`, + fi: (n) => `${n} äänestysrekisteriä osoittaa, miten puolueet äänestivät valiokunnan päätöksistä.`, + de: (n) => `${n} Abstimmungsrekorde zeigen, wie die Parteien über Ausschussbeschlüsse abstimmten.`, + fr: (n) => `${n} procès-verbaux de vote montrent comment les partis ont voté sur les décisions de commission.`, + es: (n) => `${n} registros de votación muestran cómo votaron los partidos en las decisiones de la comisión.`, + nl: (n) => `${n} stemregisters tonen hoe partijen stemden over commissiebeslissingen.`, + ar: (n) => `${n} سجلات التصويت تظهر كيف صوتت الأحزاب على قرارات اللجنة.`, + he: (n) => `${n} פרוטוקולי הצבעה מציגים כיצד הצביעו המפלגות על החלטות הוועדה.`, + ja: (n) => `${n}件の投票記録が、委員会決定に対する各党の投票方法を示しています。`, + ko: (n) => `${n}건의 투표 기록이 위원회 결정에 대한 각 정당의 투표 방식을 보여줍니다.`, + zh: (n) => `${n}条投票记录显示各党派对委员会决定的投票情况。`, + }; + const votingHeader = votingSectionHeaders[lang as string] ?? 'Voting Results'; + const votingCountFn = votingCountTemplates[lang as string]; + const votingCountText = votingCountFn + ? votingCountFn(votes.length) + : `${votes.length} voting records show how parties voted on committee decisions this period.`; + content += `\n

${escapeHtml(votingHeader)}

\n`; + content += `

${escapeHtml(votingCountText)}

\n`; + } + + // ── Optional: Committee Debate section ─────────────────────────────────── + const speeches = (data.speeches ?? []) as unknown[]; + if (speeches.length > 0) { + const debateSectionHeaders: Record = { + sv: 'Utskottsdebatt', da: 'Udvalgets debat', no: 'Komitédebatt', + fi: 'Valiokunnan keskustelu', de: 'Ausschussdebatte', fr: 'Débat en commission', + es: 'Debate en comisión', nl: 'Commissiedebat', ar: 'نقاش اللجنة', + he: 'דיון בוועדה', ja: '委員会討論', ko: '위원회 토론', zh: '委员会讨论', + }; + const debateCountTemplates: Record string> = { + sv: (n) => `${n} anföranden i kammaren belyser de viktigaste argumenten och partipositionerna i dessa frågor.`, + da: (n) => `${n} parlamentariske taler belyser nøgleargumenter og partipositioner.`, + no: (n) => `${n} parlamentariske innlegg belyser nøkkelargumenter og partiposisjoner.`, + fi: (n) => `${n} parlamentaarista puheenvuoroa valaisee keskeisiä argumentteja ja puolueiden kantoja.`, + de: (n) => `${n} parlamentarische Reden beleuchten Hauptargumente und Parteipositionen.`, + fr: (n) => `${n} discours parlementaires éclairent les arguments clés et les positions des partis.`, + es: (n) => `${n} discursos parlamentarios iluminan los principales argumentos y posiciones de los partidos.`, + nl: (n) => `${n} parlementaire toespraken belichten de belangrijkste argumenten en partijposities.`, + ar: (n) => `${n} خطاب برلماني يسلط الضوء على الحجج الرئيسية ومواقف الأحزاب.`, + he: (n) => `${n} נאומים פרלמנטריים מאירים טיעונים מרכזיים ועמדות מפלגות.`, + ja: (n) => `${n}件の議会演説が主要な論点と各党の立場を明らかにしています。`, + ko: (n) => `${n}건의 의회 연설이 주요 논점과 각 정당의 입장을 보여줍니다.`, + zh: (n) => `${n}篇议会演讲揭示了主要论点和各党派立场。`, + }; + const debateHeader = debateSectionHeaders[lang as string] ?? 'Committee Debate'; + const debateCountFn = debateCountTemplates[lang as string]; + const debateCountText = debateCountFn + ? debateCountFn(speeches.length) + : `${speeches.length} parliamentary speeches highlight key arguments and party positions on these issues.`; + content += `\n

${escapeHtml(debateHeader)}

\n`; + content += `

${escapeHtml(debateCountText)}

\n`; + } + + // ── Optional: Government Bill Linkage section ───────────────────────────── + const propositions = (data.propositions ?? []) as RawDocument[]; + if (propositions.length > 0) { + const billSectionHeaders: Record = { + sv: 'Koppling till regeringspropositioner', da: 'Tilknytning til regeringsforslag', + no: 'Tilknytning til regjeringsproposisjoner', fi: 'Yhteys hallituksen esityksiin', + de: 'Verknüpfung mit Regierungsvorlagen', fr: 'Lien avec les projets de loi gouvernementaux', + es: 'Vinculación con proyectos de ley gubernamentales', nl: 'Koppeling aan regeringsvoorstellen', + ar: 'الصلة بمشاريع قوانين الحكومة', he: 'קישור להצעות חוק ממשלתיות', + ja: '政府法案との連携', ko: '정부 법안과의 연계', zh: '与政府法案的关联', + }; + const billCountTemplates: Record string> = { + sv: (n) => `${n} regeringspropositioner är kopplade till dessa betänkanden och visar lagstiftningskedjan.`, + da: (n) => `${n} regeringsforslag er knyttet til disse betænkninger og viser den lovgivningsmæssige kæde.`, + no: (n) => `${n} regjeringsproposisjoner er knyttet til disse innstillingene og viser den legislative kjeden.`, + fi: (n) => `${n} hallituksen esitystä liittyy näihin mietintöihin ja osoittaa lainsäädäntöketjun.`, + de: (n) => `${n} Regierungsvorlagen sind mit diesen Berichten verknüpft und zeigen die Gesetzgebungskette.`, + fr: (n) => `${n} projets de loi gouvernementaux sont liés à ces rapports, montrant la chaîne législative.`, + es: (n) => `${n} proyectos de ley gubernamentales están vinculados a estos informes, mostrando la cadena legislativa.`, + nl: (n) => `${n} regeringsvoorstellen zijn gekoppeld aan deze rapporten en tonen de wetgevingsketen.`, + ar: (n) => `${n} مشاريع قوانين حكومية مرتبطة بهذه التقارير، مما يُظهر السلسلة التشريعية.`, + he: (n) => `${n} הצעות חוק ממשלתיות קשורות לדוחות אלה, ומציגות את השרשרת החקיקתית.`, + ja: (n) => `${n}件の政府法案がこれらの報告書に関連しており、立法プロセスの連鎖を示しています。`, + ko: (n) => `${n}건의 정부 법안이 이 보고서들과 연계되어 입법 과정의 연결고리를 보여줍니다.`, + zh: (n) => `${n}项政府法案与这些报告相关,展示了立法链条。`, + }; + const billHeader = billSectionHeaders[lang as string] ?? 'Government Bill Linkage'; + const billCountFn = billCountTemplates[lang as string]; + const billCountText = billCountFn + ? billCountFn(propositions.length) + : `${propositions.length} government propositions are linked to these reports, tracing the full legislative chain.`; + content += `\n

${escapeHtml(billHeader)}

\n`; + content += `

${escapeHtml(billCountText)}

\n`; + propositions.slice(0, 3).forEach(prop => { // display up to 3 linked propositions + if (typeof prop !== 'object' || prop === null) return; + const propTitle = escapeHtml((prop as RawDocument).titel || (prop as RawDocument).title || (prop as RawDocument).dokumentnamn || ''); + if (propTitle) { + content += `

→ ${propTitle}

\n`; + } + }); + } + return content; } diff --git a/scripts/data-transformers/metadata.ts b/scripts/data-transformers/metadata.ts index 655d23b0..e23d07d9 100644 --- a/scripts/data-transformers/metadata.ts +++ b/scripts/data-transformers/metadata.ts @@ -340,6 +340,12 @@ export function generateSources(tools: string[] = []): string[] { if (tools.includes('get_dokument_innehall')) { sources.push('Riksdagen Document Content'); } + if (tools.includes('search_voteringar')) { + sources.push('Riksdagen Voting Records'); + } + if (tools.includes('search_anforanden')) { + sources.push('Riksdagen Speeches'); + } return sources; } diff --git a/scripts/data-transformers/types.ts b/scripts/data-transformers/types.ts index 67971a7b..d9cddff4 100644 --- a/scripts/data-transformers/types.ts +++ b/scripts/data-transformers/types.ts @@ -93,6 +93,10 @@ export interface ArticleContentData { context?: string; /** CIA intelligence context for enriched analysis */ ciaContext?: CIAContext; + /** Voting records for cross-referencing committee decisions */ + votes?: unknown[]; + /** Parliamentary speeches for committee debate context */ + speeches?: unknown[]; /** Government department analysis from analyze_g0v_by_department */ govDeptData?: Record[]; /** Full-text search results for policy substance extraction */ diff --git a/scripts/news-types/committee-reports.ts b/scripts/news-types/committee-reports.ts index cb6516b7..e4b6c93d 100644 --- a/scripts/news-types/committee-reports.ts +++ b/scripts/news-types/committee-reports.ts @@ -183,17 +183,12 @@ import type { ArticleCategory, GeneratedArticle, GenerationResult, MCPCallRecord /** * Required MCP tools for committee-reports articles - * - * REQUIRED_TOOLS UPDATE (2026-02-14): - * Initially set to 4 tools ['get_betankanden', 'search_voteringar', 'search_anforanden', 'get_propositioner'] - * to match tests/validation expectations. However, this caused runtime validation failures - * since the implementation only calls get_betankanden (line 66). - * - * Reverted to actual implementation (1 tool) to prevent validation failures. - * When additional tools are implemented in generateCommitteeReports(), add them back here. */ export const REQUIRED_TOOLS: readonly string[] = [ - 'get_betankanden' + 'get_betankanden', + 'search_voteringar', + 'search_anforanden', + 'get_propositioner' ]; export interface TitleSet { @@ -255,8 +250,37 @@ export async function generateCommitteeReports(options: GenerationOptions = {}): return { success: true, files: 0, mcpCalls }; } - // Cross-reference with votes and debates (optional enhancement) - // Future: Add voteringar, anforanden, propositioner queries here + // Step 2: Enrich with voting patterns, speeches, and propositions (non-fatal) + console.log(' 🔄 Fetching voting patterns, speeches, and propositions...'); + // Derive riksmöte from the first report's rm field, or calculate from current date. + // Parliamentary year starts in September; e.g. Sep 2025–Aug 2026 → '2025/26'. + const firstReportRaw = (reports[0] as Record)?.['rm']; + const rmPattern = /^\d{4}\/\d{2}$/; + const firstReportRm: string | undefined = + typeof firstReportRaw === 'string' && rmPattern.test(firstReportRaw) ? firstReportRaw : undefined; + const now = new Date(); + const startYear = now.getMonth() >= 8 ? now.getFullYear() : now.getFullYear() - 1; + const currentRm = firstReportRm ?? `${startYear}/${String(startYear + 1).slice(-2)}`; + const [votes, speeches, propositions] = await Promise.all([ + Promise.resolve() + .then(() => client.fetchVotingRecords({ rm: currentRm, limit: 20 }) as Promise) + .catch((err: unknown) => { console.error(' ⚠️ Failed to fetch voting records:', (err as Error)?.message ?? String(err)); return [] as unknown[]; }), + Promise.resolve() + .then(() => client.searchSpeeches({ rm: currentRm, limit: 15 }) as Promise) + .catch((err: unknown) => { console.error(' ⚠️ Failed to fetch speeches:', (err as Error)?.message ?? String(err)); return [] as unknown[]; }), + Promise.resolve() + .then(() => client.fetchPropositions(20, currentRm) as Promise) + .catch((err: unknown) => { console.error(' ⚠️ Failed to fetch propositions:', (err as Error)?.message ?? String(err)); return [] as unknown[]; }), + ]); + mcpCalls.push({ tool: 'search_voteringar', result: votes }); + mcpCalls.push({ tool: 'search_anforanden', result: speeches }); + mcpCalls.push({ tool: 'get_propositioner', result: propositions }); + console.log(` 🗳 Found ${votes.length} voting records, ${speeches.length} speeches, ${(propositions as unknown[]).length} propositions`); + + // Normalize propositions: keep only plain objects to avoid runtime errors in content generator + const safePropositions = propositions.filter( + (p): p is RawDocument => typeof p === 'object' && p !== null + ); const today = new Date(); const slug = `${formatDateForSlug(today)}-committee-reports`; @@ -265,11 +289,15 @@ export async function generateCommitteeReports(options: GenerationOptions = {}): for (const lang of languages) { console.log(` 🌐 Generating ${lang.toUpperCase()} version...`); - const content: string = generateArticleContent({ reports }, 'committee-reports', lang); + const content: string = generateArticleContent( + { reports, votes, speeches, propositions: safePropositions }, + 'committee-reports', + lang + ); const watchPoints = extractWatchPoints({ reports }, lang); const metadata = generateMetadata({ reports }, 'committee-reports', lang); const readTime: string = calculateReadTime(content); - const sources: string[] = generateSources(['get_betankanden']); + const sources: string[] = generateSources(['get_betankanden', 'search_voteringar', 'search_anforanden', 'get_propositioner']); const titles: TitleSet = getTitles(lang, reports.length, reports); @@ -310,7 +338,7 @@ export async function generateCommitteeReports(options: GenerationOptions = {}): mcpCalls, crossReferences: { event: `${reports.length} reports`, - sources: ['betankanden'] + sources: ['betankanden', 'voteringar', 'anforanden', 'propositioner'] } }; diff --git a/tests/data-transformers.test.ts b/tests/data-transformers.test.ts index 2ab29ac9..4657c385 100644 --- a/tests/data-transformers.test.ts +++ b/tests/data-transformers.test.ts @@ -79,6 +79,8 @@ interface MockArticlePayload { dok_id?: string; organ?: string; }>; + votes?: unknown[]; + speeches?: unknown[]; } describe('Data Transformers', () => { @@ -378,6 +380,79 @@ describe('Data Transformers', () => { expect(content).toContain('No committee reports'); }); + it('should render Voting Results section when votes are provided', () => { + const content = generateArticleContent( + { reports: [{ titel: 'Test Report', url: '#', organ: 'FiU' }], votes: [{}] } as MockArticlePayload, + 'committee-reports', + 'en' + ) as string; + expect(content).toContain('Voting Results'); + }); + + it('should omit Voting Results section when votes array is empty', () => { + const content = generateArticleContent( + { reports: [{ titel: 'Test Report', url: '#', organ: 'FiU' }], votes: [] } as MockArticlePayload, + 'committee-reports', + 'en' + ) as string; + expect(content).not.toContain('Voting Results'); + }); + + it('should render Committee Debate section when speeches are provided', () => { + const content = generateArticleContent( + { reports: [{ titel: 'Test Report', url: '#', organ: 'FiU' }], speeches: [{}] } as MockArticlePayload, + 'committee-reports', + 'en' + ) as string; + expect(content).toContain('Committee Debate'); + }); + + it('should omit Committee Debate section when speeches array is empty', () => { + const content = generateArticleContent( + { reports: [{ titel: 'Test Report', url: '#', organ: 'FiU' }], speeches: [] } as MockArticlePayload, + 'committee-reports', + 'en' + ) as string; + expect(content).not.toContain('Committee Debate'); + }); + + it('should render Government Bill Linkage section when propositions are provided', () => { + const content = generateArticleContent( + { reports: [{ titel: 'Test Report', url: '#', organ: 'FiU' }], propositions: [{ titel: 'Prop 2025/26:1' }] } as MockArticlePayload, + 'committee-reports', + 'en' + ) as string; + expect(content).toContain('Government Bill Linkage'); + expect(content).toContain('Prop 2025/26:1'); + }); + + it('should omit Government Bill Linkage section when propositions array is empty', () => { + const content = generateArticleContent( + { reports: [{ titel: 'Test Report', url: '#', organ: 'FiU' }], propositions: [] } as MockArticlePayload, + 'committee-reports', + 'en' + ) as string; + expect(content).not.toContain('Government Bill Linkage'); + }); + + it('should render Swedish Röstningsresultat heading for sv language when votes provided', () => { + const content = generateArticleContent( + { reports: [{ titel: 'Test', url: '#', organ: 'FiU' }], votes: [{}] } as MockArticlePayload, + 'committee-reports', + 'sv' + ) as string; + expect(content).toContain('Röstningsresultat'); + }); + + it('should render Swedish Utskottsdebatt heading for sv language when speeches provided', () => { + const content = generateArticleContent( + { reports: [{ titel: 'Test', url: '#', organ: 'FiU' }], speeches: [{}] } as MockArticlePayload, + 'committee-reports', + 'sv' + ) as string; + expect(content).toContain('Utskottsdebatt'); + }); + it('should handle propositions with data', () => { const content = generateArticleContent({ propositions: [{ titel: 'Test Prop', url: '#', dokumentnamn: 'Prop 2025/26:1' }] diff --git a/tests/news-types/committee-reports.test.ts b/tests/news-types/committee-reports.test.ts index cf26472f..db95b6a1 100644 --- a/tests/news-types/committee-reports.test.ts +++ b/tests/news-types/committee-reports.test.ts @@ -25,6 +25,9 @@ interface CommitteeReport { /** Mock MCP client shape */ interface MockMCPClientShape { fetchCommitteeReports: Mock<(limit: number) => Promise>; + fetchVotingRecords: Mock<(filters: object) => Promise>; + searchSpeeches: Mock<(params: object) => Promise>; + fetchPropositions: Mock<(limit?: number, rm?: string | null) => Promise>; } /** Validation input */ @@ -75,7 +78,10 @@ const { mockClientInstance, mockCommitteeReports, MockMCPClient } = vi.hoisted(( ]; const mockClientInstance: MockMCPClientShape = { - fetchCommitteeReports: vi.fn().mockResolvedValue(mockCommitteeReports) as MockMCPClientShape['fetchCommitteeReports'] + fetchCommitteeReports: vi.fn().mockResolvedValue(mockCommitteeReports) as MockMCPClientShape['fetchCommitteeReports'], + fetchVotingRecords: vi.fn().mockResolvedValue([]) as MockMCPClientShape['fetchVotingRecords'], + searchSpeeches: vi.fn().mockResolvedValue([]) as MockMCPClientShape['searchSpeeches'], + fetchPropositions: vi.fn().mockResolvedValue([]) as MockMCPClientShape['fetchPropositions'], }; function MockMCPClient(): MockMCPClientShape { @@ -99,6 +105,9 @@ describe('Committee Reports Article Generation', () => { beforeEach(() => { vi.clearAllMocks(); mockClientInstance.fetchCommitteeReports.mockResolvedValue(mockCommitteeReports); + mockClientInstance.fetchVotingRecords.mockResolvedValue([]); + mockClientInstance.searchSpeeches.mockResolvedValue([]); + mockClientInstance.fetchPropositions.mockResolvedValue([]); }); afterEach(() => { @@ -116,11 +125,13 @@ describe('Committee Reports Article Generation', () => { expect(committeeReportsModule.REQUIRED_TOOLS).toContain('get_betankanden'); }); - it('should only list tools actually used by generateCommitteeReports', () => { - // REQUIRED_TOOLS must match tools actually called in the implementation. - // Future cross-referencing tools (voteringar, anforanden, propositioner) - // should be added here when implemented in generateCommitteeReports(). - expect(committeeReportsModule.REQUIRED_TOOLS).toEqual(['get_betankanden']); + it('should require all four MCP tools', () => { + expect(committeeReportsModule.REQUIRED_TOOLS).toEqual([ + 'get_betankanden', + 'search_voteringar', + 'search_anforanden', + 'get_propositioner' + ]); }); }); @@ -134,6 +145,37 @@ describe('Committee Reports Article Generation', () => { expect(result.mcpCalls!.some((call: MCPCallRecord) => call.tool === 'get_betankanden')).toBe(true); }); + it('should call search_voteringar for voting patterns', async () => { + const result = await committeeReportsModule.generateCommitteeReports({ + languages: ['en'] + }); + + expect(mockClientInstance.fetchVotingRecords).toHaveBeenCalledWith( + expect.objectContaining({ rm: '2024/25', limit: 20 }) + ); + expect(result.mcpCalls!.some((call: MCPCallRecord) => call.tool === 'search_voteringar')).toBe(true); + }); + + it('should call search_anforanden for debate context', async () => { + const result = await committeeReportsModule.generateCommitteeReports({ + languages: ['en'] + }); + + expect(mockClientInstance.searchSpeeches).toHaveBeenCalledWith( + expect.objectContaining({ rm: '2024/25', limit: 15 }) + ); + expect(result.mcpCalls!.some((call: MCPCallRecord) => call.tool === 'search_anforanden')).toBe(true); + }); + + it('should call get_propositioner for bill linkage', async () => { + const result = await committeeReportsModule.generateCommitteeReports({ + languages: ['en'] + }); + + expect(mockClientInstance.fetchPropositions).toHaveBeenCalledWith(20, '2024/25'); + expect(result.mcpCalls!.some((call: MCPCallRecord) => call.tool === 'get_propositioner')).toBe(true); + }); + it('should fetch specified number of reports', async () => { await committeeReportsModule.generateCommitteeReports({ languages: ['en'], @@ -250,6 +292,17 @@ describe('Committee Reports Article Generation', () => { expect(result.crossReferences.sources).toContain('betankanden'); }); + + it('should track all four data sources', async () => { + const result = await committeeReportsModule.generateCommitteeReports({ + languages: ['en'] + }); + + expect(result.crossReferences.sources).toContain('betankanden'); + expect(result.crossReferences.sources).toContain('voteringar'); + expect(result.crossReferences.sources).toContain('anforanden'); + expect(result.crossReferences.sources).toContain('propositioner'); + }); }); describe('Validation Functions', () => {