From 3d12b147157151c64fb7a6b94a7992724f3fb511 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:01:10 +0000 Subject: [PATCH 1/3] Initial plan From 2f621c26fb83967807396a2668580ea355013994 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:10:17 +0000 Subject: [PATCH 2/3] Implement search_dokument_fulltext, analyze_g0v_by_department, search_anforanden in propositions.ts; restore REQUIRED_TOOLS to 4 tools; add Policy Substance, Department Impact, Debate sections Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- .../data-transformers/content-generators.ts | 72 +++++++++++++++++++ scripts/data-transformers/types.ts | 6 ++ scripts/news-types/propositions.ts | 63 ++++++++++++---- tests/news-types/propositions.test.ts | 36 +++++++++- 4 files changed, 161 insertions(+), 16 deletions(-) diff --git a/scripts/data-transformers/content-generators.ts b/scripts/data-transformers/content-generators.ts index 690926df..af95c305 100644 --- a/scripts/data-transformers/content-generators.ts +++ b/scripts/data-transformers/content-generators.ts @@ -649,6 +649,78 @@ export function generatePropositionsContent(data: ArticleContentData, lang: Lang content += ` \n \n`; + // ── Policy Substance section (from search_dokument_fulltext) ───────────── + const fullTextResults = data.fullTextResults as Array> | undefined; + if (fullTextResults && fullTextResults.length > 0) { + const policySubstanceHeadings: Record = { + en: 'Policy Substance', sv: 'Politikinnehåll', da: 'Politisk indhold', + no: 'Politisk innhold', fi: 'Politiikan sisältö', de: 'Politischer Inhalt', + fr: 'Contenu politique', es: 'Contenido de la política', nl: 'Beleidsinhoud', + ar: 'مضمون السياسة', he: 'תוכן המדיניות', ja: '政策の内容', ko: '정책 내용', zh: '政策内容', + }; + const psHeading = policySubstanceHeadings[lang as string] ?? policySubstanceHeadings['en']; + content += `\n

${escapeHtml(psHeading)}

\n`; + content += `
\n`; + for (const doc of fullTextResults.slice(0, 3)) { + const docTitle = escapeHtml(String(doc['titel'] ?? doc['title'] ?? '')); + const docSummary = escapeHtml(String(doc['summary'] ?? doc['notis'] ?? '')); + if (docTitle) { + content += `
${docTitle}`; + if (docSummary) content += `

${docSummary}

`; + content += `
\n`; + } + } + content += `
\n`; + } + + // ── Department Impact section (from analyze_g0v_by_department) ─────────── + const departmentAnalysis = data.departmentAnalysis as Record | undefined; + const departments = departmentAnalysis + ? ((departmentAnalysis['departments'] ?? departmentAnalysis['dokument'] ?? []) as Array>) + : []; + if (departments.length > 0) { + const departmentImpactHeadings: Record = { + en: 'Department Impact', sv: 'Departementets påverkan', da: 'Ministerielt ansvar', + no: 'Departementspåvirkning', fi: 'Ministeriön vaikutus', de: 'Ressortverantwortung', + fr: 'Impact ministériel', es: 'Impacto ministerial', nl: 'Ministeriële impact', + ar: 'تأثير الوزارة', he: 'השפעת המשרד', ja: '省庁への影響', ko: '부처 영향', zh: '部门影响', + }; + const diHeading = departmentImpactHeadings[lang as string] ?? departmentImpactHeadings['en']; + content += `\n

${escapeHtml(diHeading)}

\n`; + content += `
    \n`; + for (const dept of departments.slice(0, 5)) { + const deptName = escapeHtml(String(dept['departement'] ?? dept['name'] ?? dept['namn'] ?? '')); + const deptCount = Number(dept['count'] ?? dept['antal'] ?? 0); + if (deptName) { + content += `
  • ${deptName}${deptCount > 0 ? ` (${deptCount})` : ''}
  • \n`; + } + } + content += `
\n`; + } + + // ── Parliamentary Debate section (from search_anforanden) ───────────────── + const speechDebates = data.speechDebates as Array> | undefined; + if (speechDebates && speechDebates.length > 0) { + const parliamentaryDebateHeadings: Record = { + en: 'Parliamentary Debate', sv: 'Parlamentarisk debatt', da: 'Parlamentarisk debat', + no: 'Parlamentarisk debatt', fi: 'Parlamentaarinen keskustelu', de: 'Parlamentarische Debatte', + fr: 'Débat parlementaire', es: 'Debate parlamentario', nl: 'Parlementair debat', + ar: 'النقاش البرلماني', he: 'דיון פרלמנטרי', ja: '議会討論', ko: '의회 토론', zh: '议会辩论', + }; + const pdHeading = parliamentaryDebateHeadings[lang as string] ?? parliamentaryDebateHeadings['en']; + content += `\n

${escapeHtml(pdHeading)}

\n`; + content += `
\n`; + for (const speech of speechDebates.slice(0, 3)) { + const speaker = escapeHtml(String(speech['talare'] ?? speech['speaker'] ?? '')); + const party = escapeHtml(String(speech['parti'] ?? speech['party'] ?? '')); + const text = escapeHtml(String(speech['anforandetext'] ?? speech['text'] ?? '').substring(0, 200)); + if (speaker && text) { + content += `

${text}…

— ${speaker}${party ? ` (${party})` : ''}
\n`; + } + } + content += `
\n`; + } + return content; } diff --git a/scripts/data-transformers/types.ts b/scripts/data-transformers/types.ts index 3193ec50..92f51766 100644 --- a/scripts/data-transformers/types.ts +++ b/scripts/data-transformers/types.ts @@ -91,4 +91,10 @@ export interface ArticleContentData { context?: string; /** CIA intelligence context for enriched analysis */ ciaContext?: CIAContext; + /** Full-text search results for policy substance extraction */ + fullTextResults?: unknown[]; + /** Government department analysis from analyze_g0v_by_department */ + departmentAnalysis?: Record; + /** Parliamentary debate speeches from search_anforanden */ + speechDebates?: unknown[]; } diff --git a/scripts/news-types/propositions.ts b/scripts/news-types/propositions.ts index 6f7ebcb6..ded3e700 100644 --- a/scripts/news-types/propositions.ts +++ b/scripts/news-types/propositions.ts @@ -46,7 +46,7 @@ * - Includes full proposition text, budget impact, department information * - Enables systematic government agenda tracking * - * TODO: Implement additional tools for comprehensive analysis: + * Additional tools for comprehensive analysis: * - search_dokument_fulltext: Full policy analysis from proposition * - analyze_g0v_by_department: Department-by-department impact * - search_anforanden: Parliamentary debate on proposition @@ -183,17 +183,12 @@ import type { ArticleCategory, GeneratedArticle, GenerationResult, MCPCallRecord /** * Required MCP tools for propositions articles - * - * REQUIRED_TOOLS UPDATE (2026-02-14): - * Initially set to 4 tools ['get_propositioner', 'search_dokument_fulltext', 'analyze_g0v_by_department', 'search_anforanden'] - * to match tests/validation expectations. However, this caused runtime validation failures - * since the implementation only calls get_propositioner (line 56). - * - * Reverted to actual implementation (1 tool) to prevent validation failures. - * When additional tools are implemented in generatePropositions(), add them back here. */ export const REQUIRED_TOOLS: readonly string[] = [ - 'get_propositioner' + 'get_propositioner', + 'search_dokument_fulltext', + 'analyze_g0v_by_department', + 'search_anforanden' ]; export interface TitleSet { @@ -250,6 +245,44 @@ export async function generatePropositions(options: GenerationOptions = {}): Pro return { success: true, files: 0, mcpCalls }; } + // ── Step 2: Full-text policy analysis ───────────────────────────────── + const topPropTitle = ((propositions[0] as Record)['titel'] ?? (propositions[0] as Record)['title'] ?? ''); + let fullTextResults: unknown[] = []; + if (topPropTitle) { + try { + console.log(' 🔄 Fetching full-text policy analysis...'); + const ftResponse = await client.request('search_dokument_fulltext', { query: topPropTitle, limit: 3 }); + fullTextResults = (ftResponse['dokument'] ?? ftResponse['results'] ?? []) as unknown[]; + mcpCalls.push({ tool: 'search_dokument_fulltext', result: fullTextResults }); + console.log(` 📄 Found ${fullTextResults.length} full-text matches`); + } catch (err: unknown) { + console.warn(' ⚠ search_dokument_fulltext failed (non-fatal):', (err as Error).message); + } + } + + // ── Step 3: Department impact analysis ──────────────────────────────── + let departmentAnalysis: Record = {}; + try { + console.log(' 🔄 Fetching department impact analysis...'); + const dateStr = formatDateForSlug(new Date()); + departmentAnalysis = await client.request('analyze_g0v_by_department', { dateFrom: dateStr, dateTo: dateStr }); + mcpCalls.push({ tool: 'analyze_g0v_by_department', result: departmentAnalysis }); + console.log(' 🏛 Department analysis retrieved'); + } catch (err: unknown) { + console.warn(' ⚠ analyze_g0v_by_department failed (non-fatal):', (err as Error).message); + } + + // ── Step 4: Parliamentary debate context ────────────────────────────── + let speechDebates: unknown[] = []; + try { + console.log(' 🔄 Fetching parliamentary debate context...'); + speechDebates = await client.searchSpeeches({ text: topPropTitle, rm: '2025/26', limit: 10 }); + mcpCalls.push({ tool: 'search_anforanden', result: speechDebates }); + console.log(` 🗣 Found ${speechDebates.length} debate speeches`); + } catch (err: unknown) { + console.warn(' ⚠ search_anforanden failed (non-fatal):', (err as Error).message); + } + const today = new Date(); const slug = `${formatDateForSlug(today)}-government-propositions`; const articles: GeneratedArticle[] = []; @@ -257,11 +290,11 @@ export async function generatePropositions(options: GenerationOptions = {}): Pro for (const lang of languages) { console.log(` 🌐 Generating ${lang.toUpperCase()} version...`); - const content: string = generateArticleContent({ propositions }, 'propositions', lang); - const watchPoints = extractWatchPoints({ propositions }, lang); - const metadata = generateMetadata({ propositions }, 'propositions', lang); + const content: string = generateArticleContent({ propositions, fullTextResults, departmentAnalysis, speechDebates }, 'propositions', lang); + const watchPoints = extractWatchPoints({ propositions, fullTextResults, departmentAnalysis, speechDebates }, lang); + const metadata = generateMetadata({ propositions, fullTextResults, departmentAnalysis, speechDebates }, 'propositions', lang); const readTime: string = calculateReadTime(content); - const sources: string[] = generateSources(['get_propositioner']); + const sources: string[] = generateSources(['get_propositioner', 'search_dokument_fulltext', 'analyze_g0v_by_department', 'search_anforanden']); const titles: TitleSet = getTitles(lang, propositions.length, propositions); @@ -302,7 +335,7 @@ export async function generatePropositions(options: GenerationOptions = {}): Pro mcpCalls, crossReferences: { event: `${propositions.length} propositions`, - sources: ['propositioner'] + sources: ['propositioner', 'dokument_fulltext', 'g0v_department', 'anforanden'] } }; diff --git a/tests/news-types/propositions.test.ts b/tests/news-types/propositions.test.ts index d82d3c75..4b0135a8 100644 --- a/tests/news-types/propositions.test.ts +++ b/tests/news-types/propositions.test.ts @@ -18,6 +18,8 @@ interface PropositionRecord { /** Mock MCP client shape */ interface MockMCPClientShape { fetchPropositions: Mock<(limit: number) => Promise>; + request: Mock<(tool: string, params: Record) => Promise>>; + searchSpeeches: Mock<(params: Record) => Promise>; } /** Validation input */ @@ -53,7 +55,9 @@ const { mockClientInstance, mockPropositions, MockMCPClient } = vi.hoisted(() => ]; const mockClientInstance: MockMCPClientShape = { - fetchPropositions: vi.fn().mockResolvedValue(mockPropositions) as MockMCPClientShape['fetchPropositions'] + fetchPropositions: vi.fn().mockResolvedValue(mockPropositions) as MockMCPClientShape['fetchPropositions'], + request: vi.fn().mockResolvedValue({}) as MockMCPClientShape['request'], + searchSpeeches: vi.fn().mockResolvedValue([]) as MockMCPClientShape['searchSpeeches'] }; function MockMCPClient(): MockMCPClientShape { @@ -77,6 +81,8 @@ describe('Propositions Article Generation', () => { beforeEach(() => { vi.clearAllMocks(); mockClientInstance.fetchPropositions.mockResolvedValue(mockPropositions); + mockClientInstance.request.mockResolvedValue({}); + mockClientInstance.searchSpeeches.mockResolvedValue([]); }); afterEach(() => { @@ -87,6 +93,10 @@ describe('Propositions Article Generation', () => { it('should export REQUIRED_TOOLS constant', () => { expect(propositionsModule.REQUIRED_TOOLS).toBeDefined(); expect(propositionsModule.REQUIRED_TOOLS).toContain('get_propositioner'); + expect(propositionsModule.REQUIRED_TOOLS).toContain('search_dokument_fulltext'); + expect(propositionsModule.REQUIRED_TOOLS).toContain('analyze_g0v_by_department'); + expect(propositionsModule.REQUIRED_TOOLS).toContain('search_anforanden'); + expect(propositionsModule.REQUIRED_TOOLS).toHaveLength(4); }); }); @@ -100,6 +110,30 @@ describe('Propositions Article Generation', () => { expect(result.mcpCalls!.some((call: MCPCallRecord) => call.tool === 'get_propositioner')).toBe(true); }); + it('should call all 4 required MCP tools', async () => { + const result = await propositionsModule.generatePropositions({ + languages: ['en'] + }); + + const toolNames = result.mcpCalls!.map((call: MCPCallRecord) => call.tool); + expect(toolNames).toContain('get_propositioner'); + expect(toolNames).toContain('search_dokument_fulltext'); + expect(toolNames).toContain('analyze_g0v_by_department'); + expect(toolNames).toContain('search_anforanden'); + }); + + it('should gracefully handle failures of enrichment tools', async () => { + mockClientInstance.request.mockRejectedValue(new Error('MCP enrichment error')); + mockClientInstance.searchSpeeches.mockRejectedValue(new Error('MCP speech error')); + + const result = await propositionsModule.generatePropositions({ + languages: ['en'] + }); + + expect(result.success).toBe(true); + expect(result.mcpCalls!.some((call: MCPCallRecord) => call.tool === 'get_propositioner')).toBe(true); + }); + it('should handle empty propositions', async () => { mockClientInstance.fetchPropositions.mockResolvedValue([]); From 6c94c899e473be5444e268611a77844db9ee25d6 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:28 +0000 Subject: [PATCH 3/3] Address code review: extract named constants, use 7-day date range for department analysis Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/data-transformers/content-generators.ts | 12 ++++++++---- scripts/news-types/propositions.ts | 9 +++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/scripts/data-transformers/content-generators.ts b/scripts/data-transformers/content-generators.ts index af95c305..9b887faa 100644 --- a/scripts/data-transformers/content-generators.ts +++ b/scripts/data-transformers/content-generators.ts @@ -649,6 +649,10 @@ export function generatePropositionsContent(data: ArticleContentData, lang: Lang content += ` \n \n`; + // Display limits for enrichment sections + const MAX_DISPLAY_ITEMS = 3; + const MAX_SPEECH_PREVIEW_LENGTH = 200; + // ── Policy Substance section (from search_dokument_fulltext) ───────────── const fullTextResults = data.fullTextResults as Array> | undefined; if (fullTextResults && fullTextResults.length > 0) { @@ -661,7 +665,7 @@ export function generatePropositionsContent(data: ArticleContentData, lang: Lang const psHeading = policySubstanceHeadings[lang as string] ?? policySubstanceHeadings['en']; content += `\n

${escapeHtml(psHeading)}

\n`; content += `
\n`; - for (const doc of fullTextResults.slice(0, 3)) { + for (const doc of fullTextResults.slice(0, MAX_DISPLAY_ITEMS)) { const docTitle = escapeHtml(String(doc['titel'] ?? doc['title'] ?? '')); const docSummary = escapeHtml(String(doc['summary'] ?? doc['notis'] ?? '')); if (docTitle) { @@ -688,7 +692,7 @@ export function generatePropositionsContent(data: ArticleContentData, lang: Lang const diHeading = departmentImpactHeadings[lang as string] ?? departmentImpactHeadings['en']; content += `\n

${escapeHtml(diHeading)}

\n`; content += `
    \n`; - for (const dept of departments.slice(0, 5)) { + for (const dept of departments.slice(0, MAX_DISPLAY_ITEMS)) { const deptName = escapeHtml(String(dept['departement'] ?? dept['name'] ?? dept['namn'] ?? '')); const deptCount = Number(dept['count'] ?? dept['antal'] ?? 0); if (deptName) { @@ -710,10 +714,10 @@ export function generatePropositionsContent(data: ArticleContentData, lang: Lang const pdHeading = parliamentaryDebateHeadings[lang as string] ?? parliamentaryDebateHeadings['en']; content += `\n

    ${escapeHtml(pdHeading)}

    \n`; content += `
    \n`; - for (const speech of speechDebates.slice(0, 3)) { + for (const speech of speechDebates.slice(0, MAX_DISPLAY_ITEMS)) { const speaker = escapeHtml(String(speech['talare'] ?? speech['speaker'] ?? '')); const party = escapeHtml(String(speech['parti'] ?? speech['party'] ?? '')); - const text = escapeHtml(String(speech['anforandetext'] ?? speech['text'] ?? '').substring(0, 200)); + const text = escapeHtml(String(speech['anforandetext'] ?? speech['text'] ?? '').substring(0, MAX_SPEECH_PREVIEW_LENGTH)); if (speaker && text) { content += `

    ${text}…

    — ${speaker}${party ? ` (${party})` : ''}
    \n`; } diff --git a/scripts/news-types/propositions.ts b/scripts/news-types/propositions.ts index ded3e700..ce817c68 100644 --- a/scripts/news-types/propositions.ts +++ b/scripts/news-types/propositions.ts @@ -264,8 +264,13 @@ export async function generatePropositions(options: GenerationOptions = {}): Pro let departmentAnalysis: Record = {}; try { console.log(' 🔄 Fetching department impact analysis...'); - const dateStr = formatDateForSlug(new Date()); - departmentAnalysis = await client.request('analyze_g0v_by_department', { dateFrom: dateStr, dateTo: dateStr }); + const toDate = new Date(); + const fromDate = new Date(toDate); + fromDate.setDate(fromDate.getDate() - 7); + departmentAnalysis = await client.request('analyze_g0v_by_department', { + dateFrom: formatDateForSlug(fromDate), + dateTo: formatDateForSlug(toDate) + }); mcpCalls.push({ tool: 'analyze_g0v_by_department', result: departmentAnalysis }); console.log(' 🏛 Department analysis retrieved'); } catch (err: unknown) {