Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions scripts/data-transformers/content-generators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,82 @@ export function generatePropositionsContent(data: ArticleContentData, lang: Lang

content += ` </ul>\n </div>\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<Record<string, unknown>> | undefined;
if (fullTextResults && fullTextResults.length > 0) {
const policySubstanceHeadings: Record<string, string> = {
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 <h2>${escapeHtml(psHeading)}</h2>\n`;
content += ` <div class="policy-substance">\n`;
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) {
content += ` <div class="fulltext-result"><strong>${docTitle}</strong>`;
if (docSummary) content += `<p>${docSummary}</p>`;
content += `</div>\n`;
}
}
content += ` </div>\n`;
}

// ── Department Impact section (from analyze_g0v_by_department) ───────────
const departmentAnalysis = data.departmentAnalysis as Record<string, unknown> | undefined;
const departments = departmentAnalysis
? ((departmentAnalysis['departments'] ?? departmentAnalysis['dokument'] ?? []) as Array<Record<string, unknown>>)
: [];
if (departments.length > 0) {
const departmentImpactHeadings: Record<string, string> = {
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 <h2>${escapeHtml(diHeading)}</h2>\n`;
content += ` <div class="department-impact"><ul>\n`;
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) {
content += ` <li>${deptName}${deptCount > 0 ? ` (${deptCount})` : ''}</li>\n`;
}
}
content += ` </ul></div>\n`;
}

// ── Parliamentary Debate section (from search_anforanden) ─────────────────
const speechDebates = data.speechDebates as Array<Record<string, unknown>> | undefined;
if (speechDebates && speechDebates.length > 0) {
const parliamentaryDebateHeadings: Record<string, string> = {
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 <h2>${escapeHtml(pdHeading)}</h2>\n`;
content += ` <div class="debate-context">\n`;
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, MAX_SPEECH_PREVIEW_LENGTH));
if (speaker && text) {
content += ` <blockquote><p>${text}…</p><footer>— ${speaker}${party ? ` (${party})` : ''}</footer></blockquote>\n`;
}
}
content += ` </div>\n`;
}

return content;
}

Expand Down
6 changes: 6 additions & 0 deletions scripts/data-transformers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
/** Parliamentary debate speeches from search_anforanden */
speechDebates?: unknown[];
}
68 changes: 53 additions & 15 deletions scripts/news-types/propositions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -250,18 +245,61 @@ 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<string, string>)['titel'] ?? (propositions[0] as Record<string, string>)['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<string, unknown> = {};
try {
console.log(' 🔄 Fetching department impact analysis...');
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) {
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[] = [];

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);

Expand Down Expand Up @@ -302,7 +340,7 @@ export async function generatePropositions(options: GenerationOptions = {}): Pro
mcpCalls,
crossReferences: {
event: `${propositions.length} propositions`,
sources: ['propositioner']
sources: ['propositioner', 'dokument_fulltext', 'g0v_department', 'anforanden']
}
};

Expand Down
36 changes: 35 additions & 1 deletion tests/news-types/propositions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ interface PropositionRecord {
/** Mock MCP client shape */
interface MockMCPClientShape {
fetchPropositions: Mock<(limit: number) => Promise<PropositionRecord[]>>;
request: Mock<(tool: string, params: Record<string, unknown>) => Promise<Record<string, unknown>>>;
searchSpeeches: Mock<(params: Record<string, unknown>) => Promise<unknown[]>>;
}

/** Validation input */
Expand Down Expand Up @@ -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 {
Expand All @@ -77,6 +81,8 @@ describe('Propositions Article Generation', () => {
beforeEach(() => {
vi.clearAllMocks();
mockClientInstance.fetchPropositions.mockResolvedValue(mockPropositions);
mockClientInstance.request.mockResolvedValue({});
mockClientInstance.searchSpeeches.mockResolvedValue([]);
});

afterEach(() => {
Expand All @@ -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);
});
});

Expand All @@ -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([]);

Expand Down
Loading