-
Notifications
You must be signed in to change notification settings - Fork 1
feat: Enhance week-ahead cross-referencing with policy context, questions, and interpellation spotlight #609
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<Record<string, (t: string) => 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<string, string>; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const eventOrgan = rec['organ'] ?? ''; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const keywords = extractKeywords(rec['rubrik'] ?? rec['titel'] ?? rec['title'] ?? ''); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+59
to
+62
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| 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<string, string>; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| 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() : ''; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| return m ? m[1].trim() : ''; | |
| if (!m) return ''; | |
| const raw = m[1].trim(); | |
| // Remove common trailing topic clauses (e.g. "om X", "angående Y") and punctuation | |
| const lowerRaw = raw.toLowerCase(); | |
| const stopPhrases = [' om ', ' angående ', ' rörande ', ' beträffande ']; | |
| let end = raw.length; | |
| for (const phrase of stopPhrases) { | |
| const idx = lowerRaw.indexOf(phrase); | |
| if (idx !== -1 && idx < end) { | |
| end = idx; | |
| } | |
| } | |
| // Also cut at common terminating punctuation if it comes earlier | |
| const punctIdx = raw.search(/[?:;.,]/); | |
| if (punctIdx !== -1 && punctIdx < end) { | |
| end = punctIdx; | |
| } | |
| return raw.slice(0, end).trim(); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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]!; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+452
to
+454
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // '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'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+440
to
+458
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 handle interpellation summary without minister line gracefully', async () => { | |
| mockClientInstance.fetchInterpellations.mockResolvedValue([{ | |
| titel: 'Question about transport policy', | |
| dok_id: 'H901ip4', | |
| parti: 'M', | |
| summary: 'Interpellation 2025/26:4\nav Alex Example\nDetta är en fråga om transportpolitiken utan specifik ministerrad.', | |
| }]); | |
| const result = await weekAheadModule.generateWeekAhead({ languages: ['en'] }); | |
| expect(result.success).toBe(true); | |
| const article = result.articles[0]!; | |
| // When no minister can be extracted, component should degrade gracefully and not render a minister-target | |
| expect(article.html).not.toContain('minister-target'); | |
| }); | |
| it('should handle malformed minister line without breaking rendering', async () => { | |
| mockClientInstance.fetchInterpellations.mockResolvedValue([{ | |
| titel: 'Question about education policy', | |
| dok_id: 'H901ip5', | |
| parti: 'L', | |
| // "till" appears but no valid minister name follows | |
| summary: 'Interpellation 2025/26:5\nav Chris Example\ntill \nDetta är en fråga om utbildningspolitiken.', | |
| }]); | |
| const result = await weekAheadModule.generateWeekAhead({ languages: ['en'] }); | |
| expect(result.success).toBe(true); | |
| const article = result.articles[0]!; | |
| // Malformed minister line should not cause a crash or render an empty minister-target | |
| expect(article.html).not.toContain('minister-target'); | |
| }); | |
| it('should handle multiple "till" patterns in summary without failing', async () => { | |
| mockClientInstance.fetchInterpellations.mockResolvedValue([{ | |
| titel: 'Question about climate and finance policy', | |
| dok_id: 'H901ip6', | |
| parti: 'C', | |
| summary: 'Interpellation 2025/26:6\nav Pat Example\ntill Klimatminister Romina Pourmokhtari\noch till Finansminister Elisabeth Svantesson\nDetta är en fråga om klimat- och finanspolitiken.', | |
| }]); | |
| const result = await weekAheadModule.generateWeekAhead({ languages: ['en'] }); | |
| expect(result.success).toBe(true); | |
| const article = result.articles[0]!; | |
| // The spotlight section should still render; parsing ambiguities must not break generation | |
| expect(article.html).toContain('Interpellation Spotlight'); | |
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
extractKeywordsfunction filters words by minimum length of 2 characters, which correctly captures acronyms like 'EU', 'KU', and 'AI'. However, it splits on whitespace only and doesn't handle common Swedish compound word patterns or punctuation-separated terms.For example, if an event title is "Budget-diskussion EU-riktlinjer" (Budget discussion EU guidelines), splitting on
\s+will produce "Budget-diskussion" and "EU-riktlinjer" as single keywords, potentially missing matches against documents that use "Budget" or "EU" alone.Consider splitting on
[\s\-,]+to also break on hyphens and commas, or using a more sophisticated tokenization approach for better cross-referencing accuracy.