Skip to content
Merged
108 changes: 108 additions & 0 deletions scripts/data-transformers/content-generators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,114 @@ export function generateCommitteeContent(data: ArticleContentData, lang: Languag

content += ` </ul>\n </div>\n`;

// ── Optional: Voting Results section ─────────────────────────────────────
const votes = (data.votes ?? []) as unknown[];
if (votes.length > 0) {
const votingSectionHeaders: Record<string, string> = {
sv: 'Röstningsresultat', da: 'Afstemningsresultater', no: 'Voteringsresultater',
fi: 'Äänestystulokset', de: 'Abstimmungsergebnisse', fr: 'Résultats du vote',
es: 'Resultados de la votación', nl: 'Stemresultaten', ar: 'نتائج التصويت',
he: 'תוצאות ההצבעה', ja: '投票結果', ko: '투표 결과', zh: '投票结果',
};
const votingCountTemplates: Record<string, (n: number) => string> = {
sv: (n) => `${n} röstningsprotokoll visar hur partierna röstade i utskottsbeslut denna period.`,
da: (n) => `${n} afstemningsprotokoller viser, hvordan partierne stemte om udvalgets beslutninger.`,
no: (n) => `${n} voteringsprotokoll viser hvordan partiene stemte i komitévedtak.`,
fi: (n) => `${n} äänestysrekisteriä osoittaa, miten puolueet äänestivät valiokunnan päätöksistä.`,
de: (n) => `${n} Abstimmungsrekorde zeigen, wie die Parteien über Ausschussbeschlüsse abstimmten.`,
fr: (n) => `${n} procès-verbaux de vote montrent comment les partis ont voté sur les décisions de commission.`,
es: (n) => `${n} registros de votación muestran cómo votaron los partidos en las decisiones de la comisión.`,
nl: (n) => `${n} stemregisters tonen hoe partijen stemden over commissiebeslissingen.`,
ar: (n) => `${n} سجلات التصويت تظهر كيف صوتت الأحزاب على قرارات اللجنة.`,
he: (n) => `${n} פרוטוקולי הצבעה מציגים כיצד הצביעו המפלגות על החלטות הוועדה.`,
ja: (n) => `${n}件の投票記録が、委員会決定に対する各党の投票方法を示しています。`,
ko: (n) => `${n}건의 투표 기록이 위원회 결정에 대한 각 정당의 투표 방식을 보여줍니다.`,
zh: (n) => `${n}条投票记录显示各党派对委员会决定的投票情况。`,
};
const votingHeader = votingSectionHeaders[lang as string] ?? 'Voting Results';
const votingCountFn = votingCountTemplates[lang as string];
const votingCountText = votingCountFn
? votingCountFn(votes.length)
: `${votes.length} voting records show how parties voted on committee decisions this period.`;
content += `\n <h2>${escapeHtml(votingHeader)}</h2>\n`;
content += ` <p>${escapeHtml(votingCountText)}</p>\n`;
}

// ── Optional: Committee Debate section ───────────────────────────────────
const speeches = (data.speeches ?? []) as unknown[];
if (speeches.length > 0) {
const debateSectionHeaders: Record<string, string> = {
sv: 'Utskottsdebatt', da: 'Udvalgets debat', no: 'Komitédebatt',
fi: 'Valiokunnan keskustelu', de: 'Ausschussdebatte', fr: 'Débat en commission',
es: 'Debate en comisión', nl: 'Commissiedebat', ar: 'نقاش اللجنة',
he: 'דיון בוועדה', ja: '委員会討論', ko: '위원회 토론', zh: '委员会讨论',
};
const debateCountTemplates: Record<string, (n: number) => string> = {
sv: (n) => `${n} anföranden i kammaren belyser de viktigaste argumenten och partipositionerna i dessa frågor.`,
da: (n) => `${n} parlamentariske taler belyser nøgleargumenter og partipositioner.`,
no: (n) => `${n} parlamentariske innlegg belyser nøkkelargumenter og partiposisjoner.`,
fi: (n) => `${n} parlamentaarista puheenvuoroa valaisee keskeisiä argumentteja ja puolueiden kantoja.`,
de: (n) => `${n} parlamentarische Reden beleuchten Hauptargumente und Parteipositionen.`,
fr: (n) => `${n} discours parlementaires éclairent les arguments clés et les positions des partis.`,
es: (n) => `${n} discursos parlamentarios iluminan los principales argumentos y posiciones de los partidos.`,
nl: (n) => `${n} parlementaire toespraken belichten de belangrijkste argumenten en partijposities.`,
ar: (n) => `${n} خطاب برلماني يسلط الضوء على الحجج الرئيسية ومواقف الأحزاب.`,
he: (n) => `${n} נאומים פרלמנטריים מאירים טיעונים מרכזיים ועמדות מפלגות.`,
ja: (n) => `${n}件の議会演説が主要な論点と各党の立場を明らかにしています。`,
ko: (n) => `${n}건의 의회 연설이 주요 논점과 각 정당의 입장을 보여줍니다.`,
zh: (n) => `${n}篇议会演讲揭示了主要论点和各党派立场。`,
};
const debateHeader = debateSectionHeaders[lang as string] ?? 'Committee Debate';
const debateCountFn = debateCountTemplates[lang as string];
const debateCountText = debateCountFn
? debateCountFn(speeches.length)
: `${speeches.length} parliamentary speeches highlight key arguments and party positions on these issues.`;
content += `\n <h2>${escapeHtml(debateHeader)}</h2>\n`;
content += ` <p>${escapeHtml(debateCountText)}</p>\n`;
}

Comment on lines +561 to +626
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New optional sections (Voting Results / Committee Debate / Government Bill Linkage) are rendered based on votes, speeches, and propositions, but there are no unit tests asserting that these sections appear when non-empty and are omitted when empty. Add focused tests (e.g., in tests/data-transformers.test.ts) that pass in minimal non-empty arrays and assert the expected headings/text are present/absent.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in ab8bff6. Eight new tests in tests/data-transformers.test.ts assert that each optional section (Voting Results, Committee Debate, Government Bill Linkage) is rendered when its respective array is non-empty and omitted when empty, plus Swedish heading variants for sv locale.

// ── Optional: Government Bill Linkage section ─────────────────────────────
const propositions = (data.propositions ?? []) as RawDocument[];
if (propositions.length > 0) {
const billSectionHeaders: Record<string, string> = {
sv: 'Koppling till regeringspropositioner', da: 'Tilknytning til regeringsforslag',
no: 'Tilknytning til regjeringsproposisjoner', fi: 'Yhteys hallituksen esityksiin',
de: 'Verknüpfung mit Regierungsvorlagen', fr: 'Lien avec les projets de loi gouvernementaux',
es: 'Vinculación con proyectos de ley gubernamentales', nl: 'Koppeling aan regeringsvoorstellen',
ar: 'الصلة بمشاريع قوانين الحكومة', he: 'קישור להצעות חוק ממשלתיות',
ja: '政府法案との連携', ko: '정부 법안과의 연계', zh: '与政府法案的关联',
};
const billCountTemplates: Record<string, (n: number) => string> = {
sv: (n) => `${n} regeringspropositioner är kopplade till dessa betänkanden och visar lagstiftningskedjan.`,
da: (n) => `${n} regeringsforslag er knyttet til disse betænkninger og viser den lovgivningsmæssige kæde.`,
no: (n) => `${n} regjeringsproposisjoner er knyttet til disse innstillingene og viser den legislative kjeden.`,
fi: (n) => `${n} hallituksen esitystä liittyy näihin mietintöihin ja osoittaa lainsäädäntöketjun.`,
de: (n) => `${n} Regierungsvorlagen sind mit diesen Berichten verknüpft und zeigen die Gesetzgebungskette.`,
fr: (n) => `${n} projets de loi gouvernementaux sont liés à ces rapports, montrant la chaîne législative.`,
es: (n) => `${n} proyectos de ley gubernamentales están vinculados a estos informes, mostrando la cadena legislativa.`,
nl: (n) => `${n} regeringsvoorstellen zijn gekoppeld aan deze rapporten en tonen de wetgevingsketen.`,
ar: (n) => `${n} مشاريع قوانين حكومية مرتبطة بهذه التقارير، مما يُظهر السلسلة التشريعية.`,
he: (n) => `${n} הצעות חוק ממשלתיות קשורות לדוחות אלה, ומציגות את השרשרת החקיקתית.`,
ja: (n) => `${n}件の政府法案がこれらの報告書に関連しており、立法プロセスの連鎖を示しています。`,
ko: (n) => `${n}건의 정부 법안이 이 보고서들과 연계되어 입법 과정의 연결고리를 보여줍니다.`,
zh: (n) => `${n}项政府法案与这些报告相关,展示了立法链条。`,
};
const billHeader = billSectionHeaders[lang as string] ?? 'Government Bill Linkage';
const billCountFn = billCountTemplates[lang as string];
const billCountText = billCountFn
? billCountFn(propositions.length)
: `${propositions.length} government propositions are linked to these reports, tracing the full legislative chain.`;
content += `\n <h2>${escapeHtml(billHeader)}</h2>\n`;
content += ` <p>${escapeHtml(billCountText)}</p>\n`;
propositions.slice(0, 3).forEach(prop => { // display up to 3 linked propositions
if (typeof prop !== 'object' || prop === null) return;
const propTitle = escapeHtml((prop as RawDocument).titel || (prop as RawDocument).title || (prop as RawDocument).dokumentnamn || '');
if (propTitle) {
content += ` <p>→ ${propTitle}</p>\n`;
}
});
}

return content;
}

Expand Down
6 changes: 6 additions & 0 deletions scripts/data-transformers/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,12 @@ export function generateSources(tools: string[] = []): string[] {
if (tools.includes('get_dokument_innehall')) {
sources.push('Riksdagen Document Content');
}
if (tools.includes('search_voteringar')) {
sources.push('Riksdagen Voting Records');
}
if (tools.includes('search_anforanden')) {
sources.push('Riksdagen Speeches');
}

return sources;
}
4 changes: 4 additions & 0 deletions scripts/data-transformers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ export interface ArticleContentData {
context?: string;
/** CIA intelligence context for enriched analysis */
ciaContext?: CIAContext;
/** Voting records for cross-referencing committee decisions */
votes?: unknown[];
/** Parliamentary speeches for committee debate context */
speeches?: unknown[];
/** Government department analysis from analyze_g0v_by_department */
govDeptData?: Record<string, unknown>[];
/** Full-text search results for policy substance extraction */
Expand Down
56 changes: 42 additions & 14 deletions scripts/news-types/committee-reports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,17 +183,12 @@ import type { ArticleCategory, GeneratedArticle, GenerationResult, MCPCallRecord

/**
* Required MCP tools for committee-reports articles
*
* REQUIRED_TOOLS UPDATE (2026-02-14):
* Initially set to 4 tools ['get_betankanden', 'search_voteringar', 'search_anforanden', 'get_propositioner']
* to match tests/validation expectations. However, this caused runtime validation failures
* since the implementation only calls get_betankanden (line 66).
*
* Reverted to actual implementation (1 tool) to prevent validation failures.
* When additional tools are implemented in generateCommitteeReports(), add them back here.
*/
export const REQUIRED_TOOLS: readonly string[] = [
'get_betankanden'
'get_betankanden',
'search_voteringar',
'search_anforanden',
'get_propositioner'
];

export interface TitleSet {
Expand Down Expand Up @@ -255,8 +250,37 @@ export async function generateCommitteeReports(options: GenerationOptions = {}):
return { success: true, files: 0, mcpCalls };
}

// Cross-reference with votes and debates (optional enhancement)
// Future: Add voteringar, anforanden, propositioner queries here
// Step 2: Enrich with voting patterns, speeches, and propositions (non-fatal)
console.log(' 🔄 Fetching voting patterns, speeches, and propositions...');
// Derive riksmöte from the first report's rm field, or calculate from current date.
// Parliamentary year starts in September; e.g. Sep 2025–Aug 2026 → '2025/26'.
const firstReportRaw = (reports[0] as Record<string, unknown>)?.['rm'];
const rmPattern = /^\d{4}\/\d{2}$/;
const firstReportRm: string | undefined =
typeof firstReportRaw === 'string' && rmPattern.test(firstReportRaw) ? firstReportRaw : undefined;
const now = new Date();
const startYear = now.getMonth() >= 8 ? now.getFullYear() : now.getFullYear() - 1;
const currentRm = firstReportRm ?? `${startYear}/${String(startYear + 1).slice(-2)}`;
const [votes, speeches, propositions] = await Promise.all([
Promise.resolve()
.then(() => client.fetchVotingRecords({ rm: currentRm, limit: 20 }) as Promise<unknown[]>)
.catch((err: unknown) => { console.error(' ⚠️ Failed to fetch voting records:', (err as Error)?.message ?? String(err)); return [] as unknown[]; }),
Promise.resolve()
.then(() => client.searchSpeeches({ rm: currentRm, limit: 15 }) as Promise<unknown[]>)
.catch((err: unknown) => { console.error(' ⚠️ Failed to fetch speeches:', (err as Error)?.message ?? String(err)); return [] as unknown[]; }),
Promise.resolve()
.then(() => client.fetchPropositions(20, currentRm) as Promise<unknown[]>)
.catch((err: unknown) => { console.error(' ⚠️ Failed to fetch propositions:', (err as Error)?.message ?? String(err)); return [] as unknown[]; }),
]);
mcpCalls.push({ tool: 'search_voteringar', result: votes });
mcpCalls.push({ tool: 'search_anforanden', result: speeches });
mcpCalls.push({ tool: 'get_propositioner', result: propositions });
console.log(` 🗳 Found ${votes.length} voting records, ${speeches.length} speeches, ${(propositions as unknown[]).length} propositions`);

// Normalize propositions: keep only plain objects to avoid runtime errors in content generator
const safePropositions = propositions.filter(
(p): p is RawDocument => typeof p === 'object' && p !== null
);

const today = new Date();
const slug = `${formatDateForSlug(today)}-committee-reports`;
Expand All @@ -265,11 +289,15 @@ export async function generateCommitteeReports(options: GenerationOptions = {}):
for (const lang of languages) {
console.log(` 🌐 Generating ${lang.toUpperCase()} version...`);

const content: string = generateArticleContent({ reports }, 'committee-reports', lang);
const content: string = generateArticleContent(
{ reports, votes, speeches, propositions: safePropositions },
'committee-reports',
lang
);
const watchPoints = extractWatchPoints({ reports }, lang);
const metadata = generateMetadata({ reports }, 'committee-reports', lang);
const readTime: string = calculateReadTime(content);
const sources: string[] = generateSources(['get_betankanden']);
const sources: string[] = generateSources(['get_betankanden', 'search_voteringar', 'search_anforanden', 'get_propositioner']);
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generateSources() currently only adds specific source labels for a subset of tool names (e.g. get_betankanden, get_propositioner, search_dokument), and does not handle search_voteringar or search_anforanden. Passing those tool names here is therefore misleading (no additional sources will be listed). Either extend generateSources to map these tools to appropriate source labels, or omit them from this call to avoid implying they’re reflected in the sources list.

Suggested change
const sources: string[] = generateSources(['get_betankanden', 'search_voteringar', 'search_anforanden', 'get_propositioner']);
const sources: string[] = generateSources(['get_betankanden', 'get_propositioner']);

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d39d175. Extended generateSources in metadata.ts to map search_voteringar'Riksdagen Voting Records' and search_anforanden'Riksdagen Speeches', so both tools now produce meaningful source labels.


const titles: TitleSet = getTitles(lang, reports.length, reports);

Expand Down Expand Up @@ -310,7 +338,7 @@ export async function generateCommitteeReports(options: GenerationOptions = {}):
mcpCalls,
crossReferences: {
event: `${reports.length} reports`,
sources: ['betankanden']
sources: ['betankanden', 'voteringar', 'anforanden', 'propositioner']
}
};

Expand Down
75 changes: 75 additions & 0 deletions tests/data-transformers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ interface MockArticlePayload {
dok_id?: string;
organ?: string;
}>;
votes?: unknown[];
speeches?: unknown[];
}

describe('Data Transformers', () => {
Expand Down Expand Up @@ -378,6 +380,79 @@ describe('Data Transformers', () => {
expect(content).toContain('No committee reports');
});

it('should render Voting Results section when votes are provided', () => {
const content = generateArticleContent(
{ reports: [{ titel: 'Test Report', url: '#', organ: 'FiU' }], votes: [{}] } as MockArticlePayload,
'committee-reports',
'en'
) as string;
expect(content).toContain('Voting Results');
});

it('should omit Voting Results section when votes array is empty', () => {
const content = generateArticleContent(
{ reports: [{ titel: 'Test Report', url: '#', organ: 'FiU' }], votes: [] } as MockArticlePayload,
'committee-reports',
'en'
) as string;
expect(content).not.toContain('Voting Results');
});

it('should render Committee Debate section when speeches are provided', () => {
const content = generateArticleContent(
{ reports: [{ titel: 'Test Report', url: '#', organ: 'FiU' }], speeches: [{}] } as MockArticlePayload,
'committee-reports',
'en'
) as string;
expect(content).toContain('Committee Debate');
});

it('should omit Committee Debate section when speeches array is empty', () => {
const content = generateArticleContent(
{ reports: [{ titel: 'Test Report', url: '#', organ: 'FiU' }], speeches: [] } as MockArticlePayload,
'committee-reports',
'en'
) as string;
expect(content).not.toContain('Committee Debate');
});

it('should render Government Bill Linkage section when propositions are provided', () => {
const content = generateArticleContent(
{ reports: [{ titel: 'Test Report', url: '#', organ: 'FiU' }], propositions: [{ titel: 'Prop 2025/26:1' }] } as MockArticlePayload,
'committee-reports',
'en'
) as string;
expect(content).toContain('Government Bill Linkage');
expect(content).toContain('Prop 2025/26:1');
});

it('should omit Government Bill Linkage section when propositions array is empty', () => {
const content = generateArticleContent(
{ reports: [{ titel: 'Test Report', url: '#', organ: 'FiU' }], propositions: [] } as MockArticlePayload,
'committee-reports',
'en'
) as string;
expect(content).not.toContain('Government Bill Linkage');
});

it('should render Swedish Röstningsresultat heading for sv language when votes provided', () => {
const content = generateArticleContent(
{ reports: [{ titel: 'Test', url: '#', organ: 'FiU' }], votes: [{}] } as MockArticlePayload,
'committee-reports',
'sv'
) as string;
expect(content).toContain('Röstningsresultat');
});

it('should render Swedish Utskottsdebatt heading for sv language when speeches provided', () => {
const content = generateArticleContent(
{ reports: [{ titel: 'Test', url: '#', organ: 'FiU' }], speeches: [{}] } as MockArticlePayload,
'committee-reports',
'sv'
) as string;
expect(content).toContain('Utskottsdebatt');
});

it('should handle propositions with data', () => {
const content = generateArticleContent({
propositions: [{ titel: 'Test Prop', url: '#', dokumentnamn: 'Prop 2025/26:1' }]
Expand Down
Loading
Loading