diff --git a/scripts/data-transformers/content-generators.ts b/scripts/data-transformers/content-generators.ts index 690926df..46caa74f 100644 --- a/scripts/data-transformers/content-generators.ts +++ b/scripts/data-transformers/content-generators.ts @@ -11,7 +11,7 @@ import { escapeHtml } from '../html-utils.js'; import type { Language } from '../types/language.js'; -import type { ArticleContentData, WeekAheadData, RawDocument } from './types.js'; +import type { ArticleContentData, WeekAheadData, RawDocument, RawCalendarEvent } from './types.js'; import { getPillarTransition } from '../editorial-pillars.js'; import { L, @@ -50,6 +50,40 @@ const TITLE_SUFFIX_TEMPLATES: Readonly string>> = zh: t => `,包括"${t}"`, }; +/** Extract meaningful keywords from text for cross-reference matching (min 2 chars, captures EU, KU, etc.) */ +function extractKeywords(text: string): string[] { + return text.toLowerCase().split(/\s+/).filter(w => w.length >= 2); +} + +/** Find documents related to a calendar event by organ match or keyword overlap (max 3) */ +function findRelatedDocuments(event: RawCalendarEvent, documents: RawDocument[]): RawDocument[] { + const rec = event as Record; + const eventOrgan = rec['organ'] ?? ''; + const keywords = extractKeywords(rec['rubrik'] ?? rec['titel'] ?? rec['title'] ?? ''); + return documents.filter(doc => { + const docOrgan = doc.organ ?? doc.committee ?? ''; + if (eventOrgan && docOrgan && eventOrgan.toLowerCase() === docOrgan.toLowerCase()) return true; + const docText = (doc.titel ?? doc.title ?? '').toLowerCase(); + return keywords.some(kw => docText.includes(kw)); + }).slice(0, 3); +} + +/** Find written questions related to a calendar event by keyword overlap (max 3) */ +function findRelatedQuestions(event: RawCalendarEvent, questions: RawDocument[]): RawDocument[] { + const rec = event as Record; + const keywords = extractKeywords(rec['rubrik'] ?? rec['titel'] ?? rec['title'] ?? ''); + return questions.filter(q => { + const qText = (q.titel ?? q.title ?? '').toLowerCase(); + return keywords.some(kw => qText.includes(kw)); + }).slice(0, 3); +} + +/** Extract targeted minister name from interpellation summary "till MINISTER" header line */ +function extractMinister(summary: string): string { + const m = summary.match(/\btill\s+([^\n]+)/i); + return m ? m[1].trim() : ''; +} + export function generateWeekAheadContent(data: WeekAheadData, lang: Language | string): string { const { events, highlights, context } = data; // Cast to ArticleContentData to access documents field (passed via switch cast) @@ -89,6 +123,50 @@ export function generateWeekAheadContent(data: WeekAheadData, lang: Language | s

${dayName ? dayName + ' - ' : ''}${titleHtml}

${event.description || `${eventTime}: ${event.details || 'Parliamentary session scheduled.'}`}

`; + + // Policy Context: cross-reference related documents and questions per event + const relatedPolicyDocs = findRelatedDocuments(event, documents); + const relatedPolicyQs = findRelatedQuestions(event, questions); + if (relatedPolicyDocs.length > 0 || relatedPolicyQs.length > 0) { + const policyContextLabel = lang === 'sv' ? 'Policysammanhang' + : lang === 'de' ? 'Politischer Kontext' + : lang === 'fr' ? 'Contexte politique' + : lang === 'es' ? 'Contexto político' + : lang === 'da' ? 'Politisk kontekst' + : lang === 'no' ? 'Politisk kontekst' + : lang === 'fi' ? 'Poliittinen konteksti' + : lang === 'nl' ? 'Beleidscontext' + : lang === 'ar' ? 'السياق السياسي' + : lang === 'he' ? 'הקשר מדיניות' + : lang === 'ja' ? '政策コンテキスト' + : lang === 'ko' ? '정책 맥락' + : lang === 'zh' ? '政策背景' + : 'Policy Context'; + content += `
\n`; + content += `

${policyContextLabel}

\n`; + relatedPolicyDocs.forEach(doc => { + const drec = doc as Record; + const docTitle = drec['titel'] ?? drec['title'] ?? 'Document'; + const dokId = drec['dok_id'] ?? ''; + const docUrl = dokId ? sanitizeUrl(`https://riksdagen.se/sv/dokument-och-lagar/dokument/${encodeURIComponent(dokId)}/`) : ''; + content += `
\n`; + content += `
${docUrl ? `` : ''}${svSpan(escapeHtml(docTitle), lang)}${docUrl ? '' : ''}
\n`; + const sig = generatePolicySignificance(doc, lang); + if (sig) content += `

${escapeHtml(sig)}

\n`; + content += `
\n`; + }); + relatedPolicyQs.forEach(q => { + const qrec = q as Record; + const qTitle = qrec['titel'] ?? qrec['title'] ?? 'Question'; + const qParty = qrec['parti'] ? ` (${escapeHtml(qrec['parti'])})` : ''; + const qDokId = qrec['dok_id'] ?? ''; + const qUrl = qDokId ? sanitizeUrl(`https://riksdagen.se/sv/dokument-och-lagar/dokument/${encodeURIComponent(qDokId)}/`) : ''; + content += `
\n`; + content += `
${qUrl ? `` : ''}${svSpan(escapeHtml(qTitle), lang)}${qUrl ? '' : ''}${qParty}
\n`; + content += `
\n`; + }); + content += `
\n`; + } }); } @@ -144,22 +222,22 @@ export function generateWeekAheadContent(data: WeekAheadData, lang: Language | s }); } - // Parliamentary Questions: upcoming written questions to ministers + // Questions to Watch: upcoming written questions cross-referenced with debate topics if (questions.length > 0) { - const questionsLabel = lang === 'sv' ? 'Skriftliga frågor till statsråd' - : lang === 'de' ? 'Schriftliche parlamentarische Anfragen' - : lang === 'fr' ? 'Questions écrites au gouvernement' - : lang === 'es' ? 'Preguntas escritas al gobierno' - : lang === 'da' ? 'Skriftlige spørgsmål til ministrene' - : lang === 'no' ? 'Skriftlige spørsmål til statsrådene' - : lang === 'fi' ? 'Kirjalliset kysymykset ministerille' - : lang === 'nl' ? 'Schriftelijke vragen aan ministers' - : lang === 'ar' ? 'أسئلة مكتوبة للحكومة' - : lang === 'he' ? 'שאלות כתובות לממשלה' - : lang === 'ja' ? '大臣への書面質問' - : lang === 'ko' ? '장관에 대한 서면 질문' - : lang === 'zh' ? '书面质询政府' - : 'Parliamentary Questions to Ministers'; + const questionsLabel = lang === 'sv' ? 'Frågor att bevaka' + : lang === 'de' ? 'Zu beobachtende Anfragen' + : lang === 'fr' ? 'Questions à surveiller' + : lang === 'es' ? 'Preguntas a seguir' + : lang === 'da' ? 'Spørgsmål at holde øje med' + : lang === 'no' ? 'Spørsmål å følge med på' + : lang === 'fi' ? 'Seurattavat kysymykset' + : lang === 'nl' ? 'Te volgen vragen' + : lang === 'ar' ? 'أسئلة تستحق المتابعة' + : lang === 'he' ? 'שאלות לעקוב' + : lang === 'ja' ? '注目の質問' + : lang === 'ko' ? '주목할 질문' + : lang === 'zh' ? '值得关注的问题' + : 'Questions to Watch'; content += `\n

${questionsLabel}

\n`; questions.slice(0, 8).forEach(q => { const rec = q as Record; @@ -174,22 +252,22 @@ export function generateWeekAheadContent(data: WeekAheadData, lang: Language | s }); } - // Interpellations: formal parliamentary interpellations awaiting ministerial response + // Interpellation Spotlight: formal interpellations enriched with minister response context if (interpellations.length > 0) { - const interLabel = lang === 'sv' ? 'Interpellationer under behandling' - : lang === 'de' ? 'Interpellationen in Bearbeitung' - : lang === 'fr' ? 'Interpellations en cours' - : lang === 'es' ? 'Interpelaciones en curso' - : lang === 'da' ? 'Forespørgsler til behandling' - : lang === 'no' ? 'Interpellasjoner til behandling' - : lang === 'fi' ? 'Käsittelyssä olevat välikysymykset' - : lang === 'nl' ? 'Interpellaties in behandeling' - : lang === 'ar' ? 'الاستجوابات البرلمانية قيد المعالجة' - : lang === 'he' ? 'בקשות הבהרה בטיפול' - : lang === 'ja' ? '処理中の質問主意書' - : lang === 'ko' ? '처리 중인 대정부 질문' - : lang === 'zh' ? '待处理的质询' - : 'Interpellations Pending'; + const interLabel = lang === 'sv' ? 'Interpellationer i fokus' + : lang === 'de' ? 'Interpellationen im Fokus' + : lang === 'fr' ? 'Interpellations en vedette' + : lang === 'es' ? 'Interpelaciones destacadas' + : lang === 'da' ? 'Forespørgsler i fokus' + : lang === 'no' ? 'Interpellasjoner i fokus' + : lang === 'fi' ? 'Välikysymykset valokeilassa' + : lang === 'nl' ? 'Interpellaties in de spotlight' + : lang === 'ar' ? 'أبرز الاستجوابات البرلمانية' + : lang === 'he' ? 'בקשות הבהרה בזרקור' + : lang === 'ja' ? '注目の質問主意書' + : lang === 'ko' ? '주목할 대정부 질문' + : lang === 'zh' ? '质询聚焦' + : 'Interpellation Spotlight'; content += `\n

${interLabel}

\n`; interpellations.slice(0, 8).forEach(interp => { const rec = interp as Record; @@ -197,9 +275,9 @@ export function generateWeekAheadContent(data: WeekAheadData, lang: Language | s const party = rec['parti'] ? ` (${escapeHtml(rec['parti'])})` : ''; const dok_id = rec['dok_id'] ?? ''; const iUrl = dok_id ? sanitizeUrl(`https://riksdagen.se/sv/dokument-och-lagar/dokument/${encodeURIComponent(dok_id)}/`) : ''; - // Extract clean summary: content starts after "till MINISTER\n" line + // Extract minister and clean summary from the header lines const rawSummary = rec['summary'] ?? ''; - // Find start of actual content after the header lines (Interpellation NNN / av AUTHOR / till MINISTER) + const ministerName = extractMinister(rawSummary); const tillMatch = rawSummary.match(/\btill\s+[^\n]+\n\s*/i); const contentStart = tillMatch ? rawSummary.indexOf(tillMatch[0]) + tillMatch[0].length @@ -215,6 +293,7 @@ export function generateWeekAheadContent(data: WeekAheadData, lang: Language | s content += `
\n`; content += `

${iUrl ? `` : ''}${svSpan(escapeHtml(titleText), lang)}${iUrl ? '' : ''}

\n`; if (party) content += `

${escapeHtml(party)}

\n`; + if (ministerName) content += `

→ ${svSpan(escapeHtml(ministerName), lang)}

\n`; if (cleanedSummary) content += `

${svSpan(escapeHtml(cleanedSummary) + '…', lang)}

\n`; content += `
\n`; }); diff --git a/scripts/news-types/week-ahead.ts b/scripts/news-types/week-ahead.ts index cf31a9ff..bcf31c96 100644 --- a/scripts/news-types/week-ahead.ts +++ b/scripts/news-types/week-ahead.ts @@ -41,16 +41,12 @@ * 6. **Party Positioning**: Known party stances on upcoming votes * 7. **International Context**: EU/Nordic cooperation dimensions * - * **MCP DATA SOURCE:** - * Primary tool: get_calendar_events - * - Retrieves riksdag calendar for specified date range - * - Includes session times, committee assignments, topics - * - Enables systematic prospective coverage - * - * TODO: Implement additional tools for comprehensive analysis: - * - search_dokument: Find related policy documents for calendar items - * - get_fragor: Written questions related to upcoming debates - * - get_interpellationer: Interpellations (parliamentary questions) for upcoming sessions + * **MCP DATA SOURCES (all five tools actively used):** + * - get_calendar_events: upcoming committee/chamber sessions (primary driver) + * - search_dokument: policy documents cross-referenced per calendar event (Policy Context boxes) + * - search_anforanden: recent speeches providing debate context + * - get_fragor: written questions linked to upcoming debates (Questions to Watch section) + * - get_interpellationer: interpellations enriched with minister response context (Interpellation Spotlight) * * **OPERATIONAL WORKFLOW:** * 1. Calculate Date Range: Get calendar for next 7 calendar days diff --git a/tests/news-types/week-ahead.test.ts b/tests/news-types/week-ahead.test.ts index 8982e261..9bd9c456 100644 --- a/tests/news-types/week-ahead.test.ts +++ b/tests/news-types/week-ahead.test.ts @@ -436,6 +436,97 @@ describe('Week-Ahead Article Generation', () => { }); }); + describe('Enhanced Cross-Referencing', () => { + it('should show Policy Context box when event organ matches document organ', async () => { + // Provide a high-priority event (contains 'EU' to pass isHighPriority) with organ matching the doc + mockClientInstance.fetchCalendarEvents.mockResolvedValue([{ + id: '1', title: 'EU budget vote', date: '2026-02-16', type: 'chamber', organ: 'Kammaren', + }]); + mockClientInstance.searchDocuments.mockResolvedValue([{ + titel: 'Budget Proposition 2026', + dok_id: 'H901prop1', + doktyp: 'prop', + organ: 'Kammaren', + }]); + + const result = await weekAheadModule.generateWeekAhead({ languages: ['en'] }); + expect(result.success).toBe(true); + const article = result.articles[0]!; + // 'EU budget vote' event has organ 'Kammaren', matching the document's organ + expect(article.html).toContain('Policy Context'); + expect(article.html).toContain('policy-context-box'); + }); + + it('should show Questions to Watch section label', async () => { + mockClientInstance.fetchWrittenQuestions.mockResolvedValue([{ + titel: 'Question about budget funding', + dok_id: 'H901fr1', + parti: 'V', + }]); + + const result = await weekAheadModule.generateWeekAhead({ languages: ['en'] }); + expect(result.success).toBe(true); + const article = result.articles[0]!; + expect(article.html).toContain('Questions to Watch'); + }); + + it('should show Swedish Questions to Watch label in sv version', async () => { + mockClientInstance.fetchWrittenQuestions.mockResolvedValue([{ + titel: 'Fråga om budgetanslaget', + dok_id: 'H901fr2', + parti: 'S', + }]); + + const result = await weekAheadModule.generateWeekAhead({ languages: ['sv'] }); + expect(result.success).toBe(true); + const article = result.articles[0]!; + expect(article.html).toContain('Frågor att bevaka'); + }); + + it('should show Interpellation Spotlight section label', async () => { + mockClientInstance.fetchInterpellations.mockResolvedValue([{ + titel: 'Question about housing policy', + dok_id: 'H901ip1', + parti: 'S', + summary: 'Interpellation 2025/26:1\nav John Doe\ntill Statsminister Ulf Kristersson\nDetta är en fråga om bostadspolitiken.', + }]); + + const result = await weekAheadModule.generateWeekAhead({ languages: ['en'] }); + expect(result.success).toBe(true); + const article = result.articles[0]!; + expect(article.html).toContain('Interpellation Spotlight'); + }); + + it('should extract and display minister name from interpellation summary', async () => { + mockClientInstance.fetchInterpellations.mockResolvedValue([{ + titel: 'Question about housing policy', + dok_id: 'H901ip2', + parti: 'MP', + summary: 'Interpellation 2025/26:2\nav Jane Doe\ntill Statsminister Ulf Kristersson\nFråga om bostadspolitiken.', + }]); + + const result = await weekAheadModule.generateWeekAhead({ languages: ['en'] }); + expect(result.success).toBe(true); + const article = result.articles[0]!; + expect(article.html).toContain('minister-target'); + expect(article.html).toContain('Statsminister Ulf Kristersson'); + }); + + it('should show Swedish Interpellation Spotlight label in sv version', async () => { + mockClientInstance.fetchInterpellations.mockResolvedValue([{ + titel: 'Interpellation om miljöpolitik', + dok_id: 'H901ip3', + parti: 'MP', + summary: 'Interpellation 2025/26:3\nav Eva Svensson\ntill Klimatminister Romina Pourmokhtari\nFråga om klimatpolitiken.', + }]); + + const result = await weekAheadModule.generateWeekAhead({ languages: ['sv'] }); + expect(result.success).toBe(true); + const article = result.articles[0]!; + expect(article.html).toContain('Interpellationer i fokus'); + }); + }); + describe('Integration with Writer', () => { it('should call writeArticle function if provided', async () => { const mockWriter = vi.fn();