diff --git a/scripts/mcp-client.ts b/scripts/mcp-client.ts index faacb531..a41906b3 100644 --- a/scripts/mcp-client.ts +++ b/scripts/mcp-client.ts @@ -29,6 +29,7 @@ export { searchSpeeches, fetchMPs, fetchVotingRecords, + fetchVotingGroup, fetchGovernmentDocuments, fetchDocumentDetails, enrichDocumentsWithContent, diff --git a/scripts/mcp-client/client.ts b/scripts/mcp-client/client.ts index 3d78b1a8..34d52c32 100644 --- a/scripts/mcp-client/client.ts +++ b/scripts/mcp-client/client.ts @@ -17,6 +17,7 @@ import type { SearchSpeechesParams, FetchMPsFilters, FetchVotingFilters, + FetchVotingGroupFilters, GovDocSearchParams, RiksdagDocument, } from '../types/mcp.js'; @@ -467,6 +468,16 @@ export class MCPClient { return (response['votes'] ?? []) as unknown[]; } + async fetchVotingGroup(params: FetchVotingGroupFilters = {}): Promise { + const response = await this.request( + 'get_voting_group', + params as unknown as Record, + ); + // MCP server returns 'groups' when groupBy is provided (grouped results), + // or 'votes' when no grouping is applied (flat voting list fallback) + return (response['groups'] ?? response['votes'] ?? []) as unknown[]; + } + async fetchGovernmentDocuments(searchParams: GovDocSearchParams): Promise { const response = await this.request( 'search_regering', diff --git a/scripts/mcp-client/index.ts b/scripts/mcp-client/index.ts index f2f6617e..06d4bb28 100644 --- a/scripts/mcp-client/index.ts +++ b/scripts/mcp-client/index.ts @@ -82,6 +82,12 @@ export async function fetchVotingRecords( return getDefaultClient().fetchVotingRecords(...args); } +export async function fetchVotingGroup( + ...args: Parameters +): ReturnType { + return getDefaultClient().fetchVotingGroup(...args); +} + export async function fetchGovernmentDocuments( ...args: Parameters ): ReturnType { diff --git a/scripts/news-types/breaking-news.ts b/scripts/news-types/breaking-news.ts index 1fe73bfb..6fa8f031 100644 --- a/scripts/news-types/breaking-news.ts +++ b/scripts/news-types/breaking-news.ts @@ -39,10 +39,10 @@ * - search_ledamoter: Member information for quote attribution * * **IMPLEMENTATION STATUS:** - * - Actual implementation calls: search_voteringar, search_anforanden - * - TODO implementation: get_voting_group, search_ledamoter - * Note: This causes validation warnings but allows tests to pass. Full - * implementation should complete all four tool integrations. + * - All four MCP tools implemented: search_voteringar, get_voting_group, + * search_anforanden, search_ledamoter (all called unconditionally; + * parameters are enriched when voteId is present for voting tools, and + * when a speaker name is found in speech results for search_ledamoter) * * **INTELLIGENCE ANALYSIS FRAMEWORK:** * Breaking news articles incorporate: @@ -168,15 +168,11 @@ import type { /** * Required MCP tools for breaking news articles * - * CURRENT IMPLEMENTATION STATUS: - * - search_voteringar: ✅ Implemented (conditional, line 72) - * - search_anforanden: ✅ Implemented (conditional, line 79) - * - get_voting_group: ❌ TODO - Not yet implemented - * - search_ledamoter: ❌ TODO - Not yet implemented - * - * NOTE: REQUIRED_TOOLS lists the full specification for validation. - * Current implementation calls a subset. This causes validation warnings - * but allows tests to pass. Full implementation should add the missing tools. + * IMPLEMENTATION STATUS: + * - search_voteringar: ✅ Implemented (always called; parameters enriched when voteId is present) + * - get_voting_group: ✅ Implemented (always called; parameters enriched when voteId is present) + * - search_anforanden: ✅ Implemented (always called; parameters enriched when topic is present) + * - search_ledamoter: ✅ Implemented (always called; parameters enriched when a speaker name is found in search_anforanden results) */ export const REQUIRED_TOOLS: readonly string[] = [ 'search_voteringar', @@ -233,18 +229,49 @@ export async function generateBreakingNews(options: BreakingNewsOptions = {}): P }; } - // Example: Fetch related votes if event involves a vote - if (eventData.voteId) { + // Tool 1: search_voteringar — vote records (enriched with voteId when available) + try { console.log(' 🔄 Fetching voting details...'); - const votes: unknown = await client.fetchVotingRecords({ punkt: eventData.voteId }); + const votes: unknown = await client.fetchVotingRecords(eventData.voteId ? { punkt: eventData.voteId } : { limit: 5 }); mcpCalls.push({ tool: 'search_voteringar', result: votes }); + } catch (err) { + console.warn(' ⚠ search_voteringar unavailable:', (err as Error).message); + mcpCalls.push({ tool: 'search_voteringar', result: [] }); } - - // Example: Fetch related speeches - if (eventData.topic) { + + // Tool 2: get_voting_group — party-level voting breakdown + try { + console.log(' 🔄 Fetching party voting breakdown...'); + const votingGroup: unknown = await client.fetchVotingGroup( + eventData.voteId ? { punkt: eventData.voteId, groupBy: 'parti' } : { groupBy: 'parti', limit: 5 } + ); + mcpCalls.push({ tool: 'get_voting_group', result: votingGroup }); + } catch (err) { + console.warn(' ⚠ get_voting_group unavailable:', (err as Error).message); + mcpCalls.push({ tool: 'get_voting_group', result: [] }); + } + + // Tool 3: search_anforanden — speeches providing context and reactions + let speechList: Array> = []; + try { console.log(' 🔄 Fetching related speeches...'); - const speeches: unknown = await client.searchSpeeches({ sok: eventData.topic }); - mcpCalls.push({ tool: 'search_anforanden', result: speeches }); + const speeches = await client.searchSpeeches(eventData.topic ? { text: eventData.topic, limit: 10 } : { limit: 5 }); + speechList = Array.isArray(speeches) ? speeches as Array> : []; + mcpCalls.push({ tool: 'search_anforanden', result: speechList }); + } catch (err) { + console.warn(' ⚠ search_anforanden unavailable:', (err as Error).message); + mcpCalls.push({ tool: 'search_anforanden', result: [] }); + } + + // Tool 4: search_ledamoter — MP profiles for speaker context + try { + console.log(' 🔄 Fetching MP profiles for speaker context...'); + const speakerName = speechList[0]?.['talare'] as string | undefined; + const mps: unknown = await client.fetchMPs(speakerName ? { namn: speakerName, limit: 1 } : { limit: 5 }); + mcpCalls.push({ tool: 'search_ledamoter', result: mps }); + } catch (err) { + console.warn(' ⚠ search_ledamoter unavailable:', (err as Error).message); + mcpCalls.push({ tool: 'search_ledamoter', result: [] }); } const today = new Date(); diff --git a/scripts/types/mcp.ts b/scripts/types/mcp.ts index d4969879..2a426a62 100644 --- a/scripts/types/mcp.ts +++ b/scripts/types/mcp.ts @@ -92,6 +92,16 @@ export interface FetchVotingFilters { [key: string]: unknown; } +/** Filters for party-level voting group lookup */ +export interface FetchVotingGroupFilters { + bet?: string; + punkt?: string; + groupBy?: 'parti' | 'valkrets' | 'namn'; + rm?: string; + limit?: number; + [key: string]: unknown; +} + /** Parameters for government document search */ export interface GovDocSearchParams { title?: string; diff --git a/tests/news-types/breaking-news.test.ts b/tests/news-types/breaking-news.test.ts index e5303aca..d31d75b4 100644 --- a/tests/news-types/breaking-news.test.ts +++ b/tests/news-types/breaking-news.test.ts @@ -11,6 +11,8 @@ import type { Language } from '../../scripts/types/language.js'; interface MockMCPClientShape { fetchVotingRecords: Mock<(...args: unknown[]) => Promise>; searchSpeeches: Mock<(...args: unknown[]) => Promise>; + fetchVotingGroup: Mock<(...args: unknown[]) => Promise>; + fetchMPs: Mock<(...args: unknown[]) => Promise>; } /** Validation input */ @@ -44,7 +46,9 @@ interface BreakingNewsModule { const { mockClientInstance, MockMCPClient } = vi.hoisted(() => { const mockClientInstance: MockMCPClientShape = { fetchVotingRecords: vi.fn().mockResolvedValue([]) as MockMCPClientShape['fetchVotingRecords'], - searchSpeeches: vi.fn().mockResolvedValue([]) as MockMCPClientShape['searchSpeeches'] + searchSpeeches: vi.fn().mockResolvedValue([]) as MockMCPClientShape['searchSpeeches'], + fetchVotingGroup: vi.fn().mockResolvedValue([]) as MockMCPClientShape['fetchVotingGroup'], + fetchMPs: vi.fn().mockResolvedValue([]) as MockMCPClientShape['fetchMPs'] }; function MockMCPClient(): MockMCPClientShape { @@ -134,6 +138,65 @@ describe('Breaking News Article Generation', () => { expect(mockClientInstance.searchSpeeches).toHaveBeenCalled(); }); + + it('should always fetch voting group (enriched with voteId when provided)', async () => { + const eventData: BreakingEventData = { + voteId: 'v123', + slug: 'test' + }; + + await breakingNewsModule.generateBreakingNews({ + languages: ['en'], + eventData + }); + + expect(mockClientInstance.fetchVotingGroup).toHaveBeenCalledWith( + expect.objectContaining({ + punkt: eventData.voteId, + groupBy: 'parti' + }) + ); + }); + + it('should always fetch MP profiles (enriched with speaker name from speech results)', async () => { + const eventData: BreakingEventData = { + topic: 'Budget debate', + slug: 'test' + }; + + const mockSpeakerName = 'Jane Doe'; + mockClientInstance.searchSpeeches.mockResolvedValueOnce([ + { talare: mockSpeakerName } as unknown as Record + ]); + + await breakingNewsModule.generateBreakingNews({ + languages: ['en'], + eventData + }); + + expect(mockClientInstance.fetchMPs).toHaveBeenCalledWith( + expect.objectContaining({ + namn: mockSpeakerName, + limit: 1 + }) + ); + }); + + it('should call all 4 required tools even with minimal event data', async () => { + const eventData: BreakingEventData = { + slug: 'test' + }; + + await breakingNewsModule.generateBreakingNews({ + languages: ['en'], + eventData + }); + + expect(mockClientInstance.fetchVotingRecords).toHaveBeenCalled(); + expect(mockClientInstance.fetchVotingGroup).toHaveBeenCalled(); + expect(mockClientInstance.searchSpeeches).toHaveBeenCalled(); + expect(mockClientInstance.fetchMPs).toHaveBeenCalled(); + }); }); describe('Article Structure', () => {