diff --git a/.github/workflows/news-evening-analysis.md b/.github/workflows/news-evening-analysis.md index e1e40088d0..817eb0d721 100644 --- a/.github/workflows/news-evening-analysis.md +++ b/.github/workflows/news-evening-analysis.md @@ -225,12 +225,18 @@ if (hoursSinceSync > 48) { /* add stale data disclaimer */ } Use riksdag-regering-mcp (32 tools for Swedish parliament data). For ad-hoc queries, use `scripts/mcp-query-cli.ts` — NEVER implement custom MCP client code (PROHIBITION). -Calculate date range for queries (day-granularity via `.slice(0, 10)` truncation): -```js -const today = new Date().toISOString().slice(0, 10); -// lookback_hours input is rounded up to full days for date-string comparison +**Date calculation pattern:** +```javascript +const lookbackHours = 24; // adjust as needed (e.g. 8 for evening analysis, 168 for weekly) +const now = new Date(); +const fromDate = new Date(now.getTime() - lookbackHours * 3600000); // 3600000 ms = 1 hour +const weekAgo = new Date(now.getTime() - 7 * 86400000); // 86400000 ms = 1 day +const today = now.toISOString().split('T')[0]; +// ISO string variants for tools with native date params +const fromDateIso = fromDate.toISOString().slice(0, 10); +// Day-granularity date strings (via .slice(0, 10) truncation): const lookbackDays = Math.ceil(lookbackHours / 24); -const fromDate = new Date(Date.now() - lookbackDays * 86400000).toISOString().slice(0, 10); +const fromDateStr = new Date(Date.now() - lookbackDays * 86400000).toISOString().slice(0, 10); // For weekly review (Saturday): 5-day lookback = 5 * 86400000 ms const weekFromDate = new Date(Date.now() - 5 * 86400000).toISOString().slice(0, 10); ``` @@ -261,18 +267,11 @@ const results = queryResults.filter( Filter results to only include items with dates `>= fromDate` using ISO-string comparison (avoids timezone-sensitive `new Date()` parsing): ```js -const filtered = results.filter(item => (item.datum || item.publicerad || item.inlämnad || '').slice(0, 10) >= fromDate); -``` - -**Post-query date filtering pattern** (use with tools that lack native date params): -```javascript -// Calculate fromDate using ms constants: 86400000 ms/day, 3600000 ms/hour -const fromDate = new Date(Date.now() - lookbackHours * 3600000).toISOString().slice(0, 10); -const today = new Date().toISOString().slice(0, 10); - -// Filter results by date field (day-granularity string comparison avoids timezone issues) -// Include inlämnad for motions which use that date field -const filtered = results.filter(item => (item.publicerad || item.datum || item.inlämnad || '').slice(0, 10) >= fromDate); +const filtered = results.filter(item => + (item.datum || item.publicerad || item.inlämnad || '').slice(0, 10) >= fromDate +); +// Discouraged alternative: new Date() parsing — timezone/format sensitive +// const filtered = rawResults.filter(item => new Date(item.publicerad || item.datum || item.inlämnad) >= fromDate); ``` **Post-query date filtering example** (day-granularity; 86400000 ms = 1 day): @@ -294,7 +293,7 @@ const fromDate = new Date(Date.now() - lookbackDays * 86400000).toISOString().sp **Post-query filtering example:** ```javascript -const results = get_betankanden({ rm: currentRm, limit: 50 }); +const results = await get_betankanden({ rm: currentRm, limit: 50 }); const recent = results.filter(b => (b.publicerad || '').slice(0, 10) >= fromDate); ``` @@ -320,39 +319,66 @@ Cross-reference related data sources for richer analysis. Filter all results by **Example 1: Committee Report Deep Dive** ```javascript -// 1. Get recent committee reports -const betankanden = get_betankanden({ rm: currentRm, limit: 20 }); -const recentBet = betankanden.filter(b => (b.publicerad || '').slice(0, 10) >= fromDate); +// Setup: riksmöte + date threshold (ISO-string comparison — timezone-safe) +const currentRm = '2025/26'; // adjust to current session +const fromDateIso = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10); // YYYY-MM-DD +// 1. Fetch committee reports, filter by date using ISO-string comparison +const allReports = await get_betankanden({ rm: currentRm }); +const reports = allReports.filter(r => (r.publicerad || r.datum || '').slice(0, 10) >= fromDateIso); +// 2. For each report, cross-reference voting records +for (const report of reports) { + const votes = await search_voteringar({ bet: report.beteckning }); +} +``` -// 2. For each report, get full details -const reportDetails = recentBet.map(bet => - get_dokument({ dok_id: bet.dok_id, include_full_text: false }) -); +**Example 2: Government Activity Analysis** +```javascript +// Setup: riksmöte + date threshold (ISO-string comparison — timezone-safe) +const currentRm = '2025/26'; // adjust to current session +const fromDateIso = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10); // YYYY-MM-DD +// 1. Fetch propositions, filter by date using ISO-string comparison +const allProps = await get_propositioner({ rm: currentRm }); +const props = allProps.filter(p => (p.publicerad || p.datum || '').slice(0, 10) >= fromDateIso); +// 2. Cross-reference with government press releases (native dateFrom param) +const press = await search_regering({ type: 'pressmeddelanden', dateFrom: fromDateIso }); +``` -// 3. Check related votes -const relatedVotes = search_voteringar({ rm: currentRm, limit: 50 }) - .filter(v => recentBet.some(bet => v.bet === bet.beteckning)); +**Example 3: Party Behavior Analysis** +```javascript +// Setup: riksmöte + date threshold + party (ISO-string comparison — timezone-safe) +const currentRm = '2025/26'; // adjust to current session +const fromDateIso = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10); // YYYY-MM-DD +const partyCode = 'S'; // e.g. S, M, SD, V, MP, C, L, KD +// 1. Get motions filed by party, filter by date using ISO-string comparison +const allMotions = await get_motioner({ rm: currentRm }); +const motions = allMotions.filter(m => (m.inlämnad || m.datum || '').slice(0, 10) >= fromDateIso); +// 2. Get party voting patterns, filter by date +const allVotes = await search_voteringar({ parti: partyCode, rm: currentRm }); +const votes = allVotes.filter(v => (v.datum || '').slice(0, 10) >= fromDateIso); ``` **Example 2: Government Activity Analysis** ```javascript // 1. Get government documents in date range -const govDocs = search_regering({ dateFrom: fromDate, dateTo: today, limit: 30 }); +const fromDateIso = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10); +const today = new Date().toISOString().slice(0, 10); +const govDocs = await search_regering({ dateFrom: fromDateIso, dateTo: today, limit: 30 }); // 2. Get related propositions -const propositions = get_propositioner({ rm: currentRm, limit: 20 }) - .filter(p => (p.publicerad || '').slice(0, 10) >= fromDate); +const propositions = (await get_propositioner({ rm: currentRm, limit: 20 })) + .filter(p => (p.publicerad || '').slice(0, 10) >= fromDateIso); ``` **Example 3: Party Behavior Analysis** ```javascript // 1. Get party voting records -const votes = search_voteringar({ rm: currentRm, limit: 100 }) - .filter(v => (v.datum || '').slice(0, 10) >= fromDate); +const fromDateIso = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10); +const votes = (await search_voteringar({ rm: currentRm, limit: 100 })) + .filter(v => (v.datum || '').slice(0, 10) >= fromDateIso); // 2. Get party speeches -const speeches = search_anforanden({ rm: currentRm, limit: 100 }) - .filter(a => (a.datum || '').slice(0, 10) >= fromDate); +const speeches = (await search_anforanden({ rm: currentRm, limit: 100 })) + .filter(a => (a.datum || '').slice(0, 10) >= fromDateIso); ``` **Detailed Example: Committee Report Deep Dive** diff --git a/scripts/generate-news-enhanced/ai-analysis-pipeline.ts b/scripts/generate-news-enhanced/ai-analysis-pipeline.ts new file mode 100644 index 0000000000..dd0c302cb0 --- /dev/null +++ b/scripts/generate-news-enhanced/ai-analysis-pipeline.ts @@ -0,0 +1,1541 @@ +/** + * @module generate-news-enhanced/ai-analysis-pipeline + * @description Heuristic-based multi-iteration analysis pipeline for deep political + * intelligence. Uses deterministic document classification, template-driven + * per-document analysis, cross-document synthesis, and quality scoring to produce + * context-aware political insights from every stakeholder perspective. + * + * NOTE: This module does NOT integrate with external LLM/MCP services. All analysis + * is performed via rule-based heuristics and localised template interpolation. The + * "iteration" depth controls how many passes run (see {@link AIAnalysisPipeline}). + * + * Architecture — four analysis passes (gated by iteration depth): + * 1. Data Collection & Classification — classify by type/domain, detect policy areas + * 2. Deep Analysis (iterations ≥ 2) — per-document legislative impact, cross-party + * implications, historical context, and EU/Nordic comparison + * 3. Cross-Document Synthesis (iterations ≥ 2) — convergence/divergence patterns, + * coalition stress, emerging trends, stakeholder power dynamics + * 4. Quality Assurance & Refinement (iterations ≥ 3) — score output, re-generate + * below threshold + * + * @author Hack23 AB + * @license Apache-2.0 + */ + +import { detectPolicyDomains } from '../data-transformers/policy-analysis.js'; +import { escapeHtml } from '../html-utils.js'; +import type { RawDocument } from '../data-transformers.js'; +import type { Language } from '../types/language.js'; +import type { SwotEntry } from '../types/article.js'; + +// --------------------------------------------------------------------------- +// Public interfaces +// --------------------------------------------------------------------------- + +/** Per-document deep analysis produced in Pass 2. */ +export interface AIDocumentAnalysis { + /** Riksdag document identifier. */ + dok_id: string; + /** Document title. */ + title: string; + /** Assessment of the document's legislative impact in the target language. */ + legislativeImpact: string; + /** Cross-party implications of the document. */ + crossPartyImplications: string; + /** Historical context or precedent relevant to the document. */ + historicalContext: string; + /** EU and Nordic comparative dimension. */ + euNordicComparison: string; + /** Analysis depth score 0–100 (distinct from the 0.0–1.0 qualityScore used by article-quality-enhancer). */ + analysisScore: number; +} + +/** Cross-document synthesis produced in Pass 3. */ +export interface AISynthesis { + /** Assessment of policy convergence or divergence across documents. */ + policyConvergence: string; + /** Indicators of coalition stress visible in the document set. */ + coalitionStressIndicators: string; + /** + * Emerging legislative trends detected. + * Format: comma-separated domain names with a single bracketed confidence + * level appended to the whole list, e.g. "fiscal policy, defence, environment [HIGH]". + * Empty string when no domains are detected. + */ + emergingTrends: string; + /** Stakeholder power dynamics implied by the document distribution. */ + stakeholderPowerDynamics: string; +} + +/** Dynamically generated SWOT entries replacing hardcoded SWOT_DEFAULTS. */ +export interface DynamicSwotEntries { + government: { + strengths: SwotEntry[]; + weaknesses: SwotEntry[]; + opportunities: SwotEntry[]; + threats: SwotEntry[]; + }; + opposition: { + strengths: SwotEntry[]; + weaknesses: SwotEntry[]; + opportunities: SwotEntry[]; + threats: SwotEntry[]; + }; + privateSector: { + strengths: SwotEntry[]; + weaknesses: SwotEntry[]; + opportunities: SwotEntry[]; + threats: SwotEntry[]; + }; +} + +/** Final output of the full multi-iteration analysis pipeline. */ +export interface AIAnalysisResult { + /** Total iterations executed. */ + iterations: number; + /** Per-document deep analyses from Pass 2. */ + documentAnalyses: AIDocumentAnalysis[]; + /** Cross-document synthesis from Pass 3. */ + synthesis: AISynthesis; + /** Context-aware SWOT entries replacing hardcoded defaults. */ + dynamicSwotEntries: DynamicSwotEntries; + /** Strategic implications paragraph in the target language. */ + strategicImplications: string; + /** Bullet-list key takeaways. */ + keyTakeaways: string[]; + /** Overall analysis depth score 0–100 (distinct from the 0.0–1.0 qualityScore used by article-quality-enhancer). */ + analysisScore: number; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** Minimum quality score below which Pass 4 triggers re-analysis. */ +const QUALITY_THRESHOLD = 45; + +/** Document type keywords used for legislative signal detection. */ +const GOV_TYPES = new Set(['prop', 'pressm', 'skr', 'sfs', 'ds', 'sou']); +const OPP_TYPES = new Set(['bet', 'mot', 'ip', 'fr']); +const EU_TYPES = new Set(['fpm', 'eu']); +const EXT_TYPES = new Set(['ext', 'external']); + +function docType(d: RawDocument): string { + return (d.doktyp ?? d.documentType ?? '').toLowerCase(); +} + +function docTitle(d: RawDocument): string { + return (d.titel ?? d.title ?? d.dokumentnamn ?? d.dok_id ?? '').slice(0, 120); +} + +/** Classify a document into a stakeholder bucket. */ +function classifyStakeholder(d: RawDocument): 'government' | 'opposition' | 'eu' | 'other' { + const t = docType(d); + if (GOV_TYPES.has(t)) return 'government'; + if (OPP_TYPES.has(t)) return 'opposition'; + if (EU_TYPES.has(t)) return 'eu'; + return 'other'; +} + +/** Identify whether a document signals cross-party coalition stress. */ +function hasCoalitionStress(d: RawDocument): boolean { + const title = docTitle(d).toLowerCase(); + // Require strong conflict markers — generic terms like "motion" or "opposition" + // are too broad and would flag most document mixes, reducing signal quality. + const stressKeywords = [ + 'avslag', 'reject', 'tillägg', 'amendment', + 'reservation', 'minoritet', 'minority', + ]; + return stressKeywords.some(kw => title.includes(kw)); +} + +/** Maximum score contribution from word count alone. */ +const WORD_SCORE_CAP = 60; +/** Words required per quality point from word-count scoring. */ +const WORDS_PER_POINT = 3; +/** Bonus score added when the analysis references specific numeric figures. */ +const SPECIFICITY_BONUS = 20; +/** Bonus score added when the analysis contains cross-reference terms. */ +const CROSS_REF_BONUS = 20; + +/** CJK Unicode range test — for languages without whitespace word boundaries (zh, ja, ko). */ +const CJK_REGEX = /[\u3000-\u9fff\uac00-\ud7af\uff00-\uffef]/; +/** Characters-per-point for CJK character-based scoring (replaces words/WORDS_PER_POINT). */ +const CJK_CHARS_PER_POINT = 5; + +/** Score analysis depth: 0–100 based on content length and richness. */ +function scoreAnalysisDepth(text: string): number { + if (!text || text.length === 0) return 0; + + // Use character-length scoring for CJK text (no whitespace word boundaries) + const isCJK = CJK_REGEX.test(text); + const contentSize = isCJK + ? Math.floor(text.length / CJK_CHARS_PER_POINT) + : text.split(/\s+/).length / WORDS_PER_POINT; + let score = Math.min(WORD_SCORE_CAP, Math.floor(contentSize)); + + // Specificity: numeric references in any language + const hasSpecific = /\d{4}|\d+\s*(kr|miljarder|miljoner|percent|%|万|億|兆|조|억)/.test(text); + if (hasSpecific) score += SPECIFICITY_BONUS; + + // Cross-reference markers — multilingual equivalents + const hasCrossRef = /cross-party|coalition|EU|EU-|Nordic|parliamentary|tvärs?parti|koalition|parlamentar|超党派|連立|議会|초당파|연립|의회|跨党派|联盟|议会/.test(text); + if (hasCrossRef) score += CROSS_REF_BONUS; + + return Math.min(100, score); +} + +// --------------------------------------------------------------------------- +// Per-language phrase maps +// --------------------------------------------------------------------------- + +/** All 14 supported languages. */ +type Lang14 = Record; + +function L14(en: string, sv: string, da: string, no: string, fi: string, + de: string, fr: string, es: string, nl: string, + ar: string, he: string, ja: string, ko: string, zh: string): Lang14 { + return { en, sv, da, no, fi, de, fr, es, nl, ar, he, ja, ko, zh }; +} + +function pickLang(map: Lang14, lang: Language): string { + return map[lang] ?? map.en; +} + +// ── Legislative impact labels ──────────────────────────────────────────────── +const LEGISLATIVE_SIGNAL: Lang14 = L14( + 'Active legislative agenda:', + 'Aktiv lagstiftningsagenda:', + 'Aktiv lovgivningsdagsorden:', + 'Aktiv lovgivningsagenda:', + 'Aktiivinen lainsäädäntöohjelma:', + 'Aktive Gesetzgebungsagenda:', + 'Agenda législative active:', + 'Agenda legislativa activa:', + 'Actieve wetgevingsagenda:', + 'جدول أعمال تشريعية نشطة:', + 'סדר יום חקיקתי פעיל:', + '活発な立法アジェンダ:', + '활발한 입법 의제:', + '积极的立法议程:', +); + +const SCRUTINY_SIGNAL: Lang14 = L14( + 'Parliamentary scrutiny signal:', + 'Parlamentarisk granskningssignal:', + 'Parlamentarisk kontrolsignal:', + 'Parlamentarisk kontrollsignal:', + 'Parlamentaarinen valvontasignaali:', + 'Parlamentarisches Kontrollsignal:', + 'Signal de contrôle parlementaire:', + 'Señal de control parlamentario:', + 'Parlementair controlesignaal:', + 'إشارة الرقابة البرلمانية:', + 'אות פיקוח פרלמנטרי:', + '議会審査シグナル:', + '의회 심사 신호:', + '议会审查信号:', +); + +const EU_ALIGNMENT_SIGNAL: Lang14 = L14( + 'EU alignment dimension:', + 'EU-anpassningsdimension:', + 'EU-tilpasningsdimension:', + 'EU-tilpasningsdimensjon:', + 'EU-yhdenmukaistamisdimensio:', + 'EU-Ausrichtungsdimension:', + 'Dimension d\'alignement UE:', + 'Dimensión de alineación UE:', + 'EU-afstemmingsdimensie:', + 'بُعد التوافق مع الاتحاد الأوروبي:', + 'ממד ההתאמה לאיחוד האירופי:', + 'EU整合の側面:', + 'EU 정합성 차원:', + 'EU对齐维度:', +); + +// ── SWOT quadrant labels ───────────────────────────────────────────────────── +const GOV_STRENGTH_LABELS: Record = { + propositions: L14( + 'Active legislative pipeline: %n government proposal%s driving %t policy', + 'Aktiv lagstiftningspipeline: %n propositioner driver %t-politik', + 'Aktiv lovgivningspipeline: %n lovforslag driver %t-politik', + 'Aktiv lovgivningspipeline: %n stortingsproposisjoner driver %t-politikk', + 'Aktiivinen lainsäädäntöputki: %n hallituksen esitystä ohjaavat %t-politiikkaa', + 'Aktive Gesetzgebungs-Pipeline: %n Regierungsvorlagen treiben %t-Politik voran', + 'Pipeline législative active: %n propositions gouvernementales propulsent la politique %t', + 'Pipeline legislativa activa: %n propuestas de gobierno impulsan la política %t', + 'Actieve wetgevingspijplijn: %n overheidstvoorstellen sturen %t-beleid', + 'خط أنابيب تشريعي نشط: %n مقترح حكومي يدفع سياسة %t', + 'צינור חקיקה פעיל: %n הצעות ממשלה מניעות מדיניות %t', + 'アクティブな立法パイプライン: %n件の政府提案が%t政策を推進', + '활발한 입법 파이프라인: %n 정부 제안이 %t 정책 추진', + '积极的立法管道:%n 项政府提案推动%t政策', + ), + sfs: L14( + 'Regulatory authority: enacted legislation provides implementation framework for %t', + 'Regelverksauktoritet: stiftad lagstiftning ger implementeringsramverk för %t', + 'Regelværksautoritet: vedtaget lovgivning giver implementeringsramme for %t', + 'Regelverksautoritet: vedtatt lovgivning gir implementeringsrammeverk for %t', + 'Sääntelyauktoriteetti: annettu lainsäädäntö tarjoaa täytäntöönpanokehyksen %t:lle', + 'Regulierungsbehörde: erlassene Gesetzgebung bildet Umsetzungsrahmen für %t', + 'Autorité réglementaire: la législation adoptée fournit un cadre de mise en œuvre pour %t', + 'Autoridad regulatoria: la legislación promulgada proporciona un marco de implementación para %t', + 'Regelgevende autoriteit: uitgevaardigde wetgeving biedt implementatiekader voor %t', + 'سلطة تنظيمية: التشريع المسنّ يوفر إطار تنفيذ لـ %t', + 'סמכות רגולטורית: חקיקה שנחקקה מספקת מסגרת יישום ל-%t', + '規制権限:制定された法令が%tの実施枠組みを提供', + '규제 권한: 제정된 법률이 %t에 대한 이행 프레임워크 제공', + '监管权力:已颁布的法律为%t提供实施框架', + ), + default: L14( + 'Government agenda-setting on %t', + 'Regeringens agendasättning för %t', + 'Regeringens dagsordenssætning for %t', + 'Regjeringens agendaoppsetting for %t', + 'Hallituksen agendaasetus %t:lle', + 'Regierung setzt Agenda für %t', + 'Le gouvernement fixe l\'agenda sur %t', + 'El gobierno establece la agenda en %t', + 'Agenda bepalen door de regering voor %t', + 'تحديد أجندة الحكومة على %t', + 'קביעת סדר יום ממשלתי על %t', + '政府が%tに関するアジェンダを設定', + '정부의 %t 의제 설정', + '政府在%t问题上设定议程', + ), +}; + +const OPP_STRENGTH_LABELS: Record = { + committee: L14( + 'Parliamentary scrutiny: %n committee report%s examining %t', + 'Parlamentarisk granskning: %n betänkanden granskar %t', + 'Parlamentarisk kontrol: %n udvalgsrapporter undersøger %t', + 'Parlamentarisk kontroll: %n komitérapporter undersøker %t', + 'Parlamentaarinen valvonta: %n valiokuntamietintöä tutkii %t:tä', + 'Parlamentarische Kontrolle: %n Ausschussberichte prüfen %t', + 'Contrôle parlementaire: %n rapports de commission examinent %t', + 'Escrutinio parlamentario: %n informes de comité examinan %t', + 'Parlementaire controle: %n commissierapporten onderzoeken %t', + 'الرقابة البرلمانية: %n تقرير لجنة تفحص %t', + 'פיקוח פרלמנטרי: %n דוחות ועדה בוחנים %t', + '議会審査:%n 件の委員会報告書が%tを検討', + '의회 심사: %n 위원회 보고서가 %t 검토', + '议会审查:%n 份委员会报告审查%t', + ), + motions: L14( + 'Opposition challenge: %n motion%s contesting %t proposals', + 'Oppositionsutmaning: %n motioner utmanar %t-förslag', + 'Oppositionsudfordring: %n motioner udfordrer %t-forslag', + 'Opposisjonsutfordring: %n motioner utfordrer %t-forslag', + 'Oppositiohaaste: %n kirjelmää haastaa %t-esitykset', + 'Herausforderung der Opposition: %n Anträge fechten %t-Vorschläge an', + 'Défi de l\'opposition: %n motions contestent les propositions %t', + 'Desafío de la oposición: %n mociones impugnan las propuestas %t', + 'Oppositie-uitdaging: %n moties betwisten %t-voorstellen', + 'تحدي المعارضة: %n اقتراح يطعن في مقترحات %t', + 'אתגר אופוזיציה: %n הצעות מתמודדות עם הצעות %t', + '野党の挑戦:%n 件の動議が%t提案に異議', + '야당 도전: %n 동의가 %t 제안에 이의', + '反对派挑战:%n 项动议对%t提案提出异议', + ), + default: L14( + 'Parliamentary oversight and accountability on %t', + 'Parlamentarisk tillsyn och ansvarsskyldighet för %t', + 'Parlamentarisk tilsyn og ansvarlighed for %t', + 'Parlamentarisk tilsyn og ansvarlighet for %t', + 'Parlamentaarinen valvonta ja vastuuvelvollisuus %t:lle', + 'Parlamentarische Aufsicht und Rechenschaftspflicht für %t', + 'Contrôle parlementaire et responsabilité sur %t', + 'Supervisión parlamentaria y responsabilidad sobre %t', + 'Parlementair toezicht en verantwoording voor %t', + 'الرقابة البرلمانية والمساءلة على %t', + 'פיקוח פרלמנטרי ואחריות על %t', + '%tに関する議会の監視と説明責任', + '%t에 대한 의회 감독 및 책임', + '%t问题上的议会监督和责任', + ), +}; + +// ── Opportunity & threat templates (all 14 languages) ────────────────────── + +const EU_OPPORTUNITY: Lang14 = L14( + 'EU framework alignment and Nordic co-operation on %t', + 'EU-ramverksanpassning och nordiskt samarbete om %t', + 'EU-rammetilpasning og nordisk samarbejde om %t', + 'EU-rammeverktilpasning og nordisk samarbeid om %t', + 'EU-kehystoimenpiteiden yhdenmukaistaminen ja pohjoismainen yhteistyö %t:ssä', + 'EU-Rahmenausrichtung und nordische Zusammenarbeit bei %t', + 'Alignement avec le cadre européen et coopération nordique sur %t', + 'Alineación con el marco europeo y cooperación nórdica en %t', + 'EU-kaderafstemming en Noordse samenwerking op %t', + 'التوافق مع الإطار الأوروبي والتعاون الشمالي على %t', + 'התאמה למסגרת האיחוד האירופי ושיתוף פעולה נורדי על %t', + 'EUフレームワーク整合とノルディック協力(%t)', + 'EU 프레임워크 정합 및 북유럽 협력 (%t)', + 'EU框架对齐与北欧合作(%t)', +); + +const GOV_WEAKNESS_IMPL: Lang14 = L14( + 'Implementation timeline and resource allocation for %t', + 'Genomförandetidsplan och resurstilldelning för %t', + 'Implementeringstidsplan og ressourceallokering for %t', + 'Implementeringstidsplan og ressursallokering for %t', + 'Toteutusaikataulu ja resurssien kohdentaminen %t:lle', + 'Umsetzungszeitplan und Ressourcenallokation für %t', + 'Calendrier de mise en œuvre et allocation des ressources pour %t', + 'Cronograma de implementación y asignación de recursos para %t', + 'Implementatietijdlijn en toewijzing van middelen voor %t', + 'الجدول الزمني للتنفيذ وتخصيص الموارد لـ %t', + 'לוח זמנים ליישום והקצאת משאבים עבור %t', + '%tの実施スケジュールとリソース配分', + '%t 이행 일정 및 자원 배분', + '%t的实施时间表和资源分配', +); + +const GOV_THREAT_EXEC: Lang14 = L14( + 'Execution risks and stakeholder resistance to %t reform', + 'Genomföranderisker och motstånd mot %t-reform', + 'Udførelsesrisici og interessentmodstand mod %t-reform', + 'Utføringsrisikoer og interessentmotstand mot %t-reform', + 'Toteutusriskit ja sidosryhmien vastustus %t-uudistukselle', + 'Ausführungsrisiken und Widerstand der Stakeholder gegen %t-Reform', + 'Risques d\'exécution et résistance des parties prenantes à la réforme %t', + 'Riesgos de ejecución y resistencia de las partes interesadas a la reforma %t', + 'Uitvoeringsrisico\'s en weerstand van belanghebbenden tegen %t-hervorming', + 'مخاطر التنفيذ ومقاومة أصحاب المصلحة لإصلاح %t', + 'סיכוני ביצוע והתנגדות בעלי עניין לרפורמת %t', + '%t改革に対する実施リスクとステークホルダーの反発', + '%t 개혁에 대한 실행 위험 및 이해관계자 저항', + '%t改革的执行风险和利益相关者阻力', +); + +const OPP_WEAKNESS_INFO: Lang14 = L14( + 'Limited access to implementation data and classified briefings on %t', + 'Begränsad tillgång till genomförandedata och sekretessbelagda underlag om %t', + 'Begrænset adgang til implementeringsdata og klassificerede briefinger om %t', + 'Begrenset tilgang til implementeringsdata og klassifiserte orienteringer om %t', + 'Rajoitettu pääsy täytäntöönpanotietoihin ja salaisiin tiedotteisiin %t:stä', + 'Begrenzter Zugang zu Umsetzungsdaten und klassifizierten Berichten über %t', + 'Accès limité aux données de mise en œuvre et aux briefings classifiés sur %t', + 'Acceso limitado a datos de implementación e informes clasificados sobre %t', + 'Beperkte toegang tot implementatiegegevens en geclassificeerde briefings over %t', + 'وصول محدود إلى بيانات التنفيذ والإحاطات السرية حول %t', + 'גישה מוגבלת לנתוני יישום ותדרוכים חסויים על %t', + '%tに関する実施データおよび機密ブリーフィングへのアクセスが限定', + '%t에 관한 이행 데이터 및 기밀 브리핑에 대한 제한적 접근', + '对%t实施数据和机密简报的有限访问', +); + +const OPP_OPPORTUNITY_CONSENSUS: Lang14 = L14( + 'Cross-party consensus building on %t reform agenda', + 'Konsensusbyggande över partigränser om %t-reformagenda', + 'Tværpartisanisk konsensusopbygning om %t-reformdagsorden', + 'Tverrpartisanisk konsensusbygging om %t-reformagenda', + 'Puolueiden välinen konsensuksen rakentaminen %t-uudistusohjelmasta', + 'Überparteilicher Konsensaufbau zur %t-Reformagenda', + 'Construction d\'un consensus interpartis sur l\'agenda de réforme %t', + 'Construcción de consenso multipartidista sobre la agenda de reforma %t', + 'Overpartijdig consensusopbouw over de %t-hervormingsagenda', + 'بناء توافق بين الأحزاب حول أجندة إصلاح %t', + 'בניית קונצנזוס בין-מפלגתי על אג\'נדת הרפורמות ב-%t', + '%t改革課題に関する超党派コンセンサス形成', + '%t 개혁 의제에 관한 초당파적 합의 구축', + '在%t改革议程上的跨党派共识建立', +); + +const OPP_THREAT_MAJORITY: Lang14 = L14( + 'Government majority limiting parliamentary amendment capacity on %t', + 'Regeringsmajoriteten begränsar riksdagens ändringskapacitet för %t', + 'Regeringsflertal begrænser parlamentets ændringskapacitet for %t', + 'Regjeringsflertall begrenser stortingets endringskapasitet for %t', + 'Hallitusenemmistö rajoittaa eduskunnan muutoskapasiteettia %t:ssä', + 'Regierungsmehrheit schränkt parlamentarische Änderungskapazität bei %t ein', + 'Majorité gouvernementale limitant la capacité d\'amendement parlementaire sur %t', + 'Mayoría de gobierno que limita la capacidad de enmienda parlamentaria sobre %t', + 'Regeringsmeerderheid beperkt parlementaire wijzigingscapaciteit voor %t', + 'أغلبية الحكومة تحد من قدرة البرلمان على التعديل في %t', + 'רוב הממשלה מגביל את יכולת תיקון הפרלמנט ב-%t', + '与党多数が%tに関する議会の修正能力を制限', + '정부 다수파가 %t에 관한 의회 수정 능력 제한', + '政府多数派限制议会对%t的修改能力', +); + +const PRIVATE_STRENGTH_DOMAIN: Lang14 = L14( + 'Domain expertise and operational capacity in %t', + 'Domänexpertis och operativ kapacitet inom %t', + 'Domæneekspertise og operationel kapacitet inden for %t', + 'Domenekompetanse og operativ kapasitet innen %t', + 'Toimialaasiantuntemus ja operatiivinen kapasiteetti %t:ssä', + 'Fachkompetenz und operative Kapazität in %t', + 'Expertise sectorielle et capacité opérationnelle dans %t', + 'Experiencia en el sector y capacidad operativa en %t', + 'Domeinexpertise en operationele capaciteit in %t', + 'الخبرة في المجال والقدرة التشغيلية في %t', + 'מומחיות בתחום וכושר תפעולי ב-%t', + '%tにおけるドメイン専門知識と運用能力', + '%t 분야 전문성 및 운영 역량', + '%t领域专业知识和运营能力', +); + +const PRIVATE_WEAKNESS_COMPLIANCE: Lang14 = L14( + 'Compliance costs and adaptation burden from %t regulation', + 'Efterlevnadskostnader och anpassningsbörda från %t-reglering', + 'Complianceomkostninger og tilpasningsbyrde fra %t-regulering', + 'Etterlevelseskostnader og tilpasningsbyrde fra %t-regulering', + 'Vaatimustenmukaisuuskustannukset ja sopeutumisrasite %t-sääntelystä', + 'Compliance-Kosten und Anpassungsbelastung durch %t-Regulierung', + 'Coûts de mise en conformité et charge d\'adaptation de la réglementation %t', + 'Costos de cumplimiento y carga de adaptación por la regulación %t', + 'Compliancekosten en aanpassingslast van %t-regelgeving', + 'تكاليف الامتثال وعبء التكيف مع لوائح %t', + 'עלויות ציות ועומס הסתגלות מרגולציית %t', + '%t規制によるコンプライアンスコストと適応負担', + '%t 규제로 인한 컴플라이언스 비용 및 적응 부담', + '%t法规带来的合规成本和适应负担', +); + +const PRIVATE_OPPORTUNITY_INVESTMENT: Lang14 = L14( + 'Investment and innovation opportunities from %t policy direction', + 'Investerings- och innovationsmöjligheter från %t-politikens inriktning', + 'Investerings- og innovationsmuligheder fra %t-politikkens retning', + 'Investerings- og innovasjonsmuligheter fra %t-politikkens retning', + 'Investointi- ja innovaatiomahdollisuudet %t-politiikan suunnasta', + 'Investitions- und Innovationschancen durch die %t-Politikrichtung', + 'Opportunités d\'investissement et d\'innovation issues de la politique %t', + 'Oportunidades de inversión e innovación derivadas de la política %t', + 'Investerings- en innovatiemogelijkheden door de %t-beleidsrichting', + 'فرص الاستثمار والابتكار من اتجاه سياسة %t', + 'הזדמנויות השקעה וחדשנות מכיוון המדיניות %t', + '%t政策方向からの投資とイノベーションの機会', + '%t 정책 방향으로부터의 투자 및 혁신 기회', + '%t政策方向带来的投资和创新机会', +); + +const PRIVATE_THREAT_UNCERTAINTY: Lang14 = L14( + 'Regulatory uncertainty and rapid policy evolution in %t creating stakeholder risk', + 'Regulatorisk osäkerhet och snabb policyutveckling inom %t skapar intressentrisk', + 'Regulatorisk usikkerhed og hurtig politikudvikling inden for %t skaber interessentrisiko', + 'Regulatorisk usikkerhet og rask policyutvikling innen %t skaper interessentrisiko', + 'Sääntelyllinen epävarmuus ja nopea politiikan kehitys %t:ssä luo sidosryhmäriskiä', + 'Regulatorische Unsicherheit und rasche Politikentwicklung in %t schaffen Stakeholder-Risiken', + 'Incertitude réglementaire et évolution rapide des politiques dans %t créant des risques pour les parties prenantes', + 'Incertidumbre regulatoria y evolución rápida de políticas en %t generando riesgo para las partes interesadas', + 'Regelgevende onzekerheid en snelle beleidsontwikkeling in %t creëren stakeholderrisico\'s', + 'الغموض التنظيمي والتطور السريع في السياسات في %t مما يخلق مخاطر لأصحاب المصلحة', + 'אי-ודאות רגולטורית ושינוי מדיניות מהיר ב-%t יוצרים סיכון לבעלי עניין', + '%tにおける規制の不確実性と急速な政策変化がステークホルダーリスクを生む', + '%t에서의 규제 불확실성과 빠른 정책 변화가 이해관계자 위험 초래', + '%t中的监管不确定性和快速政策演变为利益相关者带来风险', +); + +// ── Strategic implications (all 14 languages) ──────────────────────────────── +// NOTE: Templates use count-only placeholders (%prop, %bet, %mot) and full +// language-correct word forms — no suffix-based plural placeholders (%ps/%bs/%ms). +// This avoids incorrect forms like "betänkandeer" in Swedish or +// "proposiciones" → "proposicioness" in Spanish. + +const STRATEGIC_IMPL_TEMPLATES: Record = { + legislative: L14( + 'Based on analysis of %total documents (%enriched enriched with full text)%topic: The legislative pipeline shows %prop government proposals, %bet committee reports, and %mot opposition motions. This distribution signals %signal%domain. Stakeholders should monitor committee deliberations and chamber voting patterns as the most reliable indicators of policy trajectory.', + 'Baserat på analys av %total dokument (%enriched berikade med fulltext)%topic: Det lagstiftande flödet visar %prop propositioner, %bet betänkanden och %mot motioner. Fördelningen signalerar %signal%domain. Intressenter bör följa utskottens överläggningar och kammarens voteringsmönster.', + 'Baseret på analyse af %total dokumenter (%enriched beriget med fuldtekst)%topic: Det lovgivningsmæssige flow viser %prop lovforslag, %bet udvalgsrapporter og %mot oppositionsforslag. Fordelingen signalerer %signal%domain.', + 'Basert på analyse av %total dokumenter (%enriched beriket med fulltekst)%topic: Det lovgivningsmessige forløpet viser %prop stortingsproposisjoner, %bet komitérapporter og %mot motionsforslag. Fordelingen signaliserer %signal%domain.', + 'Perustuen %total asiakirjan analyysiin (%enriched rikastetussa koko tekstissä)%topic: Lainsäädäntöputki näyttää %prop hallituksen esitystä, %bet valiokuntamietintöä ja %mot kirjelmää. Jakauma merkitsee %signal%domain.', + 'Basierend auf der Analyse von %total Dokumenten (%enriched mit vollständigem Text angereichert)%topic: Die Gesetzgebungs-Pipeline zeigt %prop Regierungsvorlagen, %bet Ausschussberichte und %mot Oppositionsanträge. Diese Verteilung signalisiert %signal%domain.', + 'Basé sur l\'analyse de %total documents (%enriched enrichis avec le texte complet)%topic: La pipeline législative montre %prop propositions gouvernementales, %bet rapports de commission et %mot motions d\'opposition. Cette distribution signale %signal%domain.', + 'Basado en el análisis de %total documentos (%enriched enriquecidos con texto completo)%topic: La actividad legislativa muestra %prop proposiciones gubernamentales, %bet informes de comité y %mot mociones de oposición. Esta distribución señala %signal%domain.', + 'Gebaseerd op analyse van %total documenten (%enriched verrijkt met volledige tekst)%topic: De wetgevingspijplijn toont %prop overheidsvoorstellen, %bet commissierapporten en %mot oppositiemoties. Deze verdeling geeft aan: %signal%domain.', + 'استناداً إلى تحليل %total وثيقة (%enriched مُعززة بالنص الكامل)%topic: يُظهر المسار التشريعي %prop مقترحات حكومية، %bet تقارير لجان و%mot اقتراحات معارضة. يُشير هذا التوزيع إلى %signal%domain.', + 'בהתבסס על ניתוח %total מסמכים (%enriched מועשרים בטקסט מלא)%topic: הצינור החקיקתי מראה %prop הצעות חוק ממשלתיות, %bet דוחות ועדה ו-%mot הצעות חוק אופוזיציה. ההתפלגות מסמנת %signal%domain.', + '%total件の文書(%enriched件全文で充実)の分析に基づき%topic、立法パイプラインは%prop件の政府提案、%bet件の委員会報告、%mot件の野党動議を示しています。この分布は%domain%signalを示します。', + '%total개 문서(%enriched개 전문 보강) 분석 기반%topic: 입법 파이프라인은 %prop개 정부 제안, %bet개 위원회 보고서, %mot개 야당 동의를 보여줍니다. 이 분포는 %domain%signal을 나타냅니다.', + '基于对%total份文件(%enriched份全文丰富)的分析%topic:立法管道显示%prop项政府提案、%bet份委员会报告和%mot项反对派动议。这一分布表明%domain%signal。', + ), + nonLegislative: L14( + 'Based on analysis of %total documents (%enriched enriched with full text)%topic: This analysis examines %typeDesc%domain. %signalText Stakeholders should track whether formal propositions or committee referrals follow.', + 'Baserat på analys av %total dokument (%enriched berikade med fulltext)%topic: Denna analys granskar %typeDesc%domain. %signalText Intressenter bör bevaka om formella propositioner eller utskottsremisser följer.', + 'Baseret på analyse af %total dokumenter (%enriched beriget med fuldtekst)%topic: Denne analyse undersøger %typeDesc%domain. %signalText', + 'Basert på analyse av %total dokumenter (%enriched beriket med fulltekst)%topic: Denne analysen undersøker %typeDesc%domain. %signalText', + 'Perustuen %total asiakirjan analyysiin (%enriched rikastetussa koko tekstissä)%topic: Tämä analyysi tarkastelee %typeDesc%domain. %signalText', + 'Basierend auf der Analyse von %total Dokumenten (%enriched mit vollständigem Text angereichert)%topic: Diese Analyse untersucht %typeDesc%domain. %signalText', + 'Basé sur l\'analyse de %total documents (%enriched enrichis avec le texte complet)%topic: Cette analyse examine %typeDesc%domain. %signalText', + 'Basado en el análisis de %total documentos (%enriched enriquecidos con texto completo)%topic: Este análisis examina %typeDesc%domain. %signalText', + 'Gebaseerd op analyse van %total documenten (%enriched verrijkt met volledige tekst)%topic: Deze analyse onderzoekt %typeDesc%domain. %signalText', + 'استناداً إلى تحليل %total وثيقة (%enriched مُعززة بالنص الكامل)%topic: تحلل هذه الدراسة %typeDesc%domain. %signalText', + 'בהתבסס על ניתוח %total מסמכים (%enriched מועשרים בטקסט מלא)%topic: ניתוח זה בוחן %typeDesc%domain. %signalText', + '%total件の文書(%enriched件全文で充実)の分析に基づき%topic、この分析は%domain%typeDescを検討します。%signalText', + '%total개 문서(%enriched개 전문 보강) 분석 기반%topic: 이 분석은 %domain%typeDesc를 검토합니다. %signalText', + '基于对%total份文件(%enriched份全文丰富)的分析%topic:本分析研究%domain%typeDesc。%signalText', + ), +}; + +// ── Localised signal phrases for strategic implications ────────────────────── + +const SIGNAL_GOVT_AGENDA: Lang14 = L14( + 'active government agenda-setting', + 'aktiv regeringsagendasättning', + 'aktiv regeringsdagsordenssætning', + 'aktiv regjeringsagendaoppsetting', + 'aktiivista hallituksen agendan asettamista', + 'aktive Regierungsagenda', + 'établissement actif de l\'agenda gouvernemental', + 'agenda gubernamental activa', + 'actieve regeringsagenda', + 'تحديد أجندة حكومية نشطة', + 'קביעת סדר יום ממשלתי פעיל', + '積極的な政府アジェンダの設定', + '적극적인 정부 의제 설정', + '积极的政府议程设定', +); + +const SIGNAL_PARL_SCRUTINY: Lang14 = L14( + 'strong parliamentary scrutiny', + 'stark parlamentarisk granskning', + 'stærk parlamentarisk kontrol', + 'sterk parlamentarisk kontroll', + 'vahvaa parlamentaarista valvontaa', + 'starke parlamentarische Kontrolle', + 'contrôle parlementaire fort', + 'fuerte escrutinio parlamentario', + 'sterk parlementair toezicht', + 'رقابة برلمانية قوية', + 'פיקוח פרלמנטרי חזק', + '強力な議会審査', + '강력한 의회 심사', + '强有力的议会审查', +); + +const SIGNAL_BALANCED: Lang14 = L14( + 'balanced legislative activity', + 'balanserad lagstiftningsverksamhet', + 'afbalanceret lovgivningsaktivitet', + 'balansert lovgivningsaktivitet', + 'tasapainoista lainsäädäntötoimintaa', + 'ausgewogene Gesetzgebungstätigkeit', + 'activité législative équilibrée', + 'actividad legislativa equilibrada', + 'evenwichtige wetgevingsactiviteit', + 'نشاط تشريعي متوازن', + 'פעילות חקיקה מאוזנת', + '均衡な立法活動', + '균형 잡힌 입법 활동', + '平衡的立法活动', +); + +const SIGNAL_PRESS: Lang14 = L14( + 'Government press communications signal policy priorities and upcoming legislative action.', + 'Regeringens presskommunikation signalerar policyprioriteter och kommande lagstiftningsåtgärder.', + 'Regeringens pressekommunikation signalerer politiske prioriteter og kommende lovgivningstiltag.', + 'Regjeringens pressekommunikasjon signaliserer politiske prioriteringer og kommende lovgivningstiltak.', + 'Hallituksen lehdistöviestintä viestii politiikan prioriteeteista ja tulevasta lainsäädännöstä.', + 'Regierungspressekommunikation signalisiert politische Prioritäten und bevorstehende Gesetzgebung.', + 'Les communications de presse du gouvernement signalent les priorités politiques et les actions législatives à venir.', + 'Las comunicaciones de prensa del gobierno señalan prioridades políticas y acciones legislativas próximas.', + 'Overheidspersberichten signaleren beleidsprioriteiten en aanstaande wetgevingsactie.', + 'بيانات الحكومة الصحفية تشير إلى أولويات السياسة والإجراءات التشريعية القادمة.', + 'תקשורת עיתונות ממשלתית מסמנת עדיפויות מדיניות ופעולות חקיקה קרובות.', + '政府の広報は政策の優先事項と今後の法案活動を示唆しています。', + '정부 언론 커뮤니케이션이 정책 우선순위와 향후 입법 활동을 알립니다.', + '政府新闻通信表明政策优先事项和即将进行的立法行动。', +); + +const SIGNAL_EXTERNAL: Lang14 = L14( + 'External references illuminate the policy landscape.', + 'Externa referenser belyser det politiska landskapet.', + 'Eksterne referencer belyser det politiske landskab.', + 'Eksterne referanser belyser det politiske landskapet.', + 'Ulkoiset viittaukset valaisevat politiikan maisemaa.', + 'Externe Referenzen erhellen die politische Landschaft.', + 'Les références externes éclairent le paysage politique.', + 'Las referencias externas iluminan el panorama político.', + 'Externe referenties werpen licht op het politieke landschap.', + 'المراجع الخارجية تسلط الضوء على المشهد السياسي.', + 'הפניות חיצוניות מאירות את הנוף הפוליטי.', + '外部参照が政策環境を明らかにします。', + '외부 참조가 정책 환경을 조명합니다.', + '外部参考资料阐明了政策格局。', +); + +// ── Localised non-legislative type descriptions ────────────────────────────── + +const TYPE_DESC_PRESS: Lang14 = L14( + 'press releases', 'pressmeddelanden', 'pressemeddelelser', 'pressemeldinger', + 'lehdistötiedotteita', 'Pressemitteilungen', 'communiqués de presse', 'comunicados de prensa', + 'persberichten', 'بيانات صحفية', 'הודעות לעיתונות', 'プレスリリース', '보도자료', '新闻稿', +); + +const TYPE_DESC_EXTERNAL: Lang14 = L14( + 'external references', 'externa referenser', 'eksterne referencer', 'eksterne referanser', + 'ulkoisia viittauksia', 'externe Referenzen', 'références externes', 'referencias externas', + 'externe referenties', 'مراجع خارجية', 'הפניות חיצוניות', '外部参照', '외부 참조', '外部参考', +); + +const TYPE_DESC_REGULATORY: Lang14 = L14( + 'regulatory and parliamentary documents', + 'reglerande och parlamentariska dokument', + 'regulatoriske og parlamentariske dokumenter', + 'regulatoriske og parlamentariske dokumenter', + 'sääntely- ja parlamentaarisia asiakirjoja', + 'Regulierungs- und Parlamentsdokumente', + 'documents réglementaires et parlementaires', + 'documentos regulatorios y parlamentarios', + 'regelgevende en parlementaire documenten', + 'وثائق تنظيمية وبرلمانية', + 'מסמכים רגולטוריים ופרלמנטריים', + '規制・議会文書', + '규제 및 의회 문서', + '监管和议会文件', +); + +const SIGNAL_SNAPSHOT: Lang14 = L14( + 'This snapshot provides a regulatory and institutional overview — monitor whether formal legislative proposals follow.', + 'Denna ögonblicksbild ger en regulatorisk och institutionell överblick — bevaka om formella lagförslag följer.', + 'Dette øjebliksbillede giver et regulatorisk og institutionelt overblik — følg om formelle lovforslag følger.', + 'Dette øyeblikksbildet gir en regulatorisk og institusjonell oversikt — følg med på om formelle lovforslag følger.', + 'Tämä tilannekuva antaa sääntely- ja institutionaalisen yleiskuvan — seuraa, seuraavatko viralliset lakiehdotukset.', + 'Dieser Überblick bietet eine regulatorische und institutionelle Übersicht — beobachten Sie, ob formelle Gesetzesvorschläge folgen.', + 'Cet aperçu offre une vue réglementaire et institutionnelle — surveillez si des propositions législatives formelles suivent.', + 'Esta instantánea proporciona una visión regulatoria e institucional — monitoree si siguen propuestas legislativas formales.', + 'Dit overzicht biedt een regelgevend en institutioneel beeld — volg of formele wetsvoorstellen volgen.', + 'توفر هذه اللقطة نظرة تنظيمية ومؤسسية — راقب ما إذا كانت ستتبعها مقترحات تشريعية رسمية.', + 'תמונת מצב זו מספקת סקירה רגולטורית ומוסדית — עקבו אחרי האם הצעות חוק פורמליות ייכנסו.', + 'このスナップショットは規制・制度の概要を提供します — 正式な法案提出が続くか注視してください。', + '이 스냅샷은 규제 및 제도적 개요를 제공합니다 — 공식 법률안 제출이 뒤따르는지 모니터링하십시오.', + '此快照提供监管和制度概览——关注是否有正式立法提案跟进。', +); + +// ── Key takeaway templates ──────────────────────────────────────────────────── + +const TAKEAWAY_PROP: Lang14 = L14( + 'Government has submitted %n legislative proposals on %t — active policy commitment', + 'Regeringen har lämnat %n lagstiftningsförslag om %t — aktivt politiskt engagemang', + 'Regeringen har fremsat %n lovforslag om %t — aktivt politisk engagement', + 'Regjeringen har fremmet %n lovforslag om %t — aktivt politisk engasjement', + 'Hallitus on antanut %n lakiesitystä %t:stä — aktiivinen poliittinen sitoutuminen', + 'Die Regierung hat %n Gesetzgebungsvorschläge zu %t eingebracht — aktives politisches Engagement', + 'Le gouvernement a soumis %n propositions législatives sur %t — engagement politique actif', + 'El gobierno ha presentado %n propuestas legislativas sobre %t — compromiso político activo', + 'De regering heeft %n wetsvoorstellen ingediend over %t — actief politiek engagement', + 'قدّمت الحكومة %n اقتراحات تشريعية بشأن %t — التزام سياسي نشط', + 'הממשלה הגישה %n הצעות חוק בנושא %t — מחויבות פוליטית פעילה', + '政府は%t について%n件の法案を提出 — 積極的な政策コミットメント', + '정부는 %t에 관한 %n건의 입법 제안서 제출 — 적극적 정책 공약', + '政府已提交%n项关于%t的立法提案——积极的政策承诺', +); + +const TAKEAWAY_BET: Lang14 = L14( + '%n committee reports %verb %t — parliamentary oversight engaged', + '%n betänkanden granskar %t — parlamentarisk tillsyn aktiverad', + '%n udvalgsrapporter undersøger %t — parlamentarisk kontrol aktiveret', + '%n komitérapporter undersøker %t — parlamentarisk kontroll engasjert', + '%n valiokuntamietintöä tarkastelee %t:tä — parlamentaarinen valvonta aktivoitu', + '%n Ausschussberichte prüfen %t — parlamentarische Kontrolle aktiv', + '%n rapports de commission %verb %t — contrôle parlementaire engagé', + '%n informes de comité %verb %t — supervisión parlamentaria activada', + '%n commissierapporten %verb %t — parlementaire controle actief', + '%n تقارير لجان تفحص %t — الرقابة البرلمانية مفعّلة', + '%n דוחות ועדה בוחנים %t — פיקוח פרלמנטרי פעיל', + '%n件の委員会報告書が%tを審査 — 議会監視が活性化', + '%n건의 위원회 보고서가 %t 심사 — 의회 감독 활성화', + '%n份委员会报告审查%t——议会监督已启动', +); + +const TAKEAWAY_MOT: Lang14 = L14( + '%n opposition motions %verb %t — cross-party debate active', + '%n oppositionsmotioner utmanar %t — debatt över partigränser pågår', + '%n oppositionsmotioner udfordrer %t — tværpartipolitisk debat aktiv', + '%n opposisjonsmotioner utfordrer %t — tverr-partipolitisk debatt aktiv', + '%n oppositiokirjelmää haastaa %t — puolueidenvälinen debatti aktiivinen', + '%n Oppositionsanträge fechten %t an — parteiübergreifende Debatte aktiv', + '%n motions d\'opposition %verb %t — débat interpartis actif', + '%n mociones de oposición %verb %t — debate entre partidos activo', + '%n oppositiemoties %verb %t — overpartijdebat actief', + '%n اقتراحات معارضة تطعن في %t — النقاش عبر الأحزاب نشط', + '%n הצעות אופוזיציה מתמודדות עם %t — דיון בין-מפלגתי פעיל', + '%n件の野党動議が%tに異議申し立て — 超党派討論が活発', + '%n건의 야당 동의가 %t에 이의 제기 — 초당파 논쟁 활성화', + '%n项反对派动议对%t提出异议——跨党派辩论活跃', +); + +const TAKEAWAY_EU: Lang14 = L14( + '%n EU alignment documents — international context framing %t', + '%n EU-anpassningsdokument — internationellt sammanhang ramas in kring %t', + '%n EU-tilpasningsdokumenter — international kontekst rammer %t', + '%n EU-tilpasningsdokumenter — internasjonalt kontekst rammer %t', + '%n EU-yhdenmukaistamisasiakirjaa — kansainvälinen konteksti kehystää %t:n', + '%n EU-Ausrichtungsdokumente — internationale Kontextrahmung für %t', + '%n documents d\'alignement UE — contexte international encadrant %t', + '%n documentos de alineación UE — contexto internacional enmarcando %t', + '%n EU-afstemmingsdocumenten — internationale contextkaders %t', + '%n وثائق توافق أوروبية — السياق الدولي يؤطر %t', + '%n מסמכי התאמה לאיחוד האירופי — הקשר בינ\'ל מסגר %t', + '%n件のEU整合文書 — 国際的文脈が%tをフレーミング', + '%n건의 EU 정합 문서 — 국제 맥락이 %t를 틀 지어', + '%n份EU对齐文件——国际背景框架%t', +); + +const TAKEAWAY_ENRICHED: Lang14 = L14( + '%n of %total documents enriched with full text — high analytical confidence', + '%n av %total dokument berikade med fulltext — hög analytisk tillförlitlighet', + '%n af %total dokumenter beriget med fuldtekst — høj analytisk tillid', + '%n av %total dokumenter beriket med fulltekst — høy analytisk tillit', + '%n / %total asiakirjasta rikastettu koko tekstillä — korkea analyyttinen luotettavuus', + '%n von %total Dokumenten mit vollständigem Text angereichert — hohe analytische Zuverlässigkeit', + '%n sur %total documents enrichis avec le texte complet — grande confiance analytique', + '%n de %total documentos enriquecidos con texto completo — alta confianza analítica', + '%n van %total documenten verrijkt met volledige tekst — hoge analytische betrouwbaarheid', + '%n من %total وثيقة مُعززة بالنص الكامل — ثقة تحليلية عالية', + '%n מתוך %total מסמכים מועשרים בטקסט מלא — אמון אנליטי גבוה', + '%total件中%n件のドキュメントが全文で充実 — 高い分析信頼性', + '%total개 중 %n개 문서 전문 보강 — 높은 분석 신뢰도', + '%total份中%n份文件全文丰富——高分析可信度', +); + +const TAKEAWAY_COALITION_STRESS: Lang14 = L14( + 'Coalition stress indicators present — opposition challenges to government proposals detected', + 'Koalitionsstressindikatorer påvisade — oppositionsutmaningar mot regeringsförslag detekterade', + 'Koalitionsstressindikatorer til stede — oppositionsudfordringer til regeringsforslag opdaget', + 'Koalisjonsstressindikatorer tilstede — opposisjonsutfordringer til regjeringsforslag oppdaget', + 'Koalition stressindikaattorit läsnä — opposition haasteet hallituksen esityksille havaittu', + 'Koalitionsstressindikatoren vorhanden — Oppositionsherausforderungen an Regierungsvorschläge erkannt', + 'Indicateurs de stress de coalition présents — défis de l\'opposition aux propositions gouvernementales détectés', + 'Indicadores de tensión de coalición presentes — desafíos de la oposición a las propuestas del gobierno detectados', + 'Coalitie-stress-indicatoren aanwezig — oppositie-uitdagingen aan overheidsvoorstellen gedetecteerd', + 'مؤشرات ضغط التحالف موجودة — تحديات المعارضة للمقترحات الحكومية مرصودة', + 'מחווני לחץ קואליציוני קיימים — אתגרי אופוזיציה להצעות הממשלה זוהו', + '連立ストレス指標が存在 — 政府提案への野党の異議申し立てを検出', + '연립 스트레스 지표 존재 — 정부 제안에 대한 야당 이의 제기 감지', + '联盟压力指标存在——检测到反对派对政府提案的挑战', +); + +// --------------------------------------------------------------------------- +// Interpolation helpers +// --------------------------------------------------------------------------- + +function interp(template: string, vars: Record): string { + return template.replace(/%(\w+)/g, (_, key) => String(vars[key] ?? '')); +} + +function plural(n: number, lang: Language): string { + // Most supported languages use 's' as plural marker in EN-derived templates. + // For the handful that don't, handle individually. + if (lang === 'sv') return n !== 1 ? 'er' : ''; + if (lang === 'de') return n !== 1 ? 'e' : ''; + if (lang === 'fr') return n !== 1 ? 's' : ''; + if (lang === 'nl') return n !== 1 ? 'en' : ''; + // ja, ko, zh, ar, he — no plural suffix needed + if (['ja', 'ko', 'zh', 'ar', 'he'].includes(lang)) return ''; + // da, no, fi — use 'er' / 'r' style, but templates already handle this in + // most cases; default to 's' for EN-like languages. + return n !== 1 ? 's' : ''; +} + +/** Full verb form for TAKEAWAY_BET templates (committee reports "scrutinise"/"examine"). */ +function betVerbForm(n: number, lang: Language): string { + if (lang === 'en') return n === 1 ? 'scrutinises' : 'scrutinise'; + if (lang === 'fr') return n === 1 ? 'examine' : 'examinent'; + if (lang === 'es') return n === 1 ? 'examina' : 'examinan'; + if (lang === 'nl') return n === 1 ? 'onderzoekt' : 'onderzoeken'; + return ''; // Other languages use fixed verb forms in templates +} + +/** Full verb form for TAKEAWAY_MOT templates (opposition motions "challenge"/"contest"). */ +function motVerbForm(n: number, lang: Language): string { + if (lang === 'en') return n === 1 ? 'challenges' : 'challenge'; + if (lang === 'fr') return n === 1 ? 'conteste' : 'contestent'; + if (lang === 'es') return n === 1 ? 'impugna' : 'impugnan'; + if (lang === 'nl') return n === 1 ? 'betwist' : 'betwisten'; + return ''; // Other languages use fixed verb forms in templates +} + +// --------------------------------------------------------------------------- +// Document classification result (Pass 1 output) +// --------------------------------------------------------------------------- + +interface ClassifiedDocuments { + propDocs: RawDocument[]; + betDocs: RawDocument[]; + motDocs: RawDocument[]; + skrDocs: RawDocument[]; + sfsDocs: RawDocument[]; + euDocs: RawDocument[]; + pressmDocs: RawDocument[]; + extDocs: RawDocument[]; + otherDocs: RawDocument[]; + allDomains: string[]; + hasCoalitionStress: boolean; + enrichedCount: number; +} + +// --------------------------------------------------------------------------- +// AIAnalysisPipeline +// --------------------------------------------------------------------------- + +/** + * Heuristic-based multi-iteration analysis pipeline for deep political intelligence. + * + * Instantiate once per deep-inspection run; call analyze() to execute all passes. + * The number of iterations gates which passes run: + * - 1 iteration: Pass 1 only (classification + templated SWOT/implications/takeaways) + * - 2 iterations: Passes 1–3 (adds per-document analysis + cross-document synthesis) + * - 3+ iterations (default): Passes 1–4 (adds QA scoring with refinement on failure) + */ +export class AIAnalysisPipeline { + private readonly iterations: number; + private readonly qualityThreshold: number; + + constructor(options: { iterations?: number; qualityThreshold?: number } = {}) { + this.iterations = Math.min(10, Math.max(1, Math.floor(options.iterations ?? 3))); + this.qualityThreshold = options.qualityThreshold ?? QUALITY_THRESHOLD; + } + + // ── Public entry point ──────────────────────────────────────────────────── + + /** + * Execute the full multi-iteration analysis pipeline and return results. + * + * @param documents - Raw documents to analyse + * @param focusTopic - Optional focus topic constraining the analysis + * @param lang - Target language for all generated text + * @returns Complete analysis result including dynamic SWOT entries + */ + analyze( + documents: RawDocument[], + focusTopic: string | null, + lang: Language, + ): AIAnalysisResult { + // Early return for empty input — no meaningful analysis can be produced. + if (documents.length === 0) { + const empty: DynamicSwotEntries = { + government: { strengths: [], weaknesses: [], opportunities: [], threats: [] }, + opposition: { strengths: [], weaknesses: [], opportunities: [], threats: [] }, + privateSector: { strengths: [], weaknesses: [], opportunities: [], threats: [] }, + }; + return { + iterations: this.iterations, + documentAnalyses: [], + synthesis: this.createEmptySynthesis(), + dynamicSwotEntries: empty, + strategicImplications: '', + keyTakeaways: [], + analysisScore: 0, + }; + } + + const normalizedFocusTopic = focusTopic?.trim() || null; + + // Pass 1 (always): classify documents + const classified = this.classifyDocuments(documents, lang); + + // Pass 2 (iterations >= 2): per-document deep analysis + const documentAnalyses = this.iterations >= 2 + ? this.analyzeDocumentsDeep(classified, normalizedFocusTopic, lang) + : documents.map(d => this.createMinimalDocumentAnalysis(d)); + + // Pass 3 (iterations >= 2): cross-document synthesis + let synthesis = this.iterations >= 2 + ? this.synthesizeAcrossDocuments(classified, documentAnalyses, normalizedFocusTopic, lang) + : this.createEmptySynthesis(); + + // Build dynamic SWOT (always — uses classification data from Pass 1) + const dynamicSwotEntries = this.buildDynamicSwot(classified, normalizedFocusTopic, lang); + + // Build strategic implications (always) + const strategicImplications = this.buildStrategicImplications( + classified, normalizedFocusTopic, lang, + ); + + // Build key takeaways (always) + const keyTakeaways = this.buildKeyTakeaways(classified, normalizedFocusTopic, lang); + + // Pass 4 (iterations >= 3): QA + refinement + // When quality is below threshold, re-run synthesis and keep the better score. + // NOTE: With deterministic heuristics the refined output is identical to the + // original, so the score stays the same. The path is kept as a future extension + // point for non-deterministic analysis (e.g. when LLM integration is added). + let analysisScore = this.scoreAnalysis(documentAnalyses, synthesis, dynamicSwotEntries); + if (this.iterations >= 3 && analysisScore < this.qualityThreshold) { + const refinedSynthesis = this.synthesizeAcrossDocuments( + classified, documentAnalyses, normalizedFocusTopic, lang, + ); + const refinedScore = this.scoreAnalysis(documentAnalyses, refinedSynthesis, dynamicSwotEntries); + // Take the better of the two scores — no additive inflation. + if (refinedScore >= analysisScore) { + analysisScore = refinedScore; + synthesis = refinedSynthesis; + } + } + + return { + iterations: this.iterations, + documentAnalyses, + synthesis, + dynamicSwotEntries, + strategicImplications, + keyTakeaways, + analysisScore, + }; + } + + // ── Stub helpers for iterations=1 (skip Passes 2–3) ─────────────────────── + + /** Return a minimal document analysis when Pass 2 is skipped (iterations=1). */ + private createMinimalDocumentAnalysis(d: RawDocument): AIDocumentAnalysis { + return { + dok_id: this.buildAnalysisDocId(d), + title: docTitle(d), + legislativeImpact: '', + crossPartyImplications: '', + historicalContext: '', + euNordicComparison: '', + analysisScore: 0, + }; + } + + private buildAnalysisDocId(doc: RawDocument): string { + if (doc.dok_id) return doc.dok_id; + const titleFallback = docTitle(doc).slice(0, 20); + if (titleFallback) return titleFallback; + return `${docType(doc)}:${(doc.datum ?? '').slice(0, 10)}`; + } + + /** Return an empty synthesis when Pass 3 is skipped (iterations=1). */ + private createEmptySynthesis(): AISynthesis { + return { + policyConvergence: '', + coalitionStressIndicators: '', + emergingTrends: '', + stakeholderPowerDynamics: '', + }; + } + + // ── Pass 1: Data Collection & Classification ───────────────────────────── + + private classifyDocuments(docs: RawDocument[], lang: Language): ClassifiedDocuments { + const propDocs = docs.filter(d => docType(d) === 'prop'); + const betDocs = docs.filter(d => docType(d) === 'bet'); + const motDocs = docs.filter(d => docType(d) === 'mot'); + const skrDocs = docs.filter(d => docType(d) === 'skr'); + const sfsDocs = docs.filter(d => + docType(d) === 'sfs' || (d.dokumentnamn ?? '').startsWith('SFS')); + const euDocs = docs.filter(d => EU_TYPES.has(docType(d))); + const pressmDocs = docs.filter(d => docType(d) === 'pressm'); + const extDocs = docs.filter(d => EXT_TYPES.has(docType(d))); + const otherDocs = docs.filter(d => + !['prop','bet','mot','skr','sfs','fpm','eu','pressm','ext','external'].includes(docType(d)) + && !(d.dokumentnamn ?? '').startsWith('SFS')); + + const domainSet = new Set(); + docs.forEach(d => detectPolicyDomains(d, lang).forEach(dom => domainSet.add(dom))); + + const hasStress = docs.some(d => hasCoalitionStress(d)); + // Count metadata-enriched docs via contentFetched — consistent with the + // rest of the codebase (MCPClient sets contentFetched:true without + // necessarily populating fullText/fullContent). + const enrichedCount = docs.filter(d => !!d.contentFetched).length; + + return { + propDocs, betDocs, motDocs, skrDocs, sfsDocs, euDocs, + pressmDocs, extDocs, otherDocs, + allDomains: [...domainSet], + hasCoalitionStress: hasStress, + enrichedCount, + }; + } + + // ── Pass 2: AI Deep Analysis per document ───────────────────────────────── + + private analyzeDocumentsDeep( + classified: ClassifiedDocuments, + focusTopic: string | null, + lang: Language, + ): AIDocumentAnalysis[] { + const allDocs = [ + ...classified.propDocs, + ...classified.betDocs, + ...classified.motDocs, + ...classified.sfsDocs, + ...classified.skrDocs, + ...classified.euDocs, + ...classified.pressmDocs, + ...classified.extDocs, + ...classified.otherDocs, + ]; + + return allDocs.map(doc => this.analyzeDocument(doc, focusTopic, classified, lang)); + } + + private analyzeDocument( + doc: RawDocument, + focusTopic: string | null, + classified: ClassifiedDocuments, + lang: Language, + ): AIDocumentAnalysis { + const title = docTitle(doc); + const stake = classifyStakeholder(doc); + const domains = detectPolicyDomains(doc, lang); + const topDomain = domains[0] ?? classified.allDomains[0] ?? (focusTopic ?? 'policy'); + const topicStr = focusTopic ?? topDomain; + + // Legislative impact — based on document type + const legislativeImpact = this.buildLegislativeImpact(doc, stake, topicStr, lang); + + // Cross-party implications + const crossPartyImplications = this.buildCrossPartyImplications(doc, classified, topicStr, lang); + + // Historical context — thin since we have no historical corpus; use enriched text if available + const historicalContext = this.buildHistoricalContext(doc, topicStr, lang); + + // EU/Nordic comparison + const euNordicComparison = this.buildEuNordicComparison(doc, classified, topicStr, lang); + + const analysisText = [legislativeImpact, crossPartyImplications, historicalContext, euNordicComparison].join(' '); + const analysisScore = scoreAnalysisDepth(analysisText); + + return { + dok_id: this.buildAnalysisDocId(doc), + title, + legislativeImpact, + crossPartyImplications, + historicalContext, + euNordicComparison, + analysisScore, + }; + } + + private buildLegislativeImpact( + doc: RawDocument, + stake: 'government' | 'opposition' | 'eu' | 'other', + topicStr: string, + lang: Language, + ): string { + if (stake === 'government') { + return `${pickLang(LEGISLATIVE_SIGNAL, lang)} ${docTitle(doc).slice(0, 80)} — ${topicStr}`; + } + if (stake === 'opposition') { + return `${pickLang(SCRUTINY_SIGNAL, lang)} ${docTitle(doc).slice(0, 80)} — ${topicStr}`; + } + if (stake === 'eu') { + return `${pickLang(EU_ALIGNMENT_SIGNAL, lang)} ${docTitle(doc).slice(0, 80)} — ${topicStr}`; + } + return `${docTitle(doc).slice(0, 80)} — ${topicStr}`; + } + + private buildCrossPartyImplications( + doc: RawDocument, + classified: ClassifiedDocuments, + topicStr: string, + lang: Language, + ): string { + const stake = classifyStakeholder(doc); + if (classified.hasCoalitionStress && stake === 'opposition') { + return pickLang(TAKEAWAY_COALITION_STRESS, lang); + } + if (classified.propDocs.length > 0 && classified.motDocs.length > 0) { + return pickLang(OPP_OPPORTUNITY_CONSENSUS, lang).replace('%t', topicStr); + } + return ''; + } + + private buildHistoricalContext( + doc: RawDocument, + topicStr: string, + _lang: Language, + ): string { + // Use enriched content snippet when available (fullText ?? fullContent per codebase convention) + const contentSnippet = (doc.fullText ?? doc.fullContent ?? doc.summary ?? doc.notis ?? ''); + if (contentSnippet && contentSnippet.length > 50) { + return contentSnippet.slice(0, 200).replace(/\s+/g, ' '); + } + // Fallback: synthesise from document fields + const year = (doc.datum ?? '').slice(0, 4); + if (year && year.length === 4) { + return `${topicStr} — ${year}`; + } + return ''; + } + + private buildEuNordicComparison( + doc: RawDocument, + classified: ClassifiedDocuments, + topicStr: string, + lang: Language, + ): string { + if (classified.euDocs.length > 0 || EU_TYPES.has(docType(doc))) { + return pickLang(EU_OPPORTUNITY, lang).replace('%t', topicStr); + } + return ''; + } + + // ── Pass 3: Cross-Document Synthesis ────────────────────────────────────── + + private synthesizeAcrossDocuments( + classified: ClassifiedDocuments, + docAnalyses: AIDocumentAnalysis[], + focusTopic: string | null, + lang: Language, + ): AISynthesis { + const topicStr = focusTopic ?? classified.allDomains[0] ?? 'policy'; + const n = classified.propDocs.length + classified.betDocs.length + classified.motDocs.length; + + // Policy convergence/divergence + const converging = classified.propDocs.length > 0 && classified.betDocs.length > 0; + const diverging = classified.motDocs.length > classified.propDocs.length; + const policyConvergence = converging + ? interp(pickLang(GOV_STRENGTH_LABELS.propositions, lang), { + n: classified.propDocs.length, + s: plural(classified.propDocs.length, lang), + t: topicStr, + }) + : diverging + ? interp(pickLang(OPP_STRENGTH_LABELS.motions, lang), { + n: classified.motDocs.length, + s: plural(classified.motDocs.length, lang), + t: topicStr, + }) + : `${topicStr} — ${n}`; + + // Coalition stress indicators + const coalitionStressIndicators = classified.hasCoalitionStress + ? pickLang(TAKEAWAY_COALITION_STRESS, lang) + : ''; + + // Emerging trends + const avgQuality = docAnalyses.length > 0 + ? docAnalyses.reduce((sum, a) => sum + a.analysisScore, 0) / docAnalyses.length + : 0; + const trendConfidence = avgQuality >= 60 ? 'HIGH' : avgQuality >= 35 ? 'MEDIUM' : 'LOW'; + const domainList = classified.allDomains.slice(0, 3).join(', '); + const emergingTrends = domainList + ? `${domainList} [${trendConfidence}]` + : ''; + + // Stakeholder power dynamics + const govDocs = classified.propDocs.length + classified.sfsDocs.length + classified.pressmDocs.length; + const oppDocs = classified.betDocs.length + classified.motDocs.length; + const stakeholderPowerDynamics = govDocs > oppDocs + ? interp(pickLang(GOV_STRENGTH_LABELS.default, lang), { t: topicStr }) + : oppDocs > 0 + ? interp(pickLang(OPP_STRENGTH_LABELS.default, lang), { t: topicStr }) + : topicStr; + + return { policyConvergence, coalitionStressIndicators, emergingTrends, stakeholderPowerDynamics }; + } + + // ── Dynamic SWOT generation ──────────────────────────────────────────────── + + private buildDynamicSwot( + classified: ClassifiedDocuments, + focusTopic: string | null, + lang: Language, + ): DynamicSwotEntries { + const topic = focusTopic ?? classified.allDomains[0] ?? 'policy'; + const { + propDocs, betDocs, motDocs, sfsDocs, skrDocs, euDocs, pressmDocs, extDocs, + } = classified; + + const titleEntry = (d: RawDocument, impact: SwotEntry['impact'] = 'medium'): SwotEntry => ({ + text: docTitle(d), + impact, + }); + + // ── Government SWOT ────────────────────────────────────────────────────── + const govStrengths: SwotEntry[] = [ + ...propDocs.slice(0, 3).map(d => titleEntry(d, 'high')), + ...sfsDocs.slice(0, 2).map(d => titleEntry(d, 'high')), + ...skrDocs.slice(0, 1).map(d => titleEntry(d, 'medium')), + ...pressmDocs.slice(0, 2).map(d => titleEntry(d, 'high')), + ]; + if (govStrengths.length === 0) { + govStrengths.push({ + text: interp(pickLang(GOV_STRENGTH_LABELS.default, lang), { n: 0, s: plural(0, lang), t: topic }), + impact: 'medium', + }); + } + + const govWeaknesses: SwotEntry[] = [ + ...betDocs.slice(0, 2).map(d => titleEntry(d, 'medium')), + ]; + if (govWeaknesses.length === 0) { + govWeaknesses.push({ + text: interp(pickLang(GOV_WEAKNESS_IMPL, lang), { t: topic }), + impact: 'medium', + }); + } + + const govOpportunities: SwotEntry[] = [ + ...euDocs.slice(0, 2).map(d => titleEntry(d, 'high')), + ...skrDocs.slice(1, 2).map(d => titleEntry(d, 'medium')), + ]; + if (govOpportunities.length === 0) { + govOpportunities.push({ + text: interp(pickLang(EU_OPPORTUNITY, lang), { t: topic }), + impact: 'high', + }); + } + + const govThreats: SwotEntry[] = [ + ...motDocs.slice(0, 2).map(d => titleEntry(d, 'medium')), + ]; + if (govThreats.length === 0) { + govThreats.push({ + text: interp(pickLang(GOV_THREAT_EXEC, lang), { t: topic }), + impact: 'medium', + }); + } + + // ── Opposition SWOT ────────────────────────────────────────────────────── + const oppStrengths: SwotEntry[] = [ + ...betDocs.slice(0, 3).map(d => titleEntry(d, 'high')), + ...motDocs.slice(0, 2).map(d => titleEntry(d, 'medium')), + ]; + if (oppStrengths.length === 0) { + oppStrengths.push({ + text: interp(pickLang( + betDocs.length > 0 ? OPP_STRENGTH_LABELS.committee : OPP_STRENGTH_LABELS.default, + lang, + ), { n: betDocs.length, s: plural(betDocs.length, lang), t: topic }), + impact: 'high', + }); + } + + const oppWeaknesses: SwotEntry[] = []; + oppWeaknesses.push({ + text: interp(pickLang(OPP_WEAKNESS_INFO, lang), { t: topic }), + impact: 'medium', + }); + + const oppOpportunities: SwotEntry[] = []; + oppOpportunities.push({ + text: interp(pickLang(OPP_OPPORTUNITY_CONSENSUS, lang), { t: topic }), + impact: 'high', + }); + + const oppThreats: SwotEntry[] = [ + ...propDocs.slice(0, 1).map(d => titleEntry(d, 'medium')), + ]; + if (oppThreats.length === 0) { + oppThreats.push({ + text: interp(pickLang(OPP_THREAT_MAJORITY, lang), { t: topic }), + impact: 'medium', + }); + } + + // ── Private Sector / Civil Society SWOT ───────────────────────────────── + const privateStrengths: SwotEntry[] = [ + { + text: interp(pickLang(PRIVATE_STRENGTH_DOMAIN, lang), { t: topic }), + impact: 'high', + }, + ...sfsDocs.slice(0, 1).map(d => titleEntry(d, 'medium')), + ...extDocs.slice(0, 2).map(d => titleEntry(d, 'high')), + ]; + + const privateWeaknesses: SwotEntry[] = [ + { + text: interp(pickLang(PRIVATE_WEAKNESS_COMPLIANCE, lang), { t: topic }), + impact: 'medium', + }, + ]; + + const privateOpportunities: SwotEntry[] = [ + { + text: interp(pickLang(PRIVATE_OPPORTUNITY_INVESTMENT, lang), { t: topic }), + impact: 'high', + }, + ...euDocs.slice(0, 1).map(d => titleEntry(d, 'high')), + ]; + + const privateThreats: SwotEntry[] = [ + { + text: interp(pickLang(PRIVATE_THREAT_UNCERTAINTY, lang), { t: topic }), + impact: 'high', + }, + ]; + + return { + government: { + strengths: govStrengths, + weaknesses: govWeaknesses, + opportunities: govOpportunities, + threats: govThreats, + }, + opposition: { + strengths: oppStrengths, + weaknesses: oppWeaknesses, + opportunities: oppOpportunities, + threats: oppThreats, + }, + privateSector: { + strengths: privateStrengths, + weaknesses: privateWeaknesses, + opportunities: privateOpportunities, + threats: privateThreats, + }, + }; + } + + // ── Strategic implications ──────────────────────────────────────────────── + + private buildStrategicImplications( + classified: ClassifiedDocuments, + focusTopic: string | null, + lang: Language, + ): string { + const esc = escapeHtml; + const topic = focusTopic ?? classified.allDomains[0] ?? ''; + const { propDocs, betDocs, motDocs, pressmDocs, extDocs, enrichedCount, allDomains } = classified; + const total = propDocs.length + betDocs.length + motDocs.length + + pressmDocs.length + extDocs.length + classified.sfsDocs.length + + classified.skrDocs.length + classified.euDocs.length + classified.otherDocs.length; + const legislativeCount = propDocs.length + betDocs.length + motDocs.length; + const isLegislative = legislativeCount > 0; + const domainPhrase = allDomains.slice(0, 3).map(d => esc(d)).join(', '); + const topicInsert = topic ? ` (${esc(topic)})` : ''; + const domainInsert = domainPhrase ? ` — ${domainPhrase}` : ''; + + if (isLegislative) { + // Use fully localized signal phrase + const signalPhrase = propDocs.length > betDocs.length + ? pickLang(SIGNAL_GOVT_AGENDA, lang) + : betDocs.length > propDocs.length + ? pickLang(SIGNAL_PARL_SCRUTINY, lang) + : pickLang(SIGNAL_BALANCED, lang); + const template = pickLang(STRATEGIC_IMPL_TEMPLATES.legislative, lang); + return `

${interp(template, { + total, + enriched: enrichedCount, + topic: topicInsert, + prop: propDocs.length, + bet: betDocs.length, + mot: motDocs.length, + signal: esc(signalPhrase), + domain: domainInsert, + })}

`; + } + + // Non-legislative documents — branch on press/ext vs regulatory/snapshot + const hasPressOrExt = pressmDocs.length > 0 || extDocs.length > 0; + + if (hasPressOrExt) { + const typeDesc = esc(pressmDocs.length > 0 + ? `${pressmDocs.length} ${pickLang(TYPE_DESC_PRESS, lang)}` + : `${extDocs.length} ${pickLang(TYPE_DESC_EXTERNAL, lang)}`); + const signalText = pressmDocs.length > 0 + ? pickLang(SIGNAL_PRESS, lang) + : pickLang(SIGNAL_EXTERNAL, lang); + const template = pickLang(STRATEGIC_IMPL_TEMPLATES.nonLegislative, lang); + return `

${interp(template, { + total, + enriched: enrichedCount, + topic: topicInsert, + typeDesc, + domain: domainInsert, + signalText: esc(signalText), + })}

`; + } + + // Regulatory / snapshot — SFS, SKR, FPM, or other parliamentary documents only + const regulatoryCount = classified.sfsDocs.length + classified.skrDocs.length + + classified.euDocs.length + classified.otherDocs.length; + const typeDesc = esc(`${regulatoryCount} ${pickLang(TYPE_DESC_REGULATORY, lang)}`); + const signalText = pickLang(SIGNAL_SNAPSHOT, lang); + const template = pickLang(STRATEGIC_IMPL_TEMPLATES.nonLegislative, lang); + return `

${interp(template, { + total, + enriched: enrichedCount, + topic: topicInsert, + typeDesc, + domain: domainInsert, + signalText: esc(signalText), + })}

`; + } + + // ── Key takeaways ───────────────────────────────────────────────────────── + + private buildKeyTakeaways( + classified: ClassifiedDocuments, + focusTopic: string | null, + lang: Language, + ): string[] { + const topic = focusTopic ?? classified.allDomains[0] ?? 'policy'; + const { propDocs, betDocs, motDocs, euDocs, enrichedCount } = classified; + const total = propDocs.length + betDocs.length + motDocs.length + + classified.sfsDocs.length + classified.skrDocs.length + + classified.pressmDocs.length + classified.extDocs.length + + classified.euDocs.length + classified.otherDocs.length; + + const items: string[] = []; + + if (propDocs.length > 0) { + items.push(interp(pickLang(TAKEAWAY_PROP, lang), { + n: propDocs.length, t: topic, + })); + } + if (betDocs.length > 0) { + items.push(interp(pickLang(TAKEAWAY_BET, lang), { + n: betDocs.length, t: topic, + verb: betVerbForm(betDocs.length, lang), + })); + } + if (motDocs.length > 0) { + items.push(interp(pickLang(TAKEAWAY_MOT, lang), { + n: motDocs.length, t: topic, + verb: motVerbForm(motDocs.length, lang), + })); + } + if (euDocs.length > 0) { + items.push(interp(pickLang(TAKEAWAY_EU, lang), { + n: euDocs.length, t: topic, + })); + } + if (enrichedCount > 0 && enrichedCount >= Math.ceil(total / 2)) { + items.push(interp(pickLang(TAKEAWAY_ENRICHED, lang), { + n: enrichedCount, total, + })); + } + if (classified.hasCoalitionStress) { + items.push(pickLang(TAKEAWAY_COALITION_STRESS, lang)); + } + + return items; + } + + // ── Pass 4: Quality Scoring ─────────────────────────────────────────────── + + private scoreAnalysis( + docAnalyses: AIDocumentAnalysis[], + synthesis: AISynthesis, + swot: DynamicSwotEntries, + ): number { + let score = 0; + + // Document analysis quality (up to 40 points) + if (docAnalyses.length > 0) { + const avgDoc = docAnalyses.reduce((sum, a) => sum + a.analysisScore, 0) / docAnalyses.length; + score += Math.floor(avgDoc * 0.4); + } + + // Synthesis quality (up to 30 points) + const synthText = [ + synthesis.policyConvergence, + synthesis.coalitionStressIndicators, + synthesis.emergingTrends, + synthesis.stakeholderPowerDynamics, + ].join(' '); + score += Math.min(30, Math.floor(scoreAnalysisDepth(synthText) * 0.3)); + + // SWOT richness (up to 30 points) — count all 12 quadrants + const swotCount = + swot.government.strengths.length + swot.government.weaknesses.length + + swot.government.opportunities.length + swot.government.threats.length + + swot.opposition.strengths.length + swot.opposition.weaknesses.length + + swot.opposition.opportunities.length + swot.opposition.threats.length + + swot.privateSector.strengths.length + swot.privateSector.weaknesses.length + + swot.privateSector.opportunities.length + swot.privateSector.threats.length; + score += Math.min(30, swotCount * 3); + + return Math.min(100, score); + } +} diff --git a/scripts/generate-news-enhanced/analysis-cache.ts b/scripts/generate-news-enhanced/analysis-cache.ts new file mode 100644 index 0000000000..fe91c9238e --- /dev/null +++ b/scripts/generate-news-enhanced/analysis-cache.ts @@ -0,0 +1,142 @@ +/** + * @module generate-news-enhanced/analysis-cache + * @description In-memory cache for intermediate AI analysis results during + * multi-iteration news generation. Prevents redundant re-analysis within a + * single generation run and supports TTL-based expiry for stale results. + * + * @author Hack23 AB + * @license Apache-2.0 + */ + +import type { Language } from '../types/language.js'; +import type { RawDocument } from '../data-transformers.js'; +import type { AIAnalysisResult } from './ai-analysis-pipeline.js'; + +/** Default TTL for cached analysis results: 30 minutes */ +const DEFAULT_TTL_MS = 30 * 60 * 1000; + +/** Maximum number of entries the cache will hold before purging expired + oldest. */ +const MAX_CACHE_SIZE = 500; + +interface CacheEntry { + result: AIAnalysisResult; + createdAt: number; + ttlMs: number; +} + +/** + * Simple hash of a string using the FNV-1a algorithm (32-bit). + * The algorithm uses offset basis 0x811c9dc5 and prime 0x01000193. + * This is deterministic but NOT cryptographically secure — used only + * for generating cache keys. + */ +function quickHash(input: string): string { + let h = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + h ^= input.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return h.toString(36); +} + +/** + * Cache for AI analysis results. + * + * Thread-safety note: Node.js is single-threaded; concurrent async access is + * fine without additional locking. + */ +export class AnalysisCache { + private readonly store = new Map(); + + /** + * Generate a deterministic cache key from analysis inputs. + * + * @param docs - Documents being analysed + * @param topic - Focus topic, or null + * @param iterations - Number of analysis iterations + * @param lang - Target language + * @returns Cache key string + */ + generateKey( + docs: RawDocument[], + topic: string | null, + iterations: number, + lang: Language, + ): string { + // Use stable per-doc identifiers (order-independent via sort) to reduce collision risk. + // When dok_id is absent, include doktyp + datum alongside the title for uniqueness. + const docIds = docs + .map(d => d.dok_id ?? `${d.doktyp ?? ''}:${d.datum ?? ''}:${d.titel ?? d.title ?? ''}`) + .sort(); + const docPart = `${docIds.length}:${docIds.join(',')}`; + const raw = `${docPart}|${topic ?? ''}|${iterations}|${lang}`; + return quickHash(raw); + } + + /** + * Retrieve a cached analysis result if it exists and has not expired. + * + * @param key - Cache key from generateKey() + * @returns Cached result, or undefined if absent / expired + */ + get(key: string): AIAnalysisResult | undefined { + const entry = this.store.get(key); + if (!entry) return undefined; + if (Date.now() - entry.createdAt > entry.ttlMs) { + this.store.delete(key); + return undefined; + } + return entry.result; + } + + /** + * Store an analysis result in the cache. + * + * @param key - Cache key from generateKey() + * @param result - Analysis result to cache + * @param ttlMs - Time-to-live in milliseconds (default: 30 min) + */ + set(key: string, result: AIAnalysisResult, ttlMs: number = DEFAULT_TTL_MS): void { + this.store.set(key, { result, createdAt: Date.now(), ttlMs }); + // Opportunistically purge expired entries to prevent unbounded growth. + this.purgeExpired(); + } + + /** + * Remove all expired entries from the cache. + * Called opportunistically from `set()` to prevent memory growth in + * long-lived processes. Also enforces a maximum cache size. + */ + purgeExpired(): void { + const now = Date.now(); + for (const [k, entry] of this.store) { + if (now - entry.createdAt > entry.ttlMs) { + this.store.delete(k); + } + } + // If still over the cap after purging expired entries, evict oldest first. + if (this.store.size > MAX_CACHE_SIZE) { + const sorted = [...this.store.entries()].sort( + (a, b) => a[1].createdAt - b[1].createdAt, + ); + const toRemove = sorted.slice(0, this.store.size - MAX_CACHE_SIZE); + for (const [k] of toRemove) { + this.store.delete(k); + } + } + } + + /** Remove all entries from the cache. */ + clear(): void { + this.store.clear(); + } + + /** Number of live (non-expired) entries currently in the cache. */ + get size(): number { + this.purgeExpired(); + return this.store.size; + } +} + +/** Module-level singleton cache shared across all generators in a run. */ +export const sharedAnalysisCache = new AnalysisCache(); diff --git a/scripts/generate-news-enhanced/config.ts b/scripts/generate-news-enhanced/config.ts index b435755f83..c6bfae5fcf 100644 --- a/scripts/generate-news-enhanced/config.ts +++ b/scripts/generate-news-enhanced/config.ts @@ -65,6 +65,22 @@ export const documentUrls: string[] = rawDocumentUrls /** Specific policy topic to focus deep-inspection analysis on */ export const focusTopic: string = parseArgValue(focusTopicArg); +// --iterations=N: number of AI analysis iterations for deep-inspection (default: 3) +const iterationsArg: string | undefined = args.find(arg => arg.startsWith('--iterations=')); +const DEFAULT_ITERATIONS = 3; +let parsedIterations: number = DEFAULT_ITERATIONS; +if (iterationsArg) { + const rawIter: string = parseArgValue(iterationsArg); + const numIter: number = rawIter === '' ? NaN : Number(rawIter); + if (Number.isFinite(numIter)) { + // Clamp to 1–10; 0/negative values map to 1 rather than falling back to default + parsedIterations = Math.min(10, Math.max(1, Math.floor(numIter))); + } else { + console.warn(`Invalid --iterations value "${rawIter}", falling back to default ${DEFAULT_ITERATIONS}.`); + } +} +/** Number of AI analysis iterations for deep-inspection articles. Default: 3. */ +export const analysisIterations: number = parsedIterations; // --------------------------------------------------------------------------- // Analysis depth (controls number of AI analysis iterations) // --------------------------------------------------------------------------- diff --git a/scripts/generate-news-enhanced/generators.ts b/scripts/generate-news-enhanced/generators.ts index 7256ea32ba..50e27eefb5 100644 --- a/scripts/generate-news-enhanced/generators.ts +++ b/scripts/generate-news-enhanced/generators.ts @@ -7,9 +7,6 @@ * @license Apache-2.0 */ -import fs from 'node:fs'; -import path from 'node:path'; - import { transformCalendarToEventGrid, generateArticleContent, @@ -26,27 +23,28 @@ import { generateEconomicDashboardSection, generateMindmapSection, generateSankeySection, - type MindmapBranch, + buildAIMindmapAnalysis, + buildMindmapOptionsFromAnalysis, type SankeyNode, type SankeyFlow, - type StakeholderSwot, } from '../data-transformers/index.js'; -import { generateDeepAnalysisSection, localizeDocType } from '../data-transformers/content-generators/index.js'; +import { buildAISwotStakeholders, STAKEHOLDER_NAMES as AI_STAKEHOLDER_NAMES, generateDeepAnalysisSection, localizeDocType } from '../data-transformers/content-generators/index.js'; import { generateDeepPolicyAnalysis, detectPolicyDomains } from '../data-transformers/policy-analysis.js'; +import { analyzeDashboardData } from '../ai-analysis/dashboard-analyzer.js'; import { escapeHtml } from '../html-utils.js'; import { generateArticleHTML } from '../article-template.js'; import { MCPClient } from '../mcp-client.js'; import type { Language } from '../types/language.js'; -import type { GenerationResult, DateRange, ArticleCategory, TemplateSection, SwotEntry } from '../types/article.js'; +import type { GenerationResult, DateRange, ArticleCategory, TemplateSection } from '../types/article.js'; import type { TitleSet } from './types.js'; -import { languages, stats, getSharedClient, requireMcp, toISODate, documentIds, documentUrls, focusTopic, analysisDepth, METADATA_DIR } from './config.js'; -import { runAnalysisPipeline } from '../ai-analysis/pipeline.js'; -import type { AnalysisResult, AnalysisIterationMetadata } from '../ai-analysis/types.js'; +import { languages, stats, getSharedClient, requireMcp, toISODate, documentIds, documentUrls, focusTopic, analysisIterations } from './config.js'; import { getWeekAheadDateRange, formatDateForSlug, writeSingleArticle, } from './helpers.js'; +import { AIAnalysisPipeline } from './ai-analysis-pipeline.js'; +import { sharedAnalysisCache } from './analysis-cache.js'; // --------------------------------------------------------------------------- // Generator functions @@ -644,6 +642,11 @@ export function sanitizePlainText(text: string): string { // Deep-Inspection content generator (topic-focused, comprehensive) // --------------------------------------------------------------------------- +/** Cyberpunk-theme colour palette for deep-inspection dashboard charts. */ +const DEEP_CHART_PALETTE: readonly string[] = [ + '#00d9ff', '#ff006e', '#ffbe0b', '#00ff88', '#ff8800', '#aa00ff', +]; + /** Per-language headings for sections of the deep-inspection article. */ const DEEP_SECTION_LABELS: Readonly>>> = { documentIntelligence: { @@ -785,11 +788,14 @@ function docTypeLabel(doktyp: string, lang: Language, count?: number): string { * Generate topic-focused, comprehensive deep-inspection article content. * All sections are explicitly oriented around `topic`. Uses enriched full-text * content from each document and the 5W deep-analysis framework. + * When an AIAnalysisResult is supplied, its strategic implications and key + * takeaways replace the template-based versions for richer, context-aware output. */ function generateDeepInspectionContent( docs: RawDocument[], topic: string | null, lang: Language, + aiResult?: import('./ai-analysis-pipeline.js').AIAnalysisResult, ): string { const esc = escapeHtml; let html = ''; @@ -828,14 +834,29 @@ function generateDeepInspectionContent( const stratHeading = deepLabel('strategicImplications', lang); html += `\n
\n`; html += `

${esc(stratHeading)}

\n`; - html += ` ${buildStrategicImplications(docs, topic, lang)}\n`; + // Use AI-generated strategic implications when available (richer than template version). + // Safe to inject directly: AIAnalysisPipeline.buildStrategicImplications() escapes all + // dynamic values (topic, domain, signal) at construction time via escapeHtml(). The + // fallback buildStrategicImplications() in generators.ts also escapes its inputs. + const strategicImplHtml = aiResult?.strategicImplications + ?? buildStrategicImplications(docs, topic, lang); + html += ` ${strategicImplHtml}\n`; html += `
\n`; // ── 5. Key takeaways ─────────────────────────────────────────────────────── const takeawayHeading = deepLabel('keyTakeaways', lang); html += `\n
\n`; html += `

${esc(takeawayHeading)}

\n`; - html += buildKeyTakeaways(docs, topic, lang); + if (aiResult?.keyTakeaways && aiResult.keyTakeaways.length > 0) { + // Use AI-generated takeaways + html += `
    \n`; + aiResult.keyTakeaways.forEach(item => { + html += `
  • ${esc(item)}
  • \n`; + }); + html += `
\n`; + } else { + html += buildKeyTakeaways(docs, topic, lang); + } html += `
\n`; return html; @@ -959,7 +980,7 @@ function buildStrategicImplications(docs: RawDocument[], topic: string | null, l // Detect all policy domains across documents for richer context const allDomains = new Set(); docs.forEach(d => detectPolicyDomains(d, lang).forEach(dom => allDomains.add(dom))); - const domainPhrase = allDomains.size > 0 ? [...allDomains].slice(0, 3).join(', ') : ''; + const domainPhrase = allDomains.size > 0 ? [...allDomains].slice(0, 3).map(d => esc(d)).join(', ') : ''; // Choose a template style based on document composition const isLegislativeFocused = legislativeCount > 0; @@ -1085,156 +1106,32 @@ function buildKeyTakeaways(docs: RawDocument[], topic: string | null, lang: Lang // --------------------------------------------------------------------------- /** - * Write AI analysis iteration metadata to news/metadata/ for audit trail. - * Write errors are non-fatal (logged via console.warn) to avoid breaking article generation. + * Compute the effective document type for a RawDocument. + * SFS-by-name docs (missing doktyp/documentType but dokumentnamn starting with "SFS") + * are normalised to 'sfs' so filters, typeCounts, and chart labels stay consistent. */ -function writeAnalysisMetadata( - articleSlug: string, - metadata: AnalysisIterationMetadata, -): void { - try { - fs.mkdirSync(METADATA_DIR, { recursive: true }); - const filename = path.join(METADATA_DIR, `ai-analysis-${articleSlug}-${metadata.lang}.json`); - fs.writeFileSync(filename, JSON.stringify(metadata, null, 2), 'utf-8'); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - console.warn(` ⚠️ Could not write analysis metadata: ${msg}`); - } +function effectiveType(d: RawDocument): string { + return (d.doktyp || d.documentType) + || ((d.dokumentnamn || '').startsWith('SFS') ? 'sfs' : 'other'); } -// --------------------------------------------------------------------------- -// Localized label helpers for AI-derived deep-inspection sections (14 languages) -// --------------------------------------------------------------------------- - -type LanguageFormatWithConfidence = (n: number, conf: number) => string; -type LanguageFormatFunction = (n: number) => string; - -const STRATEGIC_CONTEXT_FOCUSED: Partial string>> = { - en: (t, n, c) => `Analysis exclusively focused on: ${t} — ${n} parliamentary documents examined (confidence: ${c}/100)`, - sv: (t, n, c) => `Analys exklusivt fokuserad på: ${t} — ${n} riksdagsdokument granskade (konfidens: ${c}/100)`, - da: (t, n, c) => `Analyse udelukkende fokuseret på: ${t} — ${n} parlamentsdokumenter undersøgt (konfidens: ${c}/100)`, - no: (t, n, c) => `Analyse utelukkende fokusert på: ${t} — ${n} parlamentsdokumenter undersøkt (konfidens: ${c}/100)`, - fi: (t, n, c) => `Analyysi keskittyy yksinomaan: ${t} — ${n} eduskunta-asiakirjaa tarkastettu (luottamus: ${c}/100)`, - de: (t, n, c) => `Analyse ausschließlich fokussiert auf: ${t} — ${n} parlamentarische Dokumente geprüft (Konfidenz: ${c}/100)`, - fr: (t, n, c) => `Analyse exclusivement centrée sur : ${t} — ${n} documents parlementaires examinés (confiance : ${c}/100)`, - es: (t, n, c) => `Análisis enfocado exclusivamente en: ${t} — ${n} documentos parlamentarios examinados (confianza: ${c}/100)`, - nl: (t, n, c) => `Analyse uitsluitend gericht op: ${t} — ${n} parlementaire documenten onderzocht (betrouwbaarheid: ${c}/100)`, - ar: (t, n, c) => `تحليل مركّز حصرياً على: ${t} — ${n} وثيقة برلمانية تم فحصها (ثقة: ${c}/100)`, - he: (t, n, c) => `ניתוח ממוקד באופן בלעדי ב: ${t} — ${n} מסמכים פרלמנטריים נבדקו (ביטחון: ${c}/100)`, - ja: (t, n, c) => `分析対象: ${t} — ${n}件の議会文書を調査(信頼度: ${c}/100)`, - ko: (t, n, c) => `분석 대상: ${t} — ${n}건의 의회 문서 조사 (신뢰도: ${c}/100)`, - zh: (t, n, c) => `分析专注于: ${t} — ${n}份议会文件已审查(置信度: ${c}/100)`, -}; - -const STRATEGIC_CONTEXT_MULTI: Partial> = { - en: (n, c) => `Multi-stakeholder analysis of ${n} parliamentary documents (confidence: ${c}/100)`, - sv: (n, c) => `Intressentanalys av ${n} riksdagsdokument (konfidens: ${c}/100)`, - da: (n, c) => `Interessentanalyse af ${n} parlamentsdokumenter (konfidens: ${c}/100)`, - no: (n, c) => `Interessentanalyse av ${n} parlamentsdokumenter (konfidens: ${c}/100)`, - fi: (n, c) => `Sidosryhmäanalyysi ${n} eduskunta-asiakirjasta (luottamus: ${c}/100)`, - de: (n, c) => `Multi-Stakeholder-Analyse von ${n} parlamentarischen Dokumenten (Konfidenz: ${c}/100)`, - fr: (n, c) => `Analyse multi-parties prenantes de ${n} documents parlementaires (confiance : ${c}/100)`, - es: (n, c) => `Análisis multiparticipativo de ${n} documentos parlamentarios (confianza: ${c}/100)`, - nl: (n, c) => `Multi-stakeholderanalyse van ${n} parlementaire documenten (betrouwbaarheid: ${c}/100)`, - ar: (n, c) => `تحليل متعدد الأطراف لـ ${n} وثيقة برلمانية (ثقة: ${c}/100)`, - he: (n, c) => `ניתוח רב-בעלי עניין של ${n} מסמכים פרלמנטריים (ביטחון: ${c}/100)`, - ja: (n, c) => `${n}件の議会文書のマルチステークホルダー分析(信頼度: ${c}/100)`, - ko: (n, c) => `${n}건의 의회 문서 다자 이해관계자 분석 (신뢰도: ${c}/100)`, - zh: (n, c) => `${n}份议会文件的多利益相关方分析(置信度: ${c}/100)`, -}; - -const MINDMAP_FALLBACK_TOPIC: Partial> = { - en: 'Parliamentary Analysis', sv: 'Parlamentarisk analys', da: 'Parlamentarisk analyse', no: 'Parlamentarisk analyse', - fi: 'Eduskunta-analyysi', de: 'Parlamentarische Analyse', fr: 'Analyse parlementaire', es: 'Análisis parlamentario', - nl: 'Parlementaire analyse', ar: 'تحليل برلماني', he: 'ניתוח פרלמנטרי', - ja: '議会分析', ko: '의회 분석', zh: '议会分析', -}; - -const MINDMAP_SUMMARY_FOCUSED: Partial string>> = { - en: (t) => `Conceptual map for deep inspection: ${t}`, - sv: (t) => `Konceptuell karta för djupinspektion: ${t}`, - da: (t) => `Konceptuelt kort til dybdeinspektion: ${t}`, - no: (t) => `Konseptuelt kart for dybdeinspeksjon: ${t}`, - fi: (t) => `Käsitekartta syväanalyysiin: ${t}`, - de: (t) => `Konzeptkarte zur Tiefeninspektion: ${t}`, - fr: (t) => `Carte conceptuelle pour inspection approfondie : ${t}`, - es: (t) => `Mapa conceptual para inspección profunda: ${t}`, - nl: (t) => `Conceptkaart voor diepgaande inspectie: ${t}`, - ar: (t) => `خريطة مفاهيمية للتفتيش المعمق: ${t}`, - he: (t) => `מפה מושגית לבדיקה מעמיקה: ${t}`, - ja: (t) => `深層検査の概念マップ: ${t}`, - ko: (t) => `심층 검사 개념 맵: ${t}`, - zh: (t) => `深度审查概念图: ${t}`, -}; - -const MINDMAP_SUMMARY_GENERIC: Partial> = { - en: (n) => `Conceptual map for ${n} parliamentary documents`, - sv: (n) => `Konceptuell karta för ${n} riksdagsdokument`, - da: (n) => `Konceptuelt kort for ${n} parlamentsdokumenter`, - no: (n) => `Konseptuelt kart for ${n} parlamentsdokumenter`, - fi: (n) => `Käsitekartta ${n} eduskunta-asiakirjasta`, - de: (n) => `Konzeptkarte für ${n} parlamentarische Dokumente`, - fr: (n) => `Carte conceptuelle de ${n} documents parlementaires`, - es: (n) => `Mapa conceptual de ${n} documentos parlamentarios`, - nl: (n) => `Conceptkaart voor ${n} parlementaire documenten`, - ar: (n) => `خريطة مفاهيمية لـ ${n} وثيقة برلمانية`, - he: (n) => `מפה מושגית ל-${n} מסמכים פרלמנטריים`, - ja: (n) => `${n}件の議会文書の概念マップ`, - ko: (n) => `${n}건의 의회 문서 개념 맵`, - zh: (n) => `${n}份议会文件概念图`, -}; - -const SANKEY_TITLE_LABEL: Partial> = { - en: 'Legislative Flow', sv: 'Lagstiftningsflöde', da: 'Lovgivningsflow', no: 'Lovgivningsflyt', - fi: 'Lainsäädännön kulku', de: 'Gesetzgebungsfluss', fr: 'Flux législatif', es: 'Flujo legislativo', - nl: 'Wetgevingsstroming', ar: 'تدفق التشريعات', he: 'זרימה חקיקתית', - ja: '立法フロー', ko: '입법 흐름', zh: '立法流程', -}; - -const SANKEY_SUMMARY: Partial> = { - en: (n) => `Flow of ${n} parliamentary documents from initiating actors to document types`, - sv: (n) => `Flöde av ${n} riksdagsdokument från initierande aktörer till dokumenttyper`, - da: (n) => `Flow af ${n} parlamentsdokumenter fra initierende aktører til dokumenttyper`, - no: (n) => `Flyt av ${n} parlamentsdokumenter fra initierende aktører til dokumenttyper`, - fi: (n) => `${n} eduskunta-asiakirjan kulku aloittavista toimijoista asiakirjatyyppeihin`, - de: (n) => `Fluss von ${n} parlamentarischen Dokumenten von initiierenden Akteuren zu Dokumenttypen`, - fr: (n) => `Flux de ${n} documents parlementaires des acteurs initiateurs aux types de documents`, - es: (n) => `Flujo de ${n} documentos parlamentarios de actores iniciadores a tipos de documentos`, - nl: (n) => `Stroom van ${n} parlementaire documenten van initiërende actoren naar documenttypen`, - ar: (n) => `تدفق ${n} وثيقة برلمانية من الأطراف المبادرة إلى أنواع الوثائق`, - he: (n) => `זרימת ${n} מסמכים פרלמנטריים מגורמים יוזמים לסוגי מסמכים`, - ja: (n) => `${n}件の議会文書の発議者から文書種類への流れ`, - ko: (n) => `${n}건의 의회 문서가 발의 주체에서 문서 유형으로의 흐름`, - zh: (n) => `${n}份议会文件从发起方到文件类型的流程`, -}; - /** - * Convert an AI AnalysisResult to TemplateSection[] for use in generateArticleHTML. - * This replaces buildDeepInspectionSections when the AI pipeline is used. - * Template section rendering (HTML structure, CSS, accessibility) is unchanged. + * Build SWOT and dashboard TemplateSections for a deep-inspection article. + * Uses buildAISwotStakeholders() to derive 6 stakeholder perspectives + * from document metadata (types, titles, document IDs as evidence). + * Returns TemplateSection[] ready for generateArticleHTML.sections. */ -function buildDeepInspectionSectionsFromAnalysis( - analysis: AnalysisResult, +function buildDeepInspectionSections( docs: RawDocument[], topic: string | null, lang: Language, + aiResult?: import('./ai-analysis-pipeline.js').AIAnalysisResult, ): TemplateSection[] { if (docs.length === 0) return []; - // Convert AnalysisStakeholderSwot → StakeholderSwot (for generateStakeholderSwotSection) - // Omit sh.role (internal key like "government") — sh.name already carries the localised label - const stakeholders: StakeholderSwot[] = analysis.stakeholderSwot.map(sh => ({ - name: sh.name, - swot: { - strengths: sh.swot.strengths.map(e => ({ text: e.text, impact: e.impact })) satisfies SwotEntry[], - weaknesses: sh.swot.weaknesses.map(e => ({ text: e.text, impact: e.impact })) satisfies SwotEntry[], - opportunities: sh.swot.opportunities.map(e => ({ text: e.text, impact: e.impact })) satisfies SwotEntry[], - threats: sh.swot.threats.map(e => ({ text: e.text, impact: e.impact })) satisfies SwotEntry[], - }, - })); - // Single-pass classification: bucket docs by effectiveType() to avoid N×filter passes. - // EU docs use both 'fpm' and 'eu' raw types and are merged into the euDocs bucket. + // EU docs use both 'fpm' and 'eu' raw types; effectiveType() preserves the raw value, + // so we merge both into the euDocs bucket below. const buckets = new Map(); for (const d of docs) { const t = effectiveType(d); @@ -1256,116 +1153,161 @@ function buildDeepInspectionSectionsFromAnalysis( .filter(([k]) => !classifiedTypes.has(k)) .flatMap(([, v]) => v); + // ── AI-driven 6-stakeholder SWOT ───────────────────────────────────────── + const stakeholders = buildAISwotStakeholders(docs, topic, lang); + const strategicContext = topic - ? (STRATEGIC_CONTEXT_FOCUSED[lang] ?? STRATEGIC_CONTEXT_FOCUSED.en!)(topic, docs.length, analysis.confidenceScore) - : (STRATEGIC_CONTEXT_MULTI[lang] ?? STRATEGIC_CONTEXT_MULTI.en!)(docs.length, analysis.confidenceScore); + ? `Analysis exclusively focused on: ${topic} — ${docs.length} parliamentary documents examined` + : `Multi-stakeholder analysis of ${docs.length} parliamentary documents`; const swotSection = generateStakeholderSwotSection({ stakeholders, lang, strategicContext }); - // Dashboard from analysis output + // ── Localised names for mindmap/sankey labels (single source from ai-swot-analyzer) + const govName = AI_STAKEHOLDER_NAMES['government-coalition'][lang] ?? AI_STAKEHOLDER_NAMES['government-coalition'].en; + const oppName = AI_STAKEHOLDER_NAMES['opposition'][lang] ?? AI_STAKEHOLDER_NAMES['opposition'].en; + const privateName = AI_STAKEHOLDER_NAMES['private-sector'][lang] ?? AI_STAKEHOLDER_NAMES['private-sector'].en; + + // ── AI-analyzed multi-chart dashboard ───────────────────────────────────── + // Produces 3 chart types (radar, scatter, bar) with accessible data tables. + const dashboardAnalysis = analyzeDashboardData(docs, topic, lang); + + // Also build the classic document-type distribution bar chart as chart #4 + // so existing article consumers still see document counts. + const typeCounts: Record = {}; + docs.forEach(d => { + const t = effectiveType(d); + typeCounts[t] = (typeCounts[t] || 0) + 1; + }); + const rawTypeKeys = Object.keys(typeCounts); + // Use localized display names for chart labels (e.g., "Press Release" not "pressm") + const chartLabels = rawTypeKeys.map(t => docTypeLabel(t, lang, typeCounts[t])); + const chartValues = rawTypeKeys.map(t => typeCounts[t]); + + const docTypeChart = { + id: 'deep-inspection-doc-types', + type: 'bar' as const, + title: deepLabel('documentsByType', lang), + labels: chartLabels, + datasets: [{ + label: deepLabel('documents', lang), + data: chartValues, + backgroundColor: rawTypeKeys.map((_, i) => DEEP_CHART_PALETTE[i % DEEP_CHART_PALETTE.length]), + }], + }; + const docTypeTable = { + caption: deepLabel('documentsByType', lang), + headers: [deepLabel('documentTypes', lang), deepLabel('documents', lang)], + rows: rawTypeKeys.map((t, i) => [docTypeLabel(t, lang, chartValues[i]), String(chartValues[i])]), + }; + const dashboardSection = generateDashboardSection({ data: { - title: analysis.dashboardData.title, - summary: analysis.dashboardData.summary, - charts: [{ - id: 'deep-inspection-doc-types', - type: 'bar', - title: deepLabel('documentsByType', lang), - labels: analysis.dashboardData.typeDistribution.map(t => t.label), - datasets: [{ - label: deepLabel('documents', lang), - data: analysis.dashboardData.typeDistribution.map(t => t.value), - backgroundColor: analysis.dashboardData.typeDistribution.map(t => t.color), - }], - }], + title: topic + ? `${deepLabel('documentIntelligence', lang)} — ${topic}` + : deepLabel('documentIntelligence', lang), + summary: dashboardAnalysis.summary, + charts: [...dashboardAnalysis.charts, docTypeChart], + tables: [...dashboardAnalysis.tables, docTypeTable], }, lang, }); - // Mindmap from analysis branches - const mindmapBranches: MindmapBranch[] = analysis.mindmapBranches.map(b => ({ - label: b.label, - color: b.color, - icon: b.icon, - items: b.items, - })); - - const mindmapSection = generateMindmapSection({ - topic: topic || (MINDMAP_FALLBACK_TOPIC[lang] ?? MINDMAP_FALLBACK_TOPIC.en!), - branches: mindmapBranches, - lang, - summary: topic - ? (MINDMAP_SUMMARY_FOCUSED[lang] ?? MINDMAP_SUMMARY_FOCUSED.en!)(topic) - : (MINDMAP_SUMMARY_GENERIC[lang] ?? MINDMAP_SUMMARY_GENERIC.en!)(docs.length), - }); - - // Sankey section reuses doc classification from above (propDocs, betDocs, etc.) - - // Select stakeholder names by role key (not array index) to avoid silent mislabeling - const findStakeholderName = (role: string, fallback: string): string => - analysis.stakeholderSwot.find(s => s.role === role)?.name ?? fallback; - const sankeyGovName = findStakeholderName('government', 'Government'); - const sankeyOppName = findStakeholderName('parliament', 'Parliament'); - const privateName = findStakeholderName('private-sector', 'Private Sector'); - - // Sankey: actor groups → document types + // ── Mindmap: AI-driven conceptual map across 5 political dimensions ───────── + const allDetectedDomains = new Set(); + docs.forEach(d => detectPolicyDomains(d, lang).forEach(dom => allDetectedDomains.add(dom))); + // Augment with AI-detected domains when available. + // emergingTrends format: "domain1, domain2, domain3 [CONFIDENCE]" — single suffix on whole list. + if (aiResult?.synthesis?.emergingTrends) { + aiResult.synthesis.emergingTrends + .split(',') + .map(s => s.split('[')[0]?.trim() ?? '') + .filter(Boolean) + .forEach(dom => allDetectedDomains.add(dom)); + } + const detectedDomainList = [...allDetectedDomains].filter(Boolean).slice(0, 8); + + // Pass precomputed domains to avoid iterating docs twice + const aiAnalysis = buildAIMindmapAnalysis(docs, topic, lang, detectedDomainList); + const mindmapSection = generateMindmapSection( + buildMindmapOptionsFromAnalysis( + aiAnalysis, + lang, + topic || deepLabel('parliamentaryAnalysis', lang), + { + summary: topic + ? `${deepLabel('conceptualMap', lang)}: ${topic}` + : `${deepLabel('conceptualMap', lang)} — ${docs.length} ${deepLabel('documents', lang).toLowerCase()}`, + }, + ), + ); + + // ── Sankey: party/doc-type flow → legislative outcome ───────────────────── + // The sankey uses three primary legislative actor groups as source nodes: + // - government: initiates propositions, laws, gov. communications, press releases, + // and EU position papers (fpm) — these originate from government ministries + // - opposition: initiates committee reports and motions + // - private sector / external actors: associated with external references + // and other document types + // Additional SWOT stakeholders (civil society, citizens, etc.) are + // analysis perspectives rather than document-originating actors. const sankeyNodes: SankeyNode[] = [ - { id: 'gov', label: sankeyGovName, color: 'cyan' }, - { id: 'opp', label: sankeyOppName, color: 'magenta' }, - { id: 'pvt', label: privateName, color: 'purple' }, + { id: 'gov', label: govName, color: 'cyan' }, + { id: 'opp', label: oppName, color: 'magenta' }, + { id: 'pvt', label: privateName, color: 'purple' }, ]; + // Add document type nodes and target outcome nodes const sankeyFlows: SankeyFlow[] = []; if (propDocs.length > 0) { - sankeyNodes.push({ id: 'prop', label: localizeDocType('prop', lang, propDocs.length), color: 'orange' }); + sankeyNodes.push({ id: 'prop', label: 'Propositions', color: 'orange' }); sankeyFlows.push({ source: 'gov', target: 'prop', value: propDocs.length, label: `${propDocs.length}` }); } if (betDocs.length > 0) { - sankeyNodes.push({ id: 'bet', label: localizeDocType('bet', lang, betDocs.length), color: 'blue' }); + sankeyNodes.push({ id: 'bet', label: 'Committee Reports', color: 'blue' }); sankeyFlows.push({ source: 'opp', target: 'bet', value: betDocs.length, label: `${betDocs.length}` }); } if (motDocs.length > 0) { - sankeyNodes.push({ id: 'mot', label: localizeDocType('mot', lang, motDocs.length), color: 'yellow' }); + sankeyNodes.push({ id: 'mot', label: 'Motions', color: 'yellow' }); sankeyFlows.push({ source: 'opp', target: 'mot', value: motDocs.length, label: `${motDocs.length}` }); } if (sfsDocs.length > 0) { - sankeyNodes.push({ id: 'sfs', label: localizeDocType('sfs', lang, sfsDocs.length), color: 'green' }); + sankeyNodes.push({ id: 'sfs', label: 'Laws (SFS)', color: 'green' }); sankeyFlows.push({ source: 'gov', target: 'sfs', value: sfsDocs.length, label: `${sfsDocs.length}` }); } if (skrDocs.length > 0) { - sankeyNodes.push({ id: 'skr', label: localizeDocType('skr', lang, skrDocs.length), color: 'cyan' }); + sankeyNodes.push({ id: 'skr', label: deepLabel('govCommunications', lang), color: 'green' }); sankeyFlows.push({ source: 'gov', target: 'skr', value: skrDocs.length, label: `${skrDocs.length}` }); } if (euDocs.length > 0) { - sankeyNodes.push({ id: 'eu', label: localizeDocType('fpm', lang, euDocs.length), color: 'blue' }); + sankeyNodes.push({ id: 'eu', label: 'EU Positions', color: 'blue' }); sankeyFlows.push({ source: 'gov', target: 'eu', value: euDocs.length, label: `${euDocs.length}` }); } if (pressmDocs.length > 0) { - sankeyNodes.push({ id: 'pressm', label: localizeDocType('pressm', lang, pressmDocs.length), color: 'orange' }); + sankeyNodes.push({ id: 'pressm', label: 'Press Releases', color: 'orange' }); sankeyFlows.push({ source: 'gov', target: 'pressm', value: pressmDocs.length, label: `${pressmDocs.length}` }); } if (extDocs.length > 0) { - sankeyNodes.push({ id: 'ext', label: localizeDocType('ext', lang, extDocs.length), color: 'purple' }); + sankeyNodes.push({ id: 'ext', label: 'External / Reference', color: 'purple' }); sankeyFlows.push({ source: 'pvt', target: 'ext', value: extDocs.length, label: `${extDocs.length}` }); } if (otherDocs.length > 0) { - sankeyNodes.push({ id: 'other', label: localizeDocType('other', lang, otherDocs.length), color: 'purple' }); + sankeyNodes.push({ id: 'other', label: 'Other Docs', color: 'purple' }); sankeyFlows.push({ source: 'pvt', target: 'other', value: otherDocs.length, label: `${otherDocs.length}` }); } + // Only include Sankey when there is more than one non-trivial flow (otherwise uninformative) const sankeySection: TemplateSection | null = sankeyFlows.length >= 2 ? generateSankeySection({ nodes: sankeyNodes, flows: sankeyFlows, lang, - title: topic - ? `${SANKEY_TITLE_LABEL[lang] ?? SANKEY_TITLE_LABEL.en!} — ${topic}` - : (SANKEY_TITLE_LABEL[lang] ?? SANKEY_TITLE_LABEL.en!), - summary: (SANKEY_SUMMARY[lang] ?? SANKEY_SUMMARY.en!)(docs.length), + title: topic ? `Legislative Flow — ${topic}` : 'Legislative Flow', + summary: `Flow of ${docs.length} parliamentary documents from initiating actors to document types`, }) : null; - const economicSection = analysis.policyAssessment.domains.length > 0 - ? generateEconomicDashboardSection({ policyDomains: analysis.policyAssessment.domains, lang }) + // ── World Bank / Economic Dashboard ────────────────────────────────────── + const economicSection = detectedDomainList.length > 0 + ? generateEconomicDashboardSection({ policyDomains: detectedDomainList, lang }) : null; const additionalSections: TemplateSection[] = [ @@ -1377,17 +1319,6 @@ function buildDeepInspectionSectionsFromAnalysis( return [dashboardSection, swotSection, ...additionalSections]; } -/** - * Compute the effective document type for a RawDocument. - * SFS-by-name docs (missing doktyp/documentType but dokumentnamn starting with "SFS") - * are normalised to 'sfs' so filters, typeCounts, and chart labels stay consistent. - */ -function effectiveType(d: RawDocument): string { - return (d.doktyp || d.documentType) - || ((d.dokumentnamn || '').startsWith('SFS') ? 'sfs' : 'other'); -} - - /** * Generate Deep-Inspection article targeting specific documents or policy topics. * Uses documentIds, documentUrls, and focusTopic from CLI config to fetch @@ -1585,7 +1516,8 @@ export async function generateDeepInspection(): Promise { const slug: string = `${formatDateForSlug(today)}-deep-inspection-${topicSlug}`; - const sanitizedTopic: string | null = focusTopic ? sanitizePlainText(focusTopic) : null; + const sanitizedTopicRaw = focusTopic ? sanitizePlainText(focusTopic) : ''; + const sanitizedTopic: string | null = sanitizedTopicRaw.trim() || null; const defaultTopicLabels: Record = { en: 'Policy Analysis', sv: 'Policyanalys', @@ -1620,50 +1552,24 @@ export async function generateDeepInspection(): Promise { }; for (const lang of languages) { - console.log(` 🌐 Generating ${lang.toUpperCase()} version (analysis-depth: ${analysisDepth})...`); - - // ── AI Analysis Pipeline (multi-iteration) ─────────────────────────── - const { analysis, validation, iterationDurationsMs } = await runAnalysisPipeline(enrichedDocs, { - depth: analysisDepth, - lang, - focusTopic: sanitizedTopic, - }); - const pipelineDuration = iterationDurationsMs.reduce((a, b) => a + b, 0); + console.log(` 🌐 Generating ${lang.toUpperCase()} version...`); - console.log(` 🤖 Analysis: ${analysis.iterationsCompleted} iteration(s) completed, confidence: ${analysis.confidenceScore}/100 (${pipelineDuration}ms)`); - if (validation) { - const passLabel = validation.passed ? '✅' : '⚠️'; - console.log(` ${passLabel} Validation score: ${validation.score}/100${validation.issues.length > 0 ? `, issues: ${validation.issues.join('; ')}` : ''}`); + // Run multi-iteration AI analysis pipeline — cache result per language + const cacheKey = sharedAnalysisCache.generateKey(enrichedDocs, sanitizedTopic, analysisIterations, lang); + let aiResult = sharedAnalysisCache.get(cacheKey); + if (!aiResult) { + const pipeline = new AIAnalysisPipeline({ iterations: analysisIterations }); + aiResult = pipeline.analyze(enrichedDocs, sanitizedTopic, lang); + sharedAnalysisCache.set(cacheKey, aiResult); + console.log(` 🤖 AI analysis: ${aiResult.iterations} iteration(s), analysis score ${aiResult.analysisScore}`); } - // Write iteration metadata for audit trail - const iterationMetadata: AnalysisIterationMetadata = { - articleSlug: slug, - lang, - depth: analysisDepth, - iterationsCompleted: analysis.iterationsCompleted, - iterationDurationsMs, - confidenceScore: analysis.confidenceScore, - validationResult: validation, - documentCount: analysis.documentCount, - enrichedCount: analysis.enrichedCount, - focusTopic: sanitizedTopic, - completedAt: analysis.completedAt, - }; - writeAnalysisMetadata(slug, iterationMetadata); - - // Topic-focused deep-inspection content (template-driven body text). - // At this stage the AI pipeline supplies structured sections (SWOT, - // dashboard, mindmap, Sankey, watch points) while the main narrative - // body is still produced by generateDeepInspectionContent(). Wiring - // AI-derived narrative into the article body is the next phase. - const content: string = generateDeepInspectionContent(enrichedDocs, sanitizedTopic, lang); + // Topic-focused deep-inspection content (uses AI strategic implications & takeaways) + const content: string = generateDeepInspectionContent(enrichedDocs, sanitizedTopic, lang, aiResult); - // Metadata derived from document data + // Metadata still derived from document data const contentData = { documents: enrichedDocs as Parameters[0]['documents'] }; - // Use AI-derived watch points (plain text — escape here before passing to - // generateWatchSection which renders pre-escaped HTML directly) - const watchPoints = analysis.watchPoints.map(wp => ({ title: escapeHtml(wp.title), description: escapeHtml(wp.description) })); + const watchPoints = extractWatchPoints(contentData, lang); const metadata = generateMetadata(contentData, 'deep-inspection', lang); const readTime: string = calculateReadTime(content); const sourceMethods = ['get_dokument', 'get_dokument_innehall', 'search_dokument']; @@ -1671,8 +1577,8 @@ export async function generateDeepInspection(): Promise { if (gitHubUrls.length > 0) sourceMethods.push('GitHub raw content'); const sources: string[] = generateSources(sourceMethods); - // SWOT + dashboard sections — built from AI analysis result (not hardcoded templates) - const sections = buildDeepInspectionSectionsFromAnalysis(analysis, enrichedDocs, sanitizedTopic, lang); + // SWOT + dashboard sections — AI-generated dynamic entries (context-aware, all 14 languages) + const sections = buildDeepInspectionSections(enrichedDocs, sanitizedTopic, lang, aiResult); const langTitles: TitleSet = titles[lang] || titles.en; diff --git a/tests/ai-analysis-pipeline.test.ts b/tests/ai-analysis-pipeline.test.ts index 88803ca5a4..becf442316 100644 --- a/tests/ai-analysis-pipeline.test.ts +++ b/tests/ai-analysis-pipeline.test.ts @@ -1,523 +1,434 @@ /** - * Tests for the AI analysis pipeline (scripts/ai-analysis/pipeline.ts). + * Tests for the multi-iteration AI analysis pipeline and analysis cache. * - * Validates: - * - analyzeDocuments (iteration 1): produces AnalysisResult with stakeholder SWOT, - * watch points, mindmap branches, dashboard data, and policy assessment. - * - refineAnalysis (iteration 2): enriches SWOT entries when enriched documents exist. - * - validateCompleteness (iteration 3): scoring and issue detection. - * - runAnalysisPipeline: depth-controlled iteration dispatch. - * - 14-language support: localised stakeholder names and labels. - * - SWOT entry confidence: HIGH for enriched content, MEDIUM for metadata-only. + * Coverage: + * - AIAnalysisPipeline.analyze() produces well-formed output + * - Dynamic SWOT entries are never empty + * - Strategic implications and key takeaways vary by document mix + * - All 14 languages produce non-empty output + * - --iterations parameter controls pipeline depth (1, 2, 3) + * - AnalysisCache stores and retrieves results correctly + * - Cache expiry behaviour */ -import { describe, it, expect } from 'vitest'; -import { - aiAnalysisPipeline, - runAnalysisPipeline, -} from '../scripts/ai-analysis/pipeline.js'; -import type { AnalysisPipelineOptions } from '../scripts/ai-analysis/types.js'; -import type { RawDocument } from '../scripts/data-transformers/types.js'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { AIAnalysisPipeline } from '../scripts/generate-news-enhanced/ai-analysis-pipeline.js'; +import { AnalysisCache } from '../scripts/generate-news-enhanced/analysis-cache.js'; +import type { RawDocument } from '../scripts/data-transformers.js'; // --------------------------------------------------------------------------- -// Test fixtures +// Helpers // --------------------------------------------------------------------------- -function makePropDoc(overrides: Partial = {}): RawDocument { +function makeDoc(overrides: Partial = {}): RawDocument { return { dok_id: 'TEST001', + titel: 'Test document', + title: 'Test document', doktyp: 'prop', - titel: 'Proposition om statsbudget 2026', - datum: '2026-01-15', - organ: 'FiU', + datum: '2026-03-01', ...overrides, - }; + } as RawDocument; } -function makeMotDoc(overrides: Partial = {}): RawDocument { - return { - dok_id: 'TEST002', - doktyp: 'mot', - titel: 'Motion om ökade försvarsanslag', - datum: '2026-01-20', - ...overrides, - }; -} +const PROP = makeDoc({ dok_id: 'PROP1', titel: 'Proposition om säkerhet', doktyp: 'prop' }); +const BET = makeDoc({ dok_id: 'BET1', titel: 'Betänkande om budget', doktyp: 'bet' }); +const MOT = makeDoc({ dok_id: 'MOT1', titel: 'Motion om klimat', doktyp: 'mot' }); +const FPM = makeDoc({ dok_id: 'FPM1', titel: 'EU-position om handel', doktyp: 'fpm' }); +const SFS = makeDoc({ dok_id: 'SFS1', titel: 'SFS 2026:1 Lag om digitalisering', doktyp: 'sfs' }); -function makeBetDoc(overrides: Partial = {}): RawDocument { - return { - dok_id: 'TEST003', - doktyp: 'bet', - titel: 'Betänkande om finanspolitiken', - datum: '2026-02-01', - organ: 'FiU', - ...overrides, - }; -} +const ALL_DOCS = [PROP, BET, MOT, FPM, SFS]; -function makeEnrichedPropDoc(): RawDocument { - return { - dok_id: 'TEST004', - doktyp: 'prop', - titel: 'Proposition om cybersäkerhet', - datum: '2026-02-15', - organ: 'JuU', - contentFetched: true, - fullText: 'Propositionen föreslår att riksdagen antar nya regler om cybersäkerhet för kritisk infrastruktur. Regeringen bedömer att åtgärderna stärker det svenska försvaret mot cyberhot.', - }; -} +// --------------------------------------------------------------------------- +// AIAnalysisPipeline — core tests +// --------------------------------------------------------------------------- -/** Metadata-enriched only (contentFetched but no fullText/fullContent). - * This mirrors what `enrichDocumentsWithContent()` typically produces for - * Riksdag docs (called with include_full_text=false). */ -function makeMetadataEnrichedPropDoc(): RawDocument { - return { - dok_id: 'TEST005', - doktyp: 'prop', - titel: 'Proposition om klimatanpassning', - datum: '2026-03-01', - organ: 'MJU', - contentFetched: true, - summary: 'Regeringen föreslår nya regler för kommunernas klimatanpassningsarbete.', - notis: 'Klimatanpassning — kommunala åtgärder', - }; -} +describe('AIAnalysisPipeline', () => { + describe('analyze() — basic structure', () => { + it('returns a result with the correct iteration count', () => { + const pipeline = new AIAnalysisPipeline({ iterations: 2 }); + const result = pipeline.analyze(ALL_DOCS, null, 'en'); + expect(result.iterations).toBe(2); + }); -function makeOptions(overrides: Partial = {}): AnalysisPipelineOptions { - return { - depth: 'standard', - lang: 'en', - focusTopic: null, - ...overrides, - }; -} + it('returns document analyses for each input document', () => { + const pipeline = new AIAnalysisPipeline({ iterations: 1 }); + const result = pipeline.analyze(ALL_DOCS, null, 'en'); + expect(result.documentAnalyses).toHaveLength(ALL_DOCS.length); + }); -// --------------------------------------------------------------------------- -// analyzeDocuments (Iteration 1) -// --------------------------------------------------------------------------- + it('produces a non-empty synthesis', () => { + const pipeline = new AIAnalysisPipeline(); + const result = pipeline.analyze(ALL_DOCS, null, 'en'); + expect(result.synthesis.policyConvergence.length).toBeGreaterThan(0); + }); -describe('aiAnalysisPipeline.analyzeDocuments', () => { - it('returns an AnalysisResult with expected shape', async () => { - const docs = [makePropDoc(), makeMotDoc()]; - const result = await aiAnalysisPipeline.analyzeDocuments(docs, makeOptions()); - - expect(result).toBeDefined(); - expect(result.stakeholderSwot).toBeInstanceOf(Array); - expect(result.stakeholderSwot.length).toBe(3); // gov, parliament, private - expect(result.policyAssessment).toBeDefined(); - expect(result.watchPoints).toBeInstanceOf(Array); - expect(result.mindmapBranches).toBeInstanceOf(Array); - expect(result.dashboardData).toBeDefined(); - expect(result.iterationsCompleted).toBe(1); - expect(result.lang).toBe('en'); - expect(result.documentCount).toBe(2); - }); + it('produces an analysis score between 0 and 100', () => { + const pipeline = new AIAnalysisPipeline(); + const result = pipeline.analyze(ALL_DOCS, null, 'en'); + expect(result.analysisScore).toBeGreaterThanOrEqual(0); + expect(result.analysisScore).toBeLessThanOrEqual(100); + }); - it('produces three stakeholder SWOT analyses (government, parliament, private)', async () => { - const docs = [makePropDoc(), makeBetDoc(), makeMotDoc()]; - const result = await aiAnalysisPipeline.analyzeDocuments(docs, makeOptions()); + it('returns at least one key takeaway for a mixed document set', () => { + const pipeline = new AIAnalysisPipeline(); + const result = pipeline.analyze(ALL_DOCS, null, 'en'); + expect(result.keyTakeaways.length).toBeGreaterThan(0); + }); - const roles = result.stakeholderSwot.map(sh => sh.role); - expect(roles).toContain('government'); - expect(roles).toContain('parliament'); - expect(roles).toContain('private-sector'); + it('returns non-empty strategic implications HTML', () => { + const pipeline = new AIAnalysisPipeline(); + const result = pipeline.analyze(ALL_DOCS, null, 'en'); + expect(result.strategicImplications).toContain('

'); + }); }); - it('each SWOT quadrant has at least one entry', async () => { - const docs = [makePropDoc(), makeBetDoc(), makeMotDoc()]; - const result = await aiAnalysisPipeline.analyzeDocuments(docs, makeOptions()); + // ── SWOT entries ────────────────────────────────────────────────────────── - for (const sh of result.stakeholderSwot) { - expect(sh.swot.strengths.length).toBeGreaterThanOrEqual(1); - expect(sh.swot.weaknesses.length).toBeGreaterThanOrEqual(1); - expect(sh.swot.opportunities.length).toBeGreaterThanOrEqual(1); - expect(sh.swot.threats.length).toBeGreaterThanOrEqual(1); - } - }); + describe('dynamicSwotEntries', () => { + it('government strengths are non-empty when propositions are present', () => { + const pipeline = new AIAnalysisPipeline(); + const result = pipeline.analyze([PROP, BET], null, 'en'); + expect(result.dynamicSwotEntries.government.strengths.length).toBeGreaterThan(0); + }); - it('SWOT entries contain text (not empty strings)', async () => { - const docs = [makePropDoc(), makeMotDoc()]; - const result = await aiAnalysisPipeline.analyzeDocuments(docs, makeOptions()); + it('opposition strengths are non-empty when committee reports are present', () => { + const pipeline = new AIAnalysisPipeline(); + const result = pipeline.analyze([BET, MOT], null, 'en'); + expect(result.dynamicSwotEntries.opposition.strengths.length).toBeGreaterThan(0); + }); - for (const sh of result.stakeholderSwot) { + it('private sector strengths always have at least one entry', () => { + const pipeline = new AIAnalysisPipeline(); + const result = pipeline.analyze([PROP], null, 'en'); + expect(result.dynamicSwotEntries.privateSector.strengths.length).toBeGreaterThan(0); + }); + + it('all SWOT entries have non-empty text', () => { + const pipeline = new AIAnalysisPipeline(); + const result = pipeline.analyze(ALL_DOCS, 'climate policy', 'en'); const allEntries = [ - ...sh.swot.strengths, - ...sh.swot.weaknesses, - ...sh.swot.opportunities, - ...sh.swot.threats, + ...result.dynamicSwotEntries.government.strengths, + ...result.dynamicSwotEntries.government.weaknesses, + ...result.dynamicSwotEntries.government.opportunities, + ...result.dynamicSwotEntries.government.threats, + ...result.dynamicSwotEntries.opposition.strengths, + ...result.dynamicSwotEntries.opposition.weaknesses, + ...result.dynamicSwotEntries.opposition.opportunities, + ...result.dynamicSwotEntries.opposition.threats, + ...result.dynamicSwotEntries.privateSector.strengths, + ...result.dynamicSwotEntries.privateSector.weaknesses, + ...result.dynamicSwotEntries.privateSector.opportunities, + ...result.dynamicSwotEntries.privateSector.threats, ]; - for (const entry of allEntries) { - expect(entry.text.trim().length).toBeGreaterThan(0); - } - } - }); - - it('sets focusTopic in the result when provided', async () => { - const docs = [makePropDoc()]; - const result = await aiAnalysisPipeline.analyzeDocuments(docs, makeOptions({ focusTopic: 'cybersecurity' })); - expect(result.focusTopic).toBe('cybersecurity'); - }); - - it('produces watch points for propositions', async () => { - const docs = [makePropDoc()]; - const result = await aiAnalysisPipeline.analyzeDocuments(docs, makeOptions()); - const propWatchPoint = result.watchPoints.find(wp => wp.urgency === 'high'); - expect(propWatchPoint).toBeDefined(); - }); + expect(allEntries.length).toBeGreaterThan(0); + allEntries.forEach(e => expect(e.text.length).toBeGreaterThan(0)); + }); - it('produces watch points for enacted laws (SFS)', async () => { - const sfsDoc: RawDocument = { dok_id: 'SFS2026:1', doktyp: 'sfs', titel: 'Ny lag om cybersäkerhet' }; - const result = await aiAnalysisPipeline.analyzeDocuments([sfsDoc], makeOptions()); - const criticalWatchPoint = result.watchPoints.find(wp => wp.urgency === 'critical'); - expect(criticalWatchPoint).toBeDefined(); + it('SWOT entries are context-aware when a focus topic is provided', () => { + const pipeline = new AIAnalysisPipeline(); + const withTopic = pipeline.analyze([PROP], 'defence policy', 'en'); + const withoutTopic = pipeline.analyze([PROP], null, 'en'); + // The topic should appear in at least one entry + const topicEntries = [ + ...withTopic.dynamicSwotEntries.government.strengths, + ...withTopic.dynamicSwotEntries.privateSector.strengths, + ].filter(e => e.text.includes('defence policy')); + expect(topicEntries.length).toBeGreaterThan(0); + // Without topic, entries should not contain the specific topic string + const noTopicEntries = [ + ...withoutTopic.dynamicSwotEntries.government.strengths, + ].filter(e => e.text.includes('defence policy')); + expect(noTopicEntries.length).toBe(0); + }); }); - it('produces dashboard data with type distribution', async () => { - const docs = [makePropDoc(), makeMotDoc(), makeBetDoc()]; - const result = await aiAnalysisPipeline.analyzeDocuments(docs, makeOptions()); - expect(result.dashboardData.typeDistribution.length).toBeGreaterThan(0); - expect(result.dashboardData.summary).toBeDefined(); - }); + // ── Key takeaways ───────────────────────────────────────────────────────── - it('produces mindmap branches with document types and stakeholders', async () => { - const docs = [makePropDoc(), makeMotDoc()]; - const result = await aiAnalysisPipeline.analyzeDocuments(docs, makeOptions()); - expect(result.mindmapBranches.length).toBeGreaterThanOrEqual(2); - const branchLabels = result.mindmapBranches.map(b => b.label); - // Stakeholders branch should always be present - expect(branchLabels.some(l => l.toLowerCase().includes('stakeholder') || l.toLowerCase().includes('intressenter'))).toBe(true); - }); + describe('keyTakeaways', () => { + it('includes a proposition takeaway when propositions are present', () => { + const pipeline = new AIAnalysisPipeline(); + const result = pipeline.analyze([PROP], 'security', 'en'); + const hasPropTakeaway = result.keyTakeaways.some(t => t.includes('legislative proposal')); + expect(hasPropTakeaway).toBe(true); + }); - it('calculates a confidence score between 0 and 100', async () => { - const docs = [makePropDoc(), makeMotDoc()]; - const result = await aiAnalysisPipeline.analyzeDocuments(docs, makeOptions()); - expect(result.confidenceScore).toBeGreaterThanOrEqual(0); - expect(result.confidenceScore).toBeLessThanOrEqual(100); - }); + it('includes a committee report takeaway when committee reports are present', () => { + const pipeline = new AIAnalysisPipeline(); + const result = pipeline.analyze([BET], 'budget', 'en'); + const hasBetTakeaway = result.keyTakeaways.some(t => t.includes('committee report')); + expect(hasBetTakeaway).toBe(true); + }); - it('handles empty document array gracefully', async () => { - const result = await aiAnalysisPipeline.analyzeDocuments([], makeOptions()); - expect(result.documentCount).toBe(0); - expect(result.confidenceScore).toBe(0); - // Should still produce 3 stakeholder SWOT analyses (placeholder entries) - expect(result.stakeholderSwot.length).toBe(3); - }); + it('includes a motion takeaway when motions are present', () => { + const pipeline = new AIAnalysisPipeline(); + const result = pipeline.analyze([MOT], 'climate', 'en'); + const hasMotTakeaway = result.keyTakeaways.some(t => t.includes('motion')); + expect(hasMotTakeaway).toBe(true); + }); - it('includes policy domains for fiscal documents', async () => { - const fiscalDoc: RawDocument = { - dok_id: 'BUDGET001', - doktyp: 'prop', - titel: 'Proposition om statsbudgeten och skattelagstiftningen 2026', - }; - const result = await aiAnalysisPipeline.analyzeDocuments([fiscalDoc], makeOptions({ lang: 'en' })); - expect(result.policyAssessment.domains.length).toBeGreaterThan(0); + it('detects coalition stress when motions challenge propositions', () => { + const stressDoc = makeDoc({ + dok_id: 'MOT2', + titel: 'Avslag på proposition om migration', + doktyp: 'mot', + }); + const pipeline = new AIAnalysisPipeline(); + const result = pipeline.analyze([PROP, stressDoc], 'migration', 'en'); + const hasStress = result.keyTakeaways.some(t => + t.toLowerCase().includes('coalition') || t.toLowerCase().includes('opposition challenge'), + ); + expect(hasStress).toBe(true); + }); }); -}); - -// --------------------------------------------------------------------------- -// Language support -// --------------------------------------------------------------------------- -describe('aiAnalysisPipeline.analyzeDocuments — language support', () => { - const testLanguages = ['en', 'sv', 'de', 'fr', 'es', 'ar', 'zh', 'ja', 'ko', 'he', 'fi', 'nl', 'da', 'no'] as const; - const docs = [makePropDoc(), makeMotDoc()]; + // ── Strategic implications ───────────────────────────────────────────────── - for (const lang of testLanguages) { - it(`produces localised stakeholder names for ${lang}`, async () => { - const result = await aiAnalysisPipeline.analyzeDocuments(docs, makeOptions({ lang })); - expect(result.lang).toBe(lang); - expect(result.stakeholderSwot[0]?.name.trim().length).toBeGreaterThan(0); - expect(result.stakeholderSwot[1]?.name.trim().length).toBeGreaterThan(0); - expect(result.stakeholderSwot[2]?.name.trim().length).toBeGreaterThan(0); + describe('strategicImplications', () => { + it('references document counts in the text', () => { + const pipeline = new AIAnalysisPipeline(); + const docs = [PROP, BET, MOT]; + const result = pipeline.analyze(docs, null, 'en'); + expect(result.strategicImplications).toContain('3'); }); - } -}); -// --------------------------------------------------------------------------- -// refineAnalysis (Iteration 2) -// --------------------------------------------------------------------------- + it('references the focus topic when provided', () => { + const pipeline = new AIAnalysisPipeline(); + const result = pipeline.analyze([PROP], 'Nordic security', 'en'); + expect(result.strategicImplications).toContain('Nordic security'); + }); -describe('aiAnalysisPipeline.refineAnalysis', () => { - it('bumps iterationsCompleted to 2', async () => { - const docs = [makePropDoc(), makeMotDoc()]; - const initial = await aiAnalysisPipeline.analyzeDocuments(docs, makeOptions()); - const refined = await aiAnalysisPipeline.refineAnalysis(initial, docs, makeOptions()); - expect(refined.iterationsCompleted).toBe(2); - }); + it('returns a paragraph element', () => { + const pipeline = new AIAnalysisPipeline(); + const result = pipeline.analyze([PROP], null, 'en'); + expect(result.strategicImplications.trim()).toMatch(/^

/); + expect(result.strategicImplications).toContain('

'); + }); - it('preserves analysis content but updates metadata when no enriched docs', async () => { - const docs = [makePropDoc(), makeMotDoc()]; // not enriched - const initial = await aiAnalysisPipeline.analyzeDocuments(docs, makeOptions()); - const refined = await aiAnalysisPipeline.refineAnalysis(initial, docs, makeOptions()); - expect(refined.iterationsCompleted).toBe(2); - expect(refined.documentCount).toBe(initial.documentCount); - }); + it('escapes HTML-special characters in focus topic to prevent XSS', () => { + const pipeline = new AIAnalysisPipeline(); + const result = pipeline.analyze([PROP], '', 'en'); + expect(result.strategicImplications).not.toContain(' { - // Simulate the typical MCP enrichment: contentFetched=true but no fullText - const docs = [makeMetadataEnrichedPropDoc(), makePropDoc()]; - const options = makeOptions(); - const initial = await aiAnalysisPipeline.analyzeDocuments(docs, options); - // enrichedCount should reflect metadata enrichment from iteration 1 - expect(initial.enrichedCount).toBe(1); - const refined = await aiAnalysisPipeline.refineAnalysis(initial, docs, options); - // enrichedCount should still be 1 after refinement (metadata-enriched count preserved) - expect(refined.enrichedCount).toBe(1); + it('produces localized signal text for Swedish (not English)', () => { + const pipeline = new AIAnalysisPipeline(); + const result = pipeline.analyze([PROP, BET, MOT], null, 'sv'); + // Swedish template should not contain "active government agenda-setting" (English) + expect(result.strategicImplications).not.toContain('active government'); + }); }); - it('produces higher confidence score with enriched documents', async () => { - const docs = [makeEnrichedPropDoc(), makePropDoc(), makeMotDoc()]; - const options = makeOptions(); - const initial = await aiAnalysisPipeline.analyzeDocuments(docs, options); - const refined = await aiAnalysisPipeline.refineAnalysis(initial, docs, options); - // Confidence should be >= initial since enriched docs add evidence - expect(refined.confidenceScore).toBeGreaterThanOrEqual(initial.confidenceScore); - }); + // ── Multi-language support ───────────────────────────────────────────────── - it('includes enriched document content in SWOT entry text', async () => { - const enrichedDoc = makeEnrichedPropDoc(); - const docs = [enrichedDoc]; - const options = makeOptions({ lang: 'en' }); - const initial = await aiAnalysisPipeline.analyzeDocuments(docs, options); - const refined = await aiAnalysisPipeline.refineAnalysis(initial, docs, options); - - // Find government stakeholder - const govSh = refined.stakeholderSwot.find(sh => sh.role === 'government'); - expect(govSh).toBeDefined(); - - // At least one HIGH confidence entry should exist (from enriched full text) - const allEntries = [ - ...(govSh?.swot.strengths ?? []), - ...(govSh?.swot.weaknesses ?? []), - ...(govSh?.swot.opportunities ?? []), - ...(govSh?.swot.threats ?? []), - ]; - const highConfidenceEntries = allEntries.filter(e => e.confidence === 'HIGH'); - expect(highConfidenceEntries.length).toBeGreaterThan(0); - }); + describe('multi-language support', () => { + const LANGUAGES = ['en', 'sv', 'da', 'no', 'fi', 'de', 'fr', 'es', 'nl', 'ar', 'he', 'ja', 'ko', 'zh'] as const; - it('sets enrichedCount to the number of metadata-enriched documents', async () => { - // makeEnrichedPropDoc has contentFetched:true + fullText - // makeMetadataEnrichedPropDoc has contentFetched:true but no fullText - // makePropDoc has neither - const docs = [makeEnrichedPropDoc(), makeMetadataEnrichedPropDoc(), makePropDoc()]; - const options = makeOptions(); - const initial = await aiAnalysisPipeline.analyzeDocuments(docs, options); - const refined = await aiAnalysisPipeline.refineAnalysis(initial, docs, options); - // enrichedCount reflects metadata enrichment (contentFetched), not just full-text - expect(refined.enrichedCount).toBe(2); - }); + LANGUAGES.forEach(lang => { + it(`produces non-empty strategic implications for ${lang}`, () => { + const pipeline = new AIAnalysisPipeline({ iterations: 1 }); + const result = pipeline.analyze([PROP, BET], 'policy', lang); + expect(result.strategicImplications.length).toBeGreaterThan(0); + }); - it('sets completedAt to a valid ISO timestamp', async () => { - const docs = [makePropDoc()]; - const initial = await aiAnalysisPipeline.analyzeDocuments(docs, makeOptions()); - const refined = await aiAnalysisPipeline.refineAnalysis(initial, docs, makeOptions()); - expect(() => new Date(refined.completedAt)).not.toThrow(); - expect(new Date(refined.completedAt).getTime()).toBeGreaterThan(0); + it(`produces non-empty SWOT entries for ${lang}`, () => { + const pipeline = new AIAnalysisPipeline({ iterations: 1 }); + const result = pipeline.analyze([PROP, MOT], 'security', lang); + expect(result.dynamicSwotEntries.government.strengths.length).toBeGreaterThan(0); + }); + }); }); -}); -// --------------------------------------------------------------------------- -// validateCompleteness (Iteration 3) -// --------------------------------------------------------------------------- - -describe('aiAnalysisPipeline.validateCompleteness', () => { - it('returns a ValidationResult with score and issues', async () => { - const docs = [makePropDoc(), makeBetDoc(), makeMotDoc()]; - const options = makeOptions(); - const initial = await aiAnalysisPipeline.analyzeDocuments(docs, options); - const refined = await aiAnalysisPipeline.refineAnalysis(initial, docs, options); - const validation = await aiAnalysisPipeline.validateCompleteness(refined, docs); - - expect(validation.score).toBeGreaterThanOrEqual(0); - expect(validation.score).toBeLessThanOrEqual(100); - expect(validation.passed).toBeDefined(); - expect(validation.issues).toBeInstanceOf(Array); - expect(validation.suggestions).toBeInstanceOf(Array); - }); + // ── Iteration count ─────────────────────────────────────────────────────── - it('passes for a well-populated analysis', async () => { - const docs = [makePropDoc(), makeBetDoc(), makeMotDoc(), makeEnrichedPropDoc()]; - const options = makeOptions(); - const initial = await aiAnalysisPipeline.analyzeDocuments(docs, options); - const refined = await aiAnalysisPipeline.refineAnalysis(initial, docs, options); - const validation = await aiAnalysisPipeline.validateCompleteness(refined, docs); + describe('--iterations parameter', () => { + it('iteration=1 produces a valid result', () => { + const pipeline = new AIAnalysisPipeline({ iterations: 1 }); + const result = pipeline.analyze([PROP], null, 'en'); + expect(result.iterations).toBe(1); + expect(result.analysisScore).toBeGreaterThanOrEqual(0); + }); - expect(validation.passed).toBe(true); - expect(validation.score).toBeGreaterThanOrEqual(60); - }); + it('iteration=5 produces a valid result', () => { + const pipeline = new AIAnalysisPipeline({ iterations: 5 }); + const result = pipeline.analyze(ALL_DOCS, 'climate', 'en'); + expect(result.iterations).toBe(5); + }); - it('reports issue when no documents are enriched', async () => { - const docs = [makePropDoc()]; // not enriched - const options = makeOptions(); - let analysis = await aiAnalysisPipeline.analyzeDocuments(docs, options); - analysis = await aiAnalysisPipeline.refineAnalysis(analysis, docs, options); - const validation = await aiAnalysisPipeline.validateCompleteness(analysis, docs); + it('default iterations is 3', () => { + const pipeline = new AIAnalysisPipeline(); + const result = pipeline.analyze([PROP], null, 'en'); + expect(result.iterations).toBe(3); + }); - const hasEnrichmentIssue = validation.issues.some(i => i.toLowerCase().includes('enriched') || i.toLowerCase().includes('full text')); - expect(hasEnrichmentIssue).toBe(true); + it('analysis score with 3 iterations >= score with 1 iteration on rich document set', () => { + const pipeline1 = new AIAnalysisPipeline({ iterations: 1 }); + const pipeline3 = new AIAnalysisPipeline({ iterations: 3 }); + const r1 = pipeline1.analyze(ALL_DOCS, 'security', 'en'); + const r3 = pipeline3.analyze(ALL_DOCS, 'security', 'en'); + // With 3 iterations, analysis score should be at least as good + expect(r3.analysisScore).toBeGreaterThanOrEqual(r1.analysisScore); + }); }); - it('suggests full text when only metadata-enriched', async () => { - const docs = [makeMetadataEnrichedPropDoc()]; // contentFetched but no fullText - const options = makeOptions(); - let analysis = await aiAnalysisPipeline.analyzeDocuments(docs, options); - analysis = await aiAnalysisPipeline.refineAnalysis(analysis, docs, options); - const validation = await aiAnalysisPipeline.validateCompleteness(analysis, docs); - - // enrichedCount should be 1 (metadata-enriched), but suggestions should - // mention full text since no fullText/fullContent is available. - expect(analysis.enrichedCount).toBe(1); - const hasSuggestion = validation.suggestions.some(s => s.toLowerCase().includes('full text')); - expect(hasSuggestion).toBe(true); - }); -}); + // ── Empty / edge-case input ─────────────────────────────────────────────── -// --------------------------------------------------------------------------- -// runAnalysisPipeline — depth-controlled dispatch -// --------------------------------------------------------------------------- + describe('edge cases', () => { + it('handles empty document array gracefully', () => { + const pipeline = new AIAnalysisPipeline(); + const result = pipeline.analyze([], null, 'en'); + expect(result.documentAnalyses).toHaveLength(0); + expect(result.keyTakeaways).toHaveLength(0); + }); -describe('runAnalysisPipeline', () => { - it('quick depth: runs 1 iteration, no validation', async () => { - const docs = [makePropDoc()]; - const { analysis, validation, iterationDurationsMs } = await runAnalysisPipeline(docs, makeOptions({ depth: 'quick' })); - expect(analysis.iterationsCompleted).toBe(1); - expect(validation).toBeNull(); - expect(iterationDurationsMs).toHaveLength(1); - }); + it('handles single document gracefully', () => { + const pipeline = new AIAnalysisPipeline(); + const result = pipeline.analyze([PROP], null, 'en'); + expect(result.documentAnalyses).toHaveLength(1); + }); - it('standard depth: runs 2 iterations, no validation', async () => { - const docs = [makePropDoc(), makeMotDoc()]; - const { analysis, validation, iterationDurationsMs } = await runAnalysisPipeline(docs, makeOptions({ depth: 'standard' })); - expect(analysis.iterationsCompleted).toBe(2); - expect(validation).toBeNull(); - expect(iterationDurationsMs).toHaveLength(2); - }); + it('handles documents with no title gracefully', () => { + const emptyDoc = makeDoc({ dok_id: 'EMPTY', titel: undefined, title: undefined }); + const pipeline = new AIAnalysisPipeline(); + expect(() => pipeline.analyze([emptyDoc], null, 'en')).not.toThrow(); + }); - it('deep depth: runs 3 iterations (incl. validation)', async () => { - const docs = [makePropDoc(), makeBetDoc()]; - const { analysis, validation, iterationDurationsMs } = await runAnalysisPipeline(docs, makeOptions({ depth: 'deep' })); - expect(analysis.iterationsCompleted).toBe(3); - expect(validation).not.toBeNull(); - expect(validation?.score).toBeDefined(); - expect(iterationDurationsMs).toHaveLength(3); - }); + it('clamps negative iterations to 1', () => { + const pipeline = new AIAnalysisPipeline({ iterations: -5 }); + const result = pipeline.analyze([PROP], null, 'en'); + expect(result.iterations).toBe(1); + }); - it('deep depth with enriched docs: validation passes', async () => { - const docs = [makeEnrichedPropDoc(), makeBetDoc(), makeMotDoc()]; - const { validation } = await runAnalysisPipeline(docs, makeOptions({ depth: 'deep' })); - expect(validation?.passed).toBe(true); - }); + it('clamps iterations > 10 to 10', () => { + const pipeline = new AIAnalysisPipeline({ iterations: 15 }); + const result = pipeline.analyze([PROP], null, 'en'); + expect(result.iterations).toBe(10); + }); - it('returns analysis in correct language', async () => { - const docs = [makePropDoc()]; - const { analysis } = await runAnalysisPipeline(docs, makeOptions({ lang: 'sv', depth: 'quick' })); - expect(analysis.lang).toBe('sv'); - }); + it('floors fractional iterations to integer', () => { + const pipeline = new AIAnalysisPipeline({ iterations: 2.7 }); + const result = pipeline.analyze([PROP], null, 'en'); + expect(result.iterations).toBe(2); + expect(Number.isInteger(result.iterations)).toBe(true); + }); - it('includes focusTopic in analysis result', async () => { - const docs = [makePropDoc()]; - const { analysis } = await runAnalysisPipeline(docs, makeOptions({ focusTopic: 'defence policy', depth: 'quick' })); - expect(analysis.focusTopic).toBe('defence policy'); - }); + it('enrichedCount uses contentFetched (consistent with codebase convention)', () => { + // contentFetched=false → should NOT count as enriched + const notFetched = makeDoc({ + dok_id: 'E1', doktyp: 'prop', contentFetched: false, + }); + const notFetched2 = makeDoc({ + dok_id: 'E2', doktyp: 'bet', + }); + const notFetched3 = makeDoc({ + dok_id: 'E3', doktyp: 'mot', contentFetched: false, + }); + const pipeline = new AIAnalysisPipeline({ iterations: 3 }); + const result = pipeline.analyze([notFetched, notFetched2, notFetched3], null, 'en'); + // None have contentFetched=true, so enrichedCount=0 → no enriched takeaway + const enrichedTakeaway = result.keyTakeaways.find(t => t.includes('enriched')); + expect(enrichedTakeaway).toBeUndefined(); + + // Now add docs WITH contentFetched=true — should produce enriched takeaway + const enriched1 = makeDoc({ + dok_id: 'E4', doktyp: 'prop', contentFetched: true, + }); + const enriched2 = makeDoc({ + dok_id: 'E5', doktyp: 'bet', contentFetched: true, + }); + const result2 = pipeline.analyze([enriched1, enriched2], null, 'en'); + const enrichedTakeaway2 = result2.keyTakeaways.find(t => t.includes('enriched')); + expect(enrichedTakeaway2).toBeDefined(); + expect(enrichedTakeaway2).toContain('2 of 2'); + }); - it('handles empty document array gracefully', async () => { - const { analysis, validation } = await runAnalysisPipeline([], makeOptions({ depth: 'deep' })); - expect(analysis.documentCount).toBe(0); - expect(validation).not.toBeNull(); + it('SFS/SKR-only inputs use regulatory snapshot, not misleading press/ext text', () => { + const sfsDoc = makeDoc({ dok_id: 'SFS1', doktyp: 'sfs', titel: 'SFS 2026:1' }); + const skrDoc = makeDoc({ dok_id: 'SKR1', doktyp: 'skr', titel: 'Skrivelse' }); + const pipeline = new AIAnalysisPipeline({ iterations: 3 }); + const result = pipeline.analyze([sfsDoc, skrDoc], null, 'en'); + // Should NOT contain "0 external references" or "0 press releases" + expect(result.strategicImplications).not.toContain('0 external'); + expect(result.strategicImplications).not.toContain('0 press'); + // Should contain regulatory language + expect(result.strategicImplications).toMatch(/regulatory|snapshot|parliamentary/i); + }); }); }); // --------------------------------------------------------------------------- -// SWOT entry content quality +// AnalysisCache // --------------------------------------------------------------------------- -describe('SWOT entry content quality', () => { - it('entries from propositions have document title content', async () => { - const docs = [makePropDoc()]; - const result = await aiAnalysisPipeline.analyzeDocuments(docs, makeOptions()); - const govSh = result.stakeholderSwot.find(sh => sh.role === 'government'); - // Government strength should reference the proposition - const allText = govSh?.swot.strengths.map(e => e.text).join(' ') ?? ''; - // The text should contain meaningful content about the proposition - expect(allText.trim().length).toBeGreaterThan(10); +describe('AnalysisCache', () => { + let cache: AnalysisCache; + + beforeEach(() => { + cache = new AnalysisCache(); }); - it('entries from enriched docs have HIGH confidence', async () => { - const docs = [makeEnrichedPropDoc()]; - const options = makeOptions({ depth: 'standard' }); - const initial = await aiAnalysisPipeline.analyzeDocuments(docs, options); - const refined = await aiAnalysisPipeline.refineAnalysis(initial, docs, options); - - const allEntries = refined.stakeholderSwot.flatMap(sh => [ - ...sh.swot.strengths, - ...sh.swot.weaknesses, - ...sh.swot.opportunities, - ...sh.swot.threats, - ]); - const highConfidence = allEntries.filter(e => e.confidence === 'HIGH'); - expect(highConfidence.length).toBeGreaterThan(0); + it('returns undefined for a missing key', () => { + expect(cache.get('nonexistent-key')).toBeUndefined(); }); - it('placeholder entries have LOW confidence', async () => { - // Only provide documents that don't match any SWOT quadrant mappings - const euDoc: RawDocument = { dok_id: 'EU001', doktyp: 'fpm', titel: 'EU faktapromemoria om dataskyddsförordning' }; - const result = await aiAnalysisPipeline.analyzeDocuments([euDoc], makeOptions()); + it('stores and retrieves a result', () => { + const fakeResult = { iterations: 1, analysisScore: 80 } as Parameters[1]; + cache.set('key-1', fakeResult); + expect(cache.get('key-1')).toBe(fakeResult); + }); - // Government weaknesses would be empty without bet docs → placeholder - const govSh = result.stakeholderSwot.find(sh => sh.role === 'government'); - const weakPlaceholders = govSh?.swot.weaknesses.filter(e => e.confidence === 'LOW') ?? []; - // With no bet docs, government weakness is a placeholder - expect(weakPlaceholders.length).toBeGreaterThanOrEqual(1); + it('reflects stored entry in size', () => { + const fakeResult = { iterations: 1 } as Parameters[1]; + cache.set('key-a', fakeResult); + cache.set('key-b', fakeResult); + expect(cache.size).toBe(2); }); - it('XSS: pipeline returns plain text; escaping deferred to render site', async () => { - const xssDoc: RawDocument = { - dok_id: 'XSS001', - doktyp: 'prop', - titel: '', - }; - const result = await aiAnalysisPipeline.analyzeDocuments([xssDoc], makeOptions()); - const titleEntries = result.stakeholderSwot - .flatMap(sh => sh.swot.strengths) - .filter(e => e.sourceDocIds.includes('XSS001')); - - // Pipeline returns plain text for ALL outputs — SWOT entries, mindmap items, - // dashboard labels, and watch points. HTML-escaping is the responsibility - // of downstream renderers (generateStakeholderSwotSection, generateMindmapSection, - // generateDashboardSection) which call escapeHtml() on all interpolated text. - // For watch points, the deep-inspection call site in generators.ts escapes - // AI-derived watch points before passing them to generateWatchSection(), - // which renders pre-escaped HTML (to stay compatible with extractWatchPoints() - // which already escapes and injects svSpan() HTML markers). - for (const entry of titleEntries) { - expect(entry.text).toContain('