diff --git a/.github/workflows/news-evening-analysis.md b/.github/workflows/news-evening-analysis.md index 291c90badd..7cc93d60a2 100644 --- a/.github/workflows/news-evening-analysis.md +++ b/.github/workflows/news-evening-analysis.md @@ -247,6 +247,18 @@ const weekFromDate = new Date(Date.now() - 5 * 86400000).toISOString().slice(0, - `get_propositioner` — filter by `publicerad` date - `search_anforanden` — filter by `datum` field +Filter results to only include items with dates `>= fromDate` using timezone-safe ISO string comparison: + +For tools without native date support, apply a post-query date filter: + +```javascript +// Calculate lookback window (e.g. 24 hours = 86400000 ms, 1 hour = 3600000 ms) +const fromDate = new Date(Date.now() - 24 * 3600000).toISOString().slice(0, 10); +const results = queryResults.filter( + item => (item.publicerad || item.datum || item.inlämnad || '').slice(0, 10) >= fromDate +); +``` + 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 => diff --git a/scripts/data-transformers/constants/content-labels-part1.ts b/scripts/data-transformers/constants/content-labels-part1.ts index 576cf266b1..34fff0708c 100644 --- a/scripts/data-transformers/constants/content-labels-part1.ts +++ b/scripts/data-transformers/constants/content-labels-part1.ts @@ -121,6 +121,10 @@ export const CONTENT_LABELS_PART1: Partial> = { swotImpactHigh: 'High', swotImpactMedium: 'Medium', swotImpactLow: 'Low', + swotJustification: 'Analysis', + swotTrendImproving: 'Improving', + swotTrendStable: 'Stable', + swotTrendDeteriorating: 'Deteriorating', dashboardTitle: 'Dashboard', dashboardSummary: 'Summary', dashboardPanel: 'Panel', @@ -240,6 +244,10 @@ export const CONTENT_LABELS_PART1: Partial> = { swotImpactHigh: 'Hög', swotImpactMedium: 'Medel', swotImpactLow: 'Låg', + swotJustification: 'Analys', + swotTrendImproving: 'Förbättras', + swotTrendStable: 'Stabil', + swotTrendDeteriorating: 'Försämras', dashboardTitle: 'Instrumentpanel', dashboardSummary: 'Sammanfattning', dashboardPanel: 'Panel', @@ -359,6 +367,10 @@ export const CONTENT_LABELS_PART1: Partial> = { swotImpactHigh: 'Høj', swotImpactMedium: 'Middel', swotImpactLow: 'Lav', + swotJustification: 'Analyse', + swotTrendImproving: 'Forbedres', + swotTrendStable: 'Stabil', + swotTrendDeteriorating: 'Forværres', dashboardTitle: 'Dashboard', dashboardSummary: 'Sammenfatning', dashboardPanel: 'Panel', @@ -478,6 +490,10 @@ export const CONTENT_LABELS_PART1: Partial> = { swotImpactHigh: 'Høy', swotImpactMedium: 'Middels', swotImpactLow: 'Lav', + swotJustification: 'Analyse', + swotTrendImproving: 'Forbedres', + swotTrendStable: 'Stabil', + swotTrendDeteriorating: 'Forverres', dashboardTitle: 'Dashbord', dashboardSummary: 'Sammendrag', dashboardPanel: 'Panel', @@ -597,6 +613,10 @@ export const CONTENT_LABELS_PART1: Partial> = { swotImpactHigh: 'Korkea', swotImpactMedium: 'Keskitaso', swotImpactLow: 'Matala', + swotJustification: 'Analyysi', + swotTrendImproving: 'Paranee', + swotTrendStable: 'Vakaa', + swotTrendDeteriorating: 'Heikkenee', dashboardTitle: 'Kojelauta', dashboardSummary: 'Yhteenveto', dashboardPanel: 'Paneeli', @@ -716,6 +736,10 @@ export const CONTENT_LABELS_PART1: Partial> = { swotImpactHigh: 'Hoch', swotImpactMedium: 'Mittel', swotImpactLow: 'Niedrig', + swotJustification: 'Analyse', + swotTrendImproving: 'Verbessernd', + swotTrendStable: 'Stabil', + swotTrendDeteriorating: 'Verschlechternd', dashboardTitle: 'Dashboard', dashboardSummary: 'Zusammenfassung', dashboardPanel: 'Panel', @@ -835,6 +859,10 @@ export const CONTENT_LABELS_PART1: Partial> = { swotImpactHigh: 'Élevé', swotImpactMedium: 'Moyen', swotImpactLow: 'Faible', + swotJustification: 'Analyse', + swotTrendImproving: 'En amélioration', + swotTrendStable: 'Stable', + swotTrendDeteriorating: 'En détérioration', dashboardTitle: 'Tableau de bord', dashboardSummary: 'Résumé', dashboardPanel: 'Panneau', diff --git a/scripts/data-transformers/constants/content-labels-part2.ts b/scripts/data-transformers/constants/content-labels-part2.ts index 1bef2d8a53..c4c598b875 100644 --- a/scripts/data-transformers/constants/content-labels-part2.ts +++ b/scripts/data-transformers/constants/content-labels-part2.ts @@ -121,6 +121,10 @@ export const CONTENT_LABELS_PART2: Partial> = { swotImpactHigh: 'Alto', swotImpactMedium: 'Medio', swotImpactLow: 'Bajo', + swotJustification: 'Análisis', + swotTrendImproving: 'Mejorando', + swotTrendStable: 'Estable', + swotTrendDeteriorating: 'Deteriorando', dashboardTitle: 'Panel de control', dashboardSummary: 'Resumen', dashboardPanel: 'Panel', @@ -240,6 +244,10 @@ export const CONTENT_LABELS_PART2: Partial> = { swotImpactHigh: 'Hoog', swotImpactMedium: 'Gemiddeld', swotImpactLow: 'Laag', + swotJustification: 'Analyse', + swotTrendImproving: 'Verbeterend', + swotTrendStable: 'Stabiel', + swotTrendDeteriorating: 'Verslechterend', dashboardTitle: 'Dashboard', dashboardSummary: 'Samenvatting', dashboardPanel: 'Paneel', @@ -359,6 +367,10 @@ export const CONTENT_LABELS_PART2: Partial> = { swotImpactHigh: 'مرتفع', swotImpactMedium: 'متوسط', swotImpactLow: 'منخفض', + swotJustification: 'تحليل', + swotTrendImproving: 'تحسن', + swotTrendStable: 'مستقر', + swotTrendDeteriorating: 'تدهور', dashboardTitle: 'لوحة المعلومات', dashboardSummary: 'ملخص', dashboardPanel: 'لوحة', @@ -478,6 +490,10 @@ export const CONTENT_LABELS_PART2: Partial> = { swotImpactHigh: 'גבוה', swotImpactMedium: 'בינוני', swotImpactLow: 'נמוך', + swotJustification: 'ניתוח', + swotTrendImproving: 'משתפר', + swotTrendStable: 'יציב', + swotTrendDeteriorating: 'מתדרדר', dashboardTitle: 'לוח מחוונים', dashboardSummary: 'סיכום', dashboardPanel: 'לוח', @@ -597,6 +613,10 @@ export const CONTENT_LABELS_PART2: Partial> = { swotImpactHigh: '高', swotImpactMedium: '中', swotImpactLow: '低', + swotJustification: '分析', + swotTrendImproving: '改善中', + swotTrendStable: '安定', + swotTrendDeteriorating: '悪化中', dashboardTitle: 'ダッシュボード', dashboardSummary: '概要', dashboardPanel: 'パネル', @@ -716,6 +736,10 @@ export const CONTENT_LABELS_PART2: Partial> = { swotImpactHigh: '높음', swotImpactMedium: '보통', swotImpactLow: '낮음', + swotJustification: '분석', + swotTrendImproving: '개선 중', + swotTrendStable: '안정', + swotTrendDeteriorating: '악화 중', dashboardTitle: '대시보드', dashboardSummary: '요약', dashboardPanel: '패널', @@ -835,6 +859,10 @@ export const CONTENT_LABELS_PART2: Partial> = { swotImpactHigh: '高', swotImpactMedium: '中', swotImpactLow: '低', + swotJustification: '分析', + swotTrendImproving: '改善中', + swotTrendStable: '稳定', + swotTrendDeteriorating: '恶化中', dashboardTitle: '仪表板', dashboardSummary: '摘要', dashboardPanel: '面板', diff --git a/scripts/data-transformers/content-generators/ai-swot-analyzer.ts b/scripts/data-transformers/content-generators/ai-swot-analyzer.ts new file mode 100644 index 0000000000..60c4df7ce2 --- /dev/null +++ b/scripts/data-transformers/content-generators/ai-swot-analyzer.ts @@ -0,0 +1,1091 @@ +/** + * @module data-transformers/content-generators/ai-swot-analyzer + * @description Heuristic multi-perspective SWOT analysis builder. + * + * Produces substantive analytical SWOT entries for 6 stakeholder perspectives + * using document-type-aware routing and rules-based reasoning over parliamentary + * document metadata, summaries, and topics. Each entry carries justification, + * trend direction, confidence scoring, and optional quantitative evidence — + * rather than raw document title truncation. + * + * Note: the current implementation is deterministic and rules-based. It does + * NOT invoke LLM or MCP prompts; "AI" in the public API names is aspirational + * and kept for interface stability. A future iteration may wire the prompts + * into the editorial-framework MCP pipeline. + * + * Analytical prose (entry text, justification) is English-only even though the + * deep-inspection pipeline renders articles in all 14 language versions. + * Stakeholder names, roles, and context metadata labels ("Confidence", + * "Cross-references") are fully localised in all 14 supported languages. + * + * The six perspectives are: + * 1. Government Coalition (M, KD, L + SD support) + * 2. Social Democratic Opposition (S, V, C, MP) + * 3. EU / International Actors + * 4. Private Sector & Business + * 5. Civil Society & NGOs + * 6. Swedish Citizens / Voters + * + * @author Hack23 AB + * @license Apache-2.0 + */ + +import type { Language } from '../../types/language.js'; +import type { SwotImpact, TrendDirection } from '../../types/article.js'; +import type { StakeholderSwot } from './stakeholder-swot-section.js'; +import type { RawDocument } from '../types.js'; + +// TrendDirection is canonically defined in types/article.ts — import from there directly + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** One of the six recognised stakeholder perspectives */ +export type StakeholderPerspective = + | 'government-coalition' + | 'opposition' + | 'eu-international' + | 'private-sector' + | 'civil-society' + | 'citizens-voters'; + +/** + * Enhanced SWOT entry with AI-generated analytical content. + * Extends the base `SwotEntry` shape with additional intelligence fields. + */ +export interface AISwotEntry { + /** Short analytical statement (the main SWOT item) */ + text: string; + /** Relative policy impact or significance */ + impact: SwotImpact; + /** Reasoning explaining why this item was included */ + justification: string; + /** IDs or titles of related documents that support this entry */ + relatedDocuments: string[]; + /** Whether this factor is getting better, static, or worse */ + trendDirection: TrendDirection; + /** Optional metric supporting the entry (e.g. "73% majority", "SEK 2.1 bn") */ + quantitativeEvidence?: string; +} + +/** A link connecting a SWOT entry in one stakeholder analysis to another */ +export interface SwotCrossReference { + fromStakeholder: StakeholderPerspective; + fromQuadrant: 'strengths' | 'weaknesses' | 'opportunities' | 'threats'; + toStakeholder: StakeholderPerspective; + toQuadrant: 'strengths' | 'weaknesses' | 'opportunities' | 'threats'; + rationale: string; +} + +/** Full AI SWOT analysis for a single stakeholder */ +export interface AISwotAnalysis { + stakeholder: string; + perspective: StakeholderPerspective; + strengths: AISwotEntry[]; + weaknesses: AISwotEntry[]; + opportunities: AISwotEntry[]; + threats: AISwotEntry[]; + crossReferences: SwotCrossReference[]; + /** Overall analytical confidence (0–1) */ + confidenceScore: number; +} + +// --------------------------------------------------------------------------- +// Localised stakeholder names (all 14 languages) +// --------------------------------------------------------------------------- + +export const STAKEHOLDER_NAMES: Readonly>>> = { + 'government-coalition': { + en: 'Government Coalition', + sv: 'Regeringskoalitionen', + da: 'Regeringskoalitionen', + no: 'Regjeringskoalisjonen', + fi: 'Hallituskoalitio', + de: 'Regierungskoalition', + fr: 'Coalition gouvernementale', + es: 'Coalición gubernamental', + nl: 'Regeringscoalitie', + ar: 'الائتلاف الحكومي', + he: 'הקואליציה הממשלתית', + ja: '政府連立', + ko: '정부 연립', + zh: '执政联盟', + }, + opposition: { + en: 'Social Democratic Opposition', + sv: 'Socialdemokratisk opposition', + da: 'Socialdemokratisk opposition', + no: 'Sosialdemokratisk opposisjon', + fi: 'Sosiaalidemokraattinen oppositio', + de: 'Sozialdemokratische Opposition', + fr: 'Opposition sociale-démocrate', + es: 'Oposición socialdemócrata', + nl: 'Sociaaldemocratische oppositie', + ar: 'المعارضة الاشتراكية الديمقراطية', + he: 'האופוזיציה הסוציאל-דמוקרטית', + ja: '社会民主主義野党', + ko: '사회민주주의 야당', + zh: '社会民主主义反对派', + }, + 'eu-international': { + en: 'EU & International Actors', + sv: 'EU och internationella aktörer', + da: 'EU og internationale aktører', + no: 'EU og internasjonale aktører', + fi: 'EU ja kansainväliset toimijat', + de: 'EU & Internationale Akteure', + fr: 'UE et acteurs internationaux', + es: 'UE y actores internacionales', + nl: 'EU & Internationale actoren', + ar: 'الاتحاد الأوروبي والجهات الدولية', + he: 'האיחוד האירופי וגורמים בינלאומיים', + ja: 'EUと国際的アクター', + ko: 'EU 및 국제 행위자', + zh: '欧盟与国际行为者', + }, + 'private-sector': { + en: 'Private Sector & Business', + sv: 'Privat sektor och näringsliv', + da: 'Privat sektor og erhvervsliv', + no: 'Privat sektor og næringsliv', + fi: 'Yksityissektori ja elinkeinoelämä', + de: 'Privatwirtschaft & Unternehmen', + fr: 'Secteur privé & entreprises', + es: 'Sector privado y empresas', + nl: 'Private sector & bedrijfsleven', + ar: 'القطاع الخاص والأعمال', + he: 'המגזר הפרטי והעסקים', + ja: '民間セクターとビジネス', + ko: '민간 부문 및 기업', + zh: '私营部门与商业', + }, + 'civil-society': { + en: 'Civil Society & NGOs', + sv: 'Civilsamhälle och NGO:er', + da: 'Civilsamfund og NGO\'er', + no: 'Sivilsamfunn og frivillige organisasjoner', + fi: 'Kansalaisyhteiskunta ja kansalaisjärjestöt', + de: 'Zivilgesellschaft & NGOs', + fr: 'Société civile & ONG', + es: 'Sociedad civil y ONG', + nl: 'Maatschappelijk middenveld & NGO\'s', + ar: 'المجتمع المدني ومنظمات غير حكومية', + he: 'החברה האזרחית וארגוני מלכ"ר', + ja: '市民社会とNGO', + ko: '시민 사회 및 NGO', + zh: '公民社会与非政府组织', + }, + 'citizens-voters': { + en: 'Swedish Citizens & Voters', + sv: 'Svenska medborgare och väljare', + da: 'Svenske borgere og vælgere', + no: 'Svenske borgere og velgere', + fi: 'Ruotsalaiset kansalaiset ja äänestäjät', + de: 'Schwedische Bürger & Wähler', + fr: 'Citoyens & électeurs suédois', + es: 'Ciudadanos y votantes suecos', + nl: 'Zweedse burgers & kiezers', + ar: 'المواطنون والناخبون السويديون', + he: 'אזרחים ובוחרים שבדים', + ja: 'スウェーデン市民と有権者', + ko: '스웨덴 시민 및 유권자', + zh: '瑞典公民与选民', + }, +}; + +// --------------------------------------------------------------------------- +// Localised stakeholder roles (all 14 languages) +// --------------------------------------------------------------------------- + +const STAKEHOLDER_ROLES: Readonly>>> = { + 'government-coalition': { + en: 'Tidö Agreement parties: M, KD, L with SD support', + sv: 'Tidöavtalspartierna: M, KD, L med SD:s stöd', + da: 'Tidö-aftalepartierne: M, KD, L med SD-støtte', + no: 'Tidö-avtalepartiene: M, KD, L med SD-støtte', + fi: 'Tidö-sopimuspuolueet: M, KD, L SD:n tuella', + de: 'Tidö-Vereinbarungsparteien: M, KD, L mit SD-Unterstützung', + fr: 'Partis de l\'accord Tidö: M, KD, L avec soutien SD', + es: 'Partidos del acuerdo Tidö: M, KD, L con apoyo SD', + nl: 'Tidö-akkoordpartijen: M, KD, L met SD-steun', + ar: 'أحزاب اتفاقية تيدو: M وKD وL بدعم SD', + he: 'מפלגות הסכם טידו: M, KD, L בתמיכת SD', + ja: 'ティドー協定政党: M、KD、L(SD支持)', + ko: '티도 협정 정당: M, KD, L (SD 지원)', + zh: 'Tidö协议党派:M、KD、L,获SD支持', + }, + opposition: { + en: 'S, V, C, MP — alternative governance bloc', + sv: 'S, V, C, MP — alternativt styrningsblock', + da: 'S, V, C, MP — alternativ styringsblok', + no: 'S, V, C, MP — alternativ styringsblokk', + fi: 'S, V, C, MP — vaihtoehtoinen hallintoryhmä', + de: 'S, V, C, MP — alternatives Regierungsblock', + fr: 'S, V, C, MP — bloc de gouvernance alternatif', + es: 'S, V, C, MP — bloque de gobierno alternativo', + nl: 'S, V, C, MP — alternatief regeringsblok', + ar: 'S وV وC وMP — كتلة حوكمة بديلة', + he: 'S, V, C, MP — גוש שלטון חלופי', + ja: 'S、V、C、MP — 代替統治ブロック', + ko: 'S, V, C, MP — 대안 통치 블록', + zh: 'S、V、C、MP — 替代执政集团', + }, + 'eu-international': { + en: 'European Union institutions, international bodies & diplomatic actors', + sv: 'EU:s institutioner, internationella organ och diplomatiska aktörer', + da: 'EU-institutioner, internationale organer og diplomatiske aktører', + no: 'EU-institusjoner, internasjonale organer og diplomatiske aktører', + fi: 'EU-instituutiot, kansainväliset elimet ja diplomaattiset toimijat', + de: 'EU-Institutionen, internationale Gremien & diplomatische Akteure', + fr: 'Institutions européennes, organismes internationaux & acteurs diplomatiques', + es: 'Instituciones europeas, organismos internacionales y actores diplomáticos', + nl: 'EU-instellingen, internationale organen & diplomatieke actoren', + ar: 'المؤسسات الأوروبية والهيئات الدولية والجهات الدبلوماسية', + he: 'מוסדות האיחוד האירופי, גופים בינלאומיים ושחקנים דיפלומטיים', + ja: 'EU機関・国際機関・外交的行為者', + ko: 'EU 기관, 국제 기구 및 외교적 행위자', + zh: '欧盟机构、国际组织与外交行为者', + }, + 'private-sector': { + en: 'Companies, industry federations, employers & investors', + sv: 'Företag, branschorganisationer, arbetsgivare och investerare', + da: 'Virksomheder, brancheforeninger, arbejdsgivere og investorer', + no: 'Bedrifter, bransjeforeninger, arbeidsgivere og investorer', + fi: 'Yritykset, toimialajärjestöt, työnantajat ja sijoittajat', + de: 'Unternehmen, Branchenverbände, Arbeitgeber & Investoren', + fr: 'Entreprises, fédérations sectorielles, employeurs & investisseurs', + es: 'Empresas, federaciones sectoriales, empleadores e inversores', + nl: 'Bedrijven, brancheverenigingen, werkgevers & investeerders', + ar: 'الشركات والاتحادات القطاعية وأصحاب العمل والمستثمرون', + he: 'חברות, התאחדויות ענפיות, מעסיקים ומשקיעים', + ja: '企業・業界団体・雇用主・投資家', + ko: '기업, 업계 연합, 고용주 및 투자자', + zh: '企业、行业协会、雇主与投资者', + }, + 'civil-society': { + en: 'Trade unions, advocacy groups, human rights organisations & media', + sv: 'Fackföreningar, påtryckargrupper, människorättsorganisationer och media', + da: 'Fagforeninger, interesseorganisationer, menneskerettighedsorganisationer og medier', + no: 'Fagforeninger, interesseorganisasjoner, menneskerettighetsorganisasjoner og medier', + fi: 'Ammattiliitot, edunvalvontaryhmät, ihmisoikeusjärjestöt ja media', + de: 'Gewerkschaften, Interessengruppen, Menschenrechtsorganisationen & Medien', + fr: 'Syndicats, groupes de plaidoyer, organisations de droits humains & médias', + es: 'Sindicatos, grupos de defensa, organizaciones de derechos humanos y medios', + nl: 'Vakbonden, belangengroepen, mensenrechtenorganisaties & media', + ar: 'النقابات ومجموعات المناصرة ومنظمات حقوق الإنسان والإعلام', + he: 'ועדי עובדים, קבוצות הסברה, ארגוני זכויות אדם ותקשורת', + ja: '労働組合・権利擁護団体・人権団体・メディア', + ko: '노동조합, 권익 단체, 인권 단체 및 미디어', + zh: '工会、倡导团体、人权组织与媒体', + }, + 'citizens-voters': { + en: 'Electorate, public service users & democratic stakeholders', + sv: 'Valmanskåren, användare av offentliga tjänster och demokratiska intressenter', + da: 'Valgberettigede, brugere af offentlige tjenester og demokratiske interessenter', + no: 'Stemmeberettigede, brukere av offentlige tjenester og demokratiske interessenter', + fi: 'Äänioikeutetut, julkisten palvelujen käyttäjät ja demokraattiset sidosryhmät', + de: 'Wählerschaft, Nutzer öffentlicher Dienste & demokratische Stakeholder', + fr: 'Électorat, usagers des services publics & parties prenantes démocratiques', + es: 'Electorado, usuarios de servicios públicos y partes interesadas democráticas', + nl: 'Kiezers, gebruikers van overheidsdiensten & democratische stakeholders', + ar: 'الناخبون ومستخدمو الخدمات العامة وأصحاب المصلحة الديمقراطية', + he: 'מצביעים, משתמשי שירותים ציבוריים ובעלי עניין דמוקרטיים', + ja: '有権者・公共サービス利用者・民主的利害関係者', + ko: '유권자, 공공 서비스 이용자 및 민주적 이해 관계자', + zh: '选民、公共服务用户与民主利益相关者', + }, +}; + +// --------------------------------------------------------------------------- +// Localised context metadata labels (all 14 languages) +// --------------------------------------------------------------------------- + +const CONTEXT_LABELS: Readonly<{ + confidence: Readonly>; + crossReferences: Readonly>; +}> = { + confidence: { + en: 'Confidence', + sv: 'Konfidens', + da: 'Tillid', + no: 'Tillit', + fi: 'Luottamus', + de: 'Konfidenz', + fr: 'Confiance', + es: 'Confianza', + nl: 'Vertrouwen', + ar: 'الثقة', + he: 'ביטחון', + ja: '信頼度', + ko: '신뢰도', + zh: '置信度', + }, + crossReferences: { + en: 'Cross-references', + sv: 'Korsreferenser', + da: 'Krydsreferencer', + no: 'Kryssreferanser', + fi: 'Ristiviittaukset', + de: 'Querverweise', + fr: 'Références croisées', + es: 'Referencias cruzadas', + nl: 'Kruisverwijzingen', + ar: 'مراجع متقاطعة', + he: 'הפניות צולבות', + ja: '相互参照', + ko: '교차 참조', + zh: '交叉引用', + }, +}; + +// --------------------------------------------------------------------------- +// Document classification helpers +// --------------------------------------------------------------------------- + +/** Minimum summary length to prefer summary over title */ +const MIN_SUMMARY_LENGTH = 20; +/** Maximum characters to use from a summary */ +const MAX_SUMMARY_CHARS = 120; +/** Maximum characters to use from a document title */ +const MAX_TITLE_CHARS = 100; + +function titleOf(d: RawDocument): string { + return (d.titel || d.title || d.rubrik || d.dokumentnamn || d.dok_id || '').trim(); +} + +function summaryOf(d: RawDocument): string { + return (d.summary || d.notis || d.undertitel || '').trim(); +} + +/** Extract meaningful content for an AI entry — prefer summary over raw title */ +function contentOf(d: RawDocument, fallback: string): string { + const s = summaryOf(d); + if (s.length > MIN_SUMMARY_LENGTH) return s.slice(0, MAX_SUMMARY_CHARS); + const t = titleOf(d); + return t.length > 0 ? t.slice(0, MAX_TITLE_CHARS) : fallback; +} + +function makeAIEntry( + text: string, + impact: SwotImpact, + justification: string, + relatedDocuments: string[], + trendDirection: TrendDirection, + quantitativeEvidence?: string, +): AISwotEntry { + return { text, impact, justification, relatedDocuments, trendDirection, quantitativeEvidence }; +} + +function docEntry( + d: RawDocument, + defaultText: string, + impact: SwotImpact, + justification: string, + trendDirection: TrendDirection, + quantEvidence?: string, +): AISwotEntry { + return makeAIEntry( + contentOf(d, defaultText), + impact, + justification, + [titleOf(d) || d.dok_id || ''].filter(Boolean), + trendDirection, + quantEvidence, + ); +} + +// --------------------------------------------------------------------------- +// Topic-aware analytical statement builder +// --------------------------------------------------------------------------- + +/** Build a topical statement: "X in {topic}" or strip %t placeholder when absent */ +function withTopic(template: string, topic: string | null): string { + if (!topic) return template.replaceAll('%t', ''); + return template.replaceAll('%t', topic); +} + +// --------------------------------------------------------------------------- +// Pre-bucketed document classification (single pass) +// --------------------------------------------------------------------------- + +/** Pre-classified document buckets to avoid repeated filtering in each builder */ +interface DocBuckets { + prop: RawDocument[]; + skr: RawDocument[]; + sfs: RawDocument[]; + pressm: RawDocument[]; + bet: RawDocument[]; + fpm: RawDocument[]; + mot: RawDocument[]; + ext: RawDocument[]; + other: RawDocument[]; +} + +/** Classify docs into type buckets in a single pass */ +function bucketDocs(docs: RawDocument[]): DocBuckets { + const b: DocBuckets = { prop: [], skr: [], sfs: [], pressm: [], bet: [], fpm: [], mot: [], ext: [], other: [] }; + for (const d of docs) { + const t = d.doktyp || d.documentType || ''; + switch (t) { + case 'prop': b.prop.push(d); break; + case 'skr': b.skr.push(d); break; + case 'sfs': b.sfs.push(d); break; + case 'pressm': b.pressm.push(d); break; + case 'bet': b.bet.push(d); break; + case 'fpm': b.fpm.push(d); break; + case 'mot': b.mot.push(d); break; + case 'ext': b.ext.push(d); break; + default: + // SFS-by-name heuristic (doktyp missing but dokumentnamn starts with SFS) + if ((d.dokumentnamn || '').startsWith('SFS')) { b.sfs.push(d); } + else { b.other.push(d); } + break; + } + } + return b; +} + +/** Sum of all bucketed documents */ +function bucketTotal(b: DocBuckets): number { + return b.prop.length + b.skr.length + b.sfs.length + b.pressm.length + + b.bet.length + b.fpm.length + b.mot.length + b.ext.length + + b.other.length; +} + +// --------------------------------------------------------------------------- +// Per-stakeholder SWOT builders +// --------------------------------------------------------------------------- + +function buildGovernmentSwot( + b: DocBuckets, + topic: string | null, + _lang: Language, +): Pick { + const { prop: propDocs, skr: skrDocs, sfs: sfsDocs, pressm: pressmDocs, bet: betDocs, fpm: euDocs, mot: motDocs } = b; + + const topicStr = topic ? ` on ${topic}` : ''; + const total = bucketTotal(b); + const docCount = `${total} parliamentary document${total !== 1 ? 's' : ''} examined`; + + const strengths: AISwotEntry[] = []; + propDocs.slice(0, 2).forEach(d => { + strengths.push(docEntry( + d, withTopic('Government proposition%t', topicStr ? ` ${topic}` : null), + 'high', + `Government-initiated proposition demonstrates legislative agenda-setting capacity${topicStr}`, + 'stable', + )); + }); + sfsDocs.slice(0, 1).forEach(d => { + strengths.push(docEntry( + d, withTopic('Enacted law%t', topicStr ? ` ${topic}` : null), + 'high', + `Enacted statute indicates completed legislative cycle${topicStr}`, + 'stable', + )); + }); + pressmDocs.slice(0, 1).forEach(d => { + strengths.push(docEntry( + d, withTopic('Government communication%t', topicStr ? ` ${topic}` : null), + 'medium', + `Press communication signals proactive policy messaging${topicStr}`, + 'stable', + )); + }); + skrDocs.slice(0, 1).forEach(d => { + strengths.push(docEntry( + d, withTopic('Government written communication (skrivelse)%t', topicStr ? ` ${topic}` : null), + 'medium', + `Government skrivelse conveys policy position or report to parliament${topicStr}`, + 'stable', + )); + }); + if (strengths.length === 0) { + strengths.push(makeAIEntry( + withTopic('Policy initiative and agenda-setting%t', topicStr ? ` on ${topic}` : ''), + 'medium', + `Government holds exclusive right to introduce primary legislation${topicStr}`, + [], + 'stable', + )); + } + + const weaknesses: AISwotEntry[] = []; + betDocs.slice(0, 2).forEach(d => { + weaknesses.push(docEntry( + d, 'Implementation scrutiny in committee report', + 'medium', + `Committee scrutiny reveals implementation challenges${topicStr}`, + 'stable', + )); + }); + if (weaknesses.length === 0) { + weaknesses.push(makeAIEntry( + withTopic('Implementation timeline and resource prioritisation%t', topicStr ? ` for ${topic}` : ''), + 'medium', + `Complex legislation requires sustained administrative capacity${topicStr}`, + [], + 'stable', + )); + } + + const opportunities: AISwotEntry[] = []; + euDocs.slice(0, 2).forEach(d => { + opportunities.push(docEntry( + d, 'EU framework position paper', + 'high', + `EU/international alignment can strengthen domestic policy credibility${topicStr}`, + 'improving', + )); + }); + if (opportunities.length === 0) { + opportunities.push(makeAIEntry( + withTopic('EU and international cooperation%t', topicStr ? ` on ${topic}` : ''), + 'high', + `Multilateral frameworks provide legitimacy and co-funding for domestic reforms${topicStr}`, + [], + 'improving', + )); + } + + const threats: AISwotEntry[] = []; + motDocs.slice(0, 2).forEach(d => { + threats.push(docEntry( + d, 'Opposition motion challenging policy', + 'medium', + `Opposition motions create parliamentary counter-pressure${topicStr}`, + 'stable', + )); + }); + if (threats.length === 0) { + threats.push(makeAIEntry( + withTopic('Execution risks and political resistance%t', topicStr ? ` to ${topic} reform` : ''), + 'medium', + `Policy implementation faces stakeholder friction and opposition challenge${topicStr}`, + [], + 'stable', + docCount, + )); + } + + return { strengths, weaknesses, opportunities, threats }; +} + +function buildOppositionSwot( + b: DocBuckets, + topic: string | null, + _lang: Language, +): Pick { + const { bet: betDocs, mot: motDocs, prop: propDocs } = b; + + const topicStr = topic ? ` on ${topic}` : ''; + + const strengths: AISwotEntry[] = []; + betDocs.slice(0, 2).forEach(d => { + strengths.push(docEntry( + d, 'Committee oversight report', + 'high', + `Committee report enables structured parliamentary scrutiny${topicStr}`, + 'stable', + )); + }); + motDocs.slice(0, 2).forEach(d => { + strengths.push(docEntry( + d, 'Opposition motion for alternative policy', + 'medium', + `Tabling motions demonstrates alternative policy capacity and public positioning${topicStr}`, + 'stable', + )); + }); + if (strengths.length === 0) { + strengths.push(makeAIEntry( + withTopic('Parliamentary oversight and scrutiny%t', topicStr ? ` of ${topic} proposals` : ''), + 'high', + `Opposition fulfils democratic accountability function${topicStr}`, + [], + 'stable', + )); + } + + const weaknesses: AISwotEntry[] = [ + makeAIEntry( + withTopic('Limited access to implementation data%t', topicStr ? ` on ${topic}` : ''), + 'medium', + `Government controls executive information; opposition relies on public documents${topicStr}`, + [], + 'stable', + ), + ]; + + const opportunities: AISwotEntry[] = [ + makeAIEntry( + withTopic('Cross-party consensus building%t', topicStr ? ` on ${topic}` : ''), + 'high', + `Issue salience creates openings for coalition with centrist defectors${topicStr}`, + [], + 'improving', + ), + ]; + + const threats: AISwotEntry[] = []; + propDocs.slice(0, 1).forEach(d => { + threats.push(docEntry( + d, 'Government proposition limiting amendment scope', + 'medium', + `Government majority can pass legislation with minimal opposition amendments${topicStr}`, + 'stable', + )); + }); + if (threats.length === 0) { + threats.push(makeAIEntry( + withTopic('Government majority limiting amendment capacity%t', topicStr ? ` on ${topic}` : ''), + 'medium', + `Parliamentary arithmetic constrains opposition legislative influence${topicStr}`, + [], + 'stable', + )); + } + + return { strengths, weaknesses, opportunities, threats }; +} + +function buildEUInternationalSwot( + b: DocBuckets, + topic: string | null, + _lang: Language, +): Pick { + const { fpm: euDocs, ext: extDocs } = b; + const topicStr = topic ? ` on ${topic}` : ''; + + const strengths: AISwotEntry[] = []; + euDocs.slice(0, 2).forEach(d => { + strengths.push(docEntry( + d, 'EU/international regulatory framework', + 'high', + `EU regulatory alignment provides Sweden with binding legal framework and co-funding${topicStr}`, + 'improving', + )); + }); + if (strengths.length === 0) { + strengths.push(makeAIEntry( + withTopic('EU regulatory frameworks and directives%t', topicStr ? ` for ${topic}` : ''), + 'high', + `EU membership provides supranational standards that shape national policy${topicStr}`, + [], + 'stable', + )); + } + + extDocs.slice(0, 1).forEach(d => { + strengths.push(docEntry( + d, 'External expert input', + 'medium', + `International expertise validates and contextualises domestic policy positions${topicStr}`, + 'stable', + )); + }); + + const weaknesses: AISwotEntry[] = [ + makeAIEntry( + withTopic('Implementation variation across EU member states%t', topicStr ? ` regarding ${topic}` : ''), + 'medium', + `Divergent transposition timelines can create competitive disadvantages for Sweden${topicStr}`, + [], + 'stable', + ), + ]; + + const opportunities: AISwotEntry[] = [ + makeAIEntry( + withTopic('Diplomatic leadership and norm-setting%t', topicStr ? ` on ${topic}` : ''), + 'high', + `Sweden can use international forums to shape standards and attract investment${topicStr}`, + [], + 'improving', + ), + ]; + + const threats: AISwotEntry[] = [ + makeAIEntry( + withTopic('Geopolitical uncertainty impacting Swedish policy space%t', topicStr ? ` for ${topic}` : ''), + 'high', + `Shifting international dynamics can constrain or override domestic policy choices${topicStr}`, + [], + 'deteriorating', + ), + ]; + + return { strengths, weaknesses, opportunities, threats }; +} + +function buildPrivateSectorSwot( + b: DocBuckets, + topic: string | null, + _lang: Language, +): Pick { + const { sfs: sfsDocs, ext: extDocs } = b; + const topicStr = topic ? ` in ${topic}` : ''; + + const strengths: AISwotEntry[] = []; + extDocs.slice(0, 2).forEach(d => { + strengths.push(docEntry( + d, 'Industry input to policy process', + 'high', + `Industry representation in consultation demonstrates established influence channels${topicStr}`, + 'stable', + )); + }); + if (strengths.length === 0) { + strengths.push(makeAIEntry( + withTopic('Domain expertise and operational capacity%t', topicStr ? ` in ${topic}` : ''), + 'high', + `Private sector holds implementation knowledge critical to policy success${topicStr}`, + [], + 'stable', + )); + } + + const weaknesses: AISwotEntry[] = [ + makeAIEntry( + withTopic('Compliance costs from new regulatory requirements%t', topicStr ? ` in ${topic}` : ''), + 'medium', + `Legislative changes impose adaptation costs particularly on SMEs${topicStr}`, + [], + 'deteriorating', + ), + ]; + sfsDocs.slice(0, 1).forEach(d => { + weaknesses.push(docEntry( + d, 'New regulatory requirement', + 'medium', + `Enacted statute creates compliance obligations for business${topicStr}`, + 'stable', + )); + }); + + const opportunities: AISwotEntry[] = [ + makeAIEntry( + withTopic('Investment and innovation from policy-driven market development%t', topicStr ? ` in ${topic}` : ''), + 'high', + `Government programmes create new markets and procurement opportunities${topicStr}`, + [], + 'improving', + ), + ]; + + const threats: AISwotEntry[] = [ + makeAIEntry( + withTopic('Regulatory uncertainty during policy transition%t', topicStr ? ` on ${topic}` : ''), + 'high', + `Short implementation timelines and evolving rules hamper business planning${topicStr}`, + [], + 'deteriorating', + ), + ]; + + return { strengths, weaknesses, opportunities, threats }; +} + +function buildCivilSocietySwot( + b: DocBuckets, + topic: string | null, + _lang: Language, +): Pick { + const { bet: betDocs, mot: motDocs } = b; + const topicStr = topic ? ` on ${topic}` : ''; + + const strengths: AISwotEntry[] = []; + betDocs.slice(0, 1).forEach(d => { + strengths.push(docEntry( + d, 'Civil society input in committee process', + 'high', + `Committee consultation includes civil society perspectives that shape final legislation${topicStr}`, + 'stable', + )); + }); + if (strengths.length === 0) { + strengths.push(makeAIEntry( + withTopic('Democratic accountability and rights advocacy%t', topicStr ? ` regarding ${topic}` : ''), + 'high', + `Civil society provides independent oversight and public interest representation${topicStr}`, + [], + 'stable', + )); + } + + const weaknesses: AISwotEntry[] = [ + makeAIEntry( + withTopic('Resource constraints limiting monitoring capacity%t', topicStr ? ` for ${topic}` : ''), + 'medium', + `NGOs often lack funding to mount sustained campaigns on complex legislation${topicStr}`, + [], + 'stable', + ), + ]; + + const opportunities: AISwotEntry[] = [ + makeAIEntry( + withTopic('Public mobilisation on rights-sensitive policies%t', topicStr ? ` related to ${topic}` : ''), + 'high', + `Heightened media attention creates window for civil society agenda-setting${topicStr}`, + [], + 'improving', + ), + ]; + + const threats: AISwotEntry[] = []; + motDocs.slice(0, 1).forEach(d => { + threats.push(docEntry( + d, 'Restrictive legislative motion', + 'medium', + `Proposed legislation may restrict civic space or NGO operational freedoms${topicStr}`, + 'deteriorating', + )); + }); + if (threats.length === 0) { + threats.push(makeAIEntry( + withTopic('Legislative changes reducing civic freedoms%t', topicStr ? ` in ${topic}` : ''), + 'medium', + `Policy reforms can inadvertently curtail associational rights or protest space${topicStr}`, + [], + 'stable', + )); + } + + return { strengths, weaknesses, opportunities, threats }; +} + +function buildCitizensSwot( + b: DocBuckets, + topic: string | null, + _lang: Language, +): Pick { + const { prop: propDocs, sfs: sfsDocs, bet: betDocs } = b; + const topicStr = topic ? ` on ${topic}` : ''; + + const strengths: AISwotEntry[] = []; + sfsDocs.slice(0, 1).forEach(d => { + strengths.push(docEntry( + d, 'Enacted welfare or service law', + 'high', + `Enacted legislation directly improves public service delivery${topicStr}`, + 'stable', + )); + }); + if (strengths.length === 0) { + strengths.push(makeAIEntry( + withTopic('Democratic representation through elected parliament%t', topicStr ? ` on ${topic}` : ''), + 'high', + `Citizens exercise electoral accountability over policy direction${topicStr}`, + [], + 'stable', + )); + } + + const weaknesses: AISwotEntry[] = [ + makeAIEntry( + withTopic('Information asymmetry on policy impacts%t', topicStr ? ` of ${topic}` : ''), + 'medium', + `Complex legislation is difficult for citizens to evaluate without expert analysis${topicStr}`, + [], + 'stable', + ), + ]; + + const opportunities: AISwotEntry[] = []; + propDocs.slice(0, 1).forEach(d => { + opportunities.push(docEntry( + d, 'Government reform proposition', + 'high', + `Government reform proposals create opportunities for improved public services${topicStr}`, + 'improving', + )); + }); + betDocs.slice(0, 1).forEach(d => { + opportunities.push(docEntry( + d, 'Transparent committee review process', + 'medium', + `Open committee proceedings allow citizens to track legislative development${topicStr}`, + 'stable', + )); + }); + if (opportunities.length === 0) { + opportunities.push(makeAIEntry( + withTopic('Policy reforms improving public service quality%t', topicStr ? ` for ${topic}` : ''), + 'high', + `Parliamentary activity on this issue indicates political will to improve outcomes${topicStr}`, + [], + 'improving', + )); + } + + const threats: AISwotEntry[] = [ + makeAIEntry( + withTopic('Policy implementation gaps reducing service quality%t', topicStr ? ` in ${topic}` : ''), + 'medium', + `Distance between legislative intent and administrative execution affects citizen outcomes${topicStr}`, + [], + 'stable', + ), + ]; + + return { strengths, weaknesses, opportunities, threats }; +} + +// --------------------------------------------------------------------------- +// Cross-reference generator +// --------------------------------------------------------------------------- + +function buildCrossReferences(b: DocBuckets, topic: string | null): SwotCrossReference[] { + const refs: SwotCrossReference[] = []; + + // Government strengths → Opposition threats + if (b.prop.length > 0) { + refs.push({ + fromStakeholder: 'government-coalition', + fromQuadrant: 'strengths', + toStakeholder: 'opposition', + toQuadrant: 'threats', + rationale: topic + ? `Government propositions on ${topic} that reinforce coalition strengths simultaneously limit opposition influence` + : 'Government legislative agenda that strengthens coalition simultaneously constrains opposition amendment capacity', + }); + } + + // EU opportunities → Private sector opportunities + if (b.fpm.length > 0) { + refs.push({ + fromStakeholder: 'eu-international', + fromQuadrant: 'opportunities', + toStakeholder: 'private-sector', + toQuadrant: 'opportunities', + rationale: topic + ? `EU directives on ${topic} open cross-border market opportunities for Swedish business` + : 'EU regulatory harmonisation creates new market access opportunities for Swedish companies', + }); + } + + // Private sector regulatory burden → Civil society accountability (only when evidence present) + const hasRegulatoryDocs = b.sfs.length > 0 || b.skr.length > 0; + if (hasRegulatoryDocs) { + refs.push({ + fromStakeholder: 'private-sector', + fromQuadrant: 'weaknesses', + toStakeholder: 'civil-society', + toQuadrant: 'opportunities', + rationale: topic + ? `Business compliance challenges on ${topic} create space for civil society to advocate proportionate regulation` + : 'Regulatory compliance burdens provide civil society with advocacy leverage for balanced policy design', + }); + } + + return refs; +} + +// --------------------------------------------------------------------------- +// Confidence score calculation +// --------------------------------------------------------------------------- + +/** Baseline confidence with minimal document evidence */ +const BASE_CONFIDENCE = 0.55; +/** Maximum bonus for document volume (capped at 10 extra documents) */ +const MAX_DOC_VOLUME_BONUS = 0.20; +/** Confidence points added per document (capped by MAX_DOC_VOLUME_BONUS) */ +const CONFIDENCE_PER_DOC = 0.02; +/** Maximum bonus for enriched full-text documents */ +const MAX_ENRICHMENT_BONUS = 0.15; +/** Confidence points per enriched document (capped by MAX_ENRICHMENT_BONUS) */ +const CONFIDENCE_PER_ENRICHED_DOC = 0.03; +/** Penalty when EU stakeholder has no fpm documents (low direct evidence) */ +const EU_NO_DATA_PENALTY = -0.05; +/** Absolute maximum confidence (no analysis is 100% certain) */ +const MAX_CONFIDENCE = 0.95; +/** Absolute minimum confidence (always some baseline reasoning possible) */ +const MIN_CONFIDENCE = 0.40; + +function computeConfidence(docs: RawDocument[], b: DocBuckets, perspective: StakeholderPerspective): number { + const docBonus = Math.min(MAX_DOC_VOLUME_BONUS, docs.length * CONFIDENCE_PER_DOC); + const enriched = docs.filter(d => d.contentFetched).length; + const enrichedBonus = Math.min(MAX_ENRICHMENT_BONUS, enriched * CONFIDENCE_PER_ENRICHED_DOC); + + // EU stakeholder gets slightly lower confidence when there are no fpm docs + const euPenalty = perspective === 'eu-international' && b.fpm.length === 0 ? EU_NO_DATA_PENALTY : 0; + + return Math.min(MAX_CONFIDENCE, Math.max(MIN_CONFIDENCE, BASE_CONFIDENCE + docBonus + enrichedBonus + euPenalty)); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Build AI-driven SWOT analyses for all 6 stakeholder perspectives. + * + * Returns `StakeholderSwot[]` ready for `generateStakeholderSwotSection()`. + * Each entry contains `AISwotEntry`-shaped data (which is compatible with the + * base `SwotEntry` shape used by the renderer). + * + * @param docs - Parliamentary documents relevant to the analysis topic + * @param topic - Focus topic for contextual framing (may be null) + * @param lang - Target language for stakeholder names and roles + */ +export function buildAISwotStakeholders( + docs: RawDocument[], + topic: string | null, + lang: Language, +): StakeholderSwot[] { + const perspectives: StakeholderPerspective[] = [ + 'government-coalition', + 'opposition', + 'eu-international', + 'private-sector', + 'civil-society', + 'citizens-voters', + ]; + + // Pre-bucket documents by type in a single pass for efficiency + const b = bucketDocs(docs); + + const builders: Record Pick> = { + 'government-coalition': () => buildGovernmentSwot(b, topic, lang), + opposition: () => buildOppositionSwot(b, topic, lang), + 'eu-international': () => buildEUInternationalSwot(b, topic, lang), + 'private-sector': () => buildPrivateSectorSwot(b, topic, lang), + 'civil-society': () => buildCivilSocietySwot(b, topic, lang), + 'citizens-voters': () => buildCitizensSwot(b, topic, lang), + }; + + const crossRefs = buildCrossReferences(b, topic); + + // Localised labels for context metadata (with English fallback for safety) + const confidenceLabel = CONTEXT_LABELS.confidence[lang] ?? CONTEXT_LABELS.confidence.en; + const crossRefLabel = CONTEXT_LABELS.crossReferences[lang] ?? CONTEXT_LABELS.crossReferences.en; + + return perspectives.map(p => { + const name = STAKEHOLDER_NAMES[p][lang]; + const role = STAKEHOLDER_ROLES[p][lang]; + const swotData = builders[p](); + const confidence = computeConfidence(docs, b, p); + + // Attach confidence and crossReferences as context metadata (localised) + const contextParts: string[] = []; + contextParts.push(`${confidenceLabel}: ${Math.round(confidence * 100)}%`); + const ownRefs = crossRefs.filter(r => r.fromStakeholder === p || r.toStakeholder === p); + if (ownRefs.length > 0) { + contextParts.push(`${crossRefLabel}: ${ownRefs.length}`); + } + + return { + name, + role, + swot: { + strengths: swotData.strengths, + weaknesses: swotData.weaknesses, + opportunities: swotData.opportunities, + threats: swotData.threats, + context: contextParts.join(' | '), + }, + }; + }); +} diff --git a/scripts/data-transformers/content-generators/index.ts b/scripts/data-transformers/content-generators/index.ts index d1f90f321a..9e9cd110da 100644 --- a/scripts/data-transformers/content-generators/index.ts +++ b/scripts/data-transformers/content-generators/index.ts @@ -52,3 +52,5 @@ export { generateSankeySection } from './sankey-section.js'; export type { SankeySectionOptions, SankeyNode, SankeyFlow, SankeyNodeColor } from './sankey-section.js'; export { generateCiaOverviewSection } from './cia-overview-section.js'; export type { CiaOverviewSectionOptions } from './cia-overview-section.js'; +export { buildAISwotStakeholders, STAKEHOLDER_NAMES } from './ai-swot-analyzer.js'; +export type { StakeholderPerspective, AISwotEntry, AISwotAnalysis, SwotCrossReference } from './ai-swot-analyzer.js'; diff --git a/scripts/data-transformers/content-generators/stakeholder-swot-section.ts b/scripts/data-transformers/content-generators/stakeholder-swot-section.ts index 6f49c311c0..8feae33a07 100644 --- a/scripts/data-transformers/content-generators/stakeholder-swot-section.ts +++ b/scripts/data-transformers/content-generators/stakeholder-swot-section.ts @@ -13,7 +13,7 @@ import { escapeHtml } from '../../html-utils.js'; import type { Language } from '../../types/language.js'; -import type { TemplateSection, SwotData, SwotEntry, SwotImpact } from '../../types/article.js'; +import type { TemplateSection, SwotData, SwotEntry, SwotImpact, TrendDirection } from '../../types/article.js'; import { L } from '../helpers.js'; // --------------------------------------------------------------------------- @@ -98,6 +98,60 @@ const STRATEGIC_CONTEXT_LABELS: Readonly> = { zh: '战略背景', }; +// --------------------------------------------------------------------------- +// Extended entry interface (AI-generated SWOT entries carry extra metadata) +// --------------------------------------------------------------------------- + +/** + * Extended SwotEntry with AI-analysis fields. + * These fields are optional so the renderer remains backward-compatible with + * plain `SwotEntry` objects that lack them. + */ +interface EnhancedSwotEntry extends SwotEntry { + /** Human-readable explanation for why this item was included */ + justification?: string; + /** Direction this factor is heading */ + trendDirection?: TrendDirection; + /** Supporting quantitative evidence (e.g. "73% majority", "SEK 2.1 bn") */ + quantitativeEvidence?: string; +} + +// --------------------------------------------------------------------------- +// Trend indicator helper +// --------------------------------------------------------------------------- + +const TREND_SYMBOLS: Readonly> = { + improving: '↑', + stable: '→', + deteriorating: '↓', +}; + +const TREND_CLASSES: Readonly> = { + improving: 'swot-trend--improving', + stable: 'swot-trend--stable', + deteriorating: 'swot-trend--deteriorating', +}; + +/** Localised i18n keys for trend direction (used in aria-label) */ +const TREND_LABEL_KEYS: Readonly> = { + improving: 'swotTrendImproving', + stable: 'swotTrendStable', + deteriorating: 'swotTrendDeteriorating', +}; + +function trendIndicator(entry: EnhancedSwotEntry, lbl: (key: string) => string): string { + const dir = entry.trendDirection; + if (!dir) return ''; + const sym = TREND_SYMBOLS[dir] ?? ''; + const cls = TREND_CLASSES[dir] ?? ''; + if (!sym) return ''; // Guard: unknown direction value → skip indicator + const labelKey = TREND_LABEL_KEYS[dir]; + const raw = labelKey ? lbl(labelKey) : dir; + // If the label lookup returned the key itself (incomplete label map), fall back to the direction name + const ariaLabel = (raw === labelKey) ? dir : raw; + return ` ${sym}`; +} + // --------------------------------------------------------------------------- // Impact badge helper (shared with swot-section.ts pattern) // --------------------------------------------------------------------------- @@ -121,7 +175,20 @@ function impactBadge(impact: SwotImpact | undefined, lbl: (key: string) => strin function renderEntries(entries: SwotEntry[], lbl: (key: string) => string): string { if (!entries || entries.length === 0) return ''; - return entries.map(e => `
  • ${escapeHtml(e.text)}${impactBadge(e.impact, lbl)}
  • `).join('\n'); + return entries.map(e => { + const enhanced = e as EnhancedSwotEntry; + const badges = impactBadge(e.impact, lbl) + trendIndicator(enhanced, lbl); + const quantEvidence = enhanced.quantitativeEvidence + ? ` (${escapeHtml(enhanced.quantitativeEvidence)})` + : ''; + const justLabel = lbl('swotJustification'); + // lbl() returns the key name when no translation is found; detect that and use English fallback + const justSummary = (justLabel !== 'swotJustification') ? justLabel : 'Analysis'; + const justification = enhanced.justification?.trim() + ? `\n
    ${escapeHtml(justSummary)}

    ${escapeHtml(enhanced.justification.trim())}

    ` + : ''; + return `
  • ${escapeHtml(e.text)}${badges}${quantEvidence}${justification}
  • `; + }).join('\n'); } function renderStakeholderSwot(stakeholder: StakeholderSwot, lbl: (key: string) => string): string { diff --git a/scripts/generate-news-enhanced/generators.ts b/scripts/generate-news-enhanced/generators.ts index b2753b1629..11e2418b32 100644 --- a/scripts/generate-news-enhanced/generators.ts +++ b/scripts/generate-news-enhanced/generators.ts @@ -28,7 +28,7 @@ import { type SankeyNode, type SankeyFlow, } 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'; @@ -1109,37 +1109,43 @@ async function buildDeepInspectionSections( ): Promise { if (docs.length === 0) return []; - // Lazy-import swot-analyzer to avoid loading its large localization maps - // when generators.ts is used for non-deep-inspection article types. - const { buildMultiStakeholderSwot, STAKEHOLDER_NAMES } = await import('./swot-analyzer.js'); - - // Precompute effectiveType() once per document to avoid repeated string checks. - const docTypes = docs.map(d => effectiveType(d)); - - // Classify by document type (needed for downstream sankey/dashboard sections). - const propDocs = docs.filter((_, i) => docTypes[i] === 'prop'); - const betDocs = docs.filter((_, i) => docTypes[i] === 'bet'); - const motDocs = docs.filter((_, i) => docTypes[i] === 'mot'); - const skrDocs = docs.filter((_, i) => docTypes[i] === 'skr'); - const sfsDocs = docs.filter((_, i) => docTypes[i] === 'sfs'); - const euDocs = docs.filter((_, i) => docTypes[i] === 'fpm' || docTypes[i] === 'eu'); - const pressmDocs = docs.filter((_, i) => docTypes[i] === 'pressm'); - const extDocs = docs.filter((_, i) => docTypes[i] === 'ext'); - const otherDocs = docs.filter((_, i) => - !['prop','bet','mot','skr','sfs','fpm','eu','pressm','ext'].includes(docTypes[i])); - - // Build 4–9 stakeholder SWOT analyses from document metadata - const stakeholders = buildMultiStakeholderSwot(docs, lang); - - // Derive localised names for the mindmap / sankey from the STAKEHOLDER_NAMES map - const govName = STAKEHOLDER_NAMES.government[lang] ?? STAKEHOLDER_NAMES.government.en ?? 'Government Coalition'; - const oppName = STAKEHOLDER_NAMES.opposition[lang] ?? STAKEHOLDER_NAMES.opposition.en ?? 'Opposition Parties'; + // Single-pass classification: bucket docs by effectiveType() to avoid N×filter passes. + // 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); + let arr = buckets.get(t); + if (!arr) { arr = []; buckets.set(t, arr); } + arr.push(d); + } + const propDocs = buckets.get('prop') ?? []; + const betDocs = buckets.get('bet') ?? []; + const motDocs = buckets.get('mot') ?? []; + const skrDocs = buckets.get('skr') ?? []; + const sfsDocs = buckets.get('sfs') ?? []; + const euDocs = [...(buckets.get('fpm') ?? []), ...(buckets.get('eu') ?? [])]; + const pressmDocs = buckets.get('pressm') ?? []; + const extDocs = buckets.get('ext') ?? []; + // classifiedTypes must mirror every bucket key consumed above (including both EU keys) + const classifiedTypes = new Set(['prop','bet','mot','skr','sfs','fpm','eu','pressm','ext']); + const otherDocs = [...buckets.entries()] + .filter(([k]) => !classifiedTypes.has(k)) + .flatMap(([, v]) => v); + + // ── AI-driven 6-stakeholder SWOT ───────────────────────────────────────── + const stakeholders = buildAISwotStakeholders(docs, topic, lang); const strategicContext = topic ? `Analysis exclusively focused on: ${topic} — ${docs.length} parliamentary documents examined` : `Multi-stakeholder analysis of ${docs.length} parliamentary documents`; const swotSection = generateStakeholderSwotSection({ stakeholders, lang, strategicContext }); + // ── 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); @@ -1188,7 +1194,7 @@ async function buildDeepInspectionSections( // ── Mindmap: AI-driven conceptual map across 5 political dimensions ───────── const allDetectedDomains = new Set(); docs.forEach(d => detectPolicyDomains(d, lang).forEach(dom => allDetectedDomains.add(dom))); - const detectedDomainList = [...allDetectedDomains].slice(0, 6); + const detectedDomainList = [...allDetectedDomains].slice(0, 8); // Pass precomputed domains to avoid iterating docs twice const aiAnalysis = buildAIMindmapAnalysis(docs, topic, lang, detectedDomainList); @@ -1207,13 +1213,13 @@ async function buildDeepInspectionSections( // ── 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 + // - 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 EU positions, - // external references, and other document types - // Additional SWOT stakeholders (municipal, media, academia, etc.) are + // - 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 privateName = STAKEHOLDER_NAMES.private[lang] ?? STAKEHOLDER_NAMES.private.en ?? 'Private Sector / Industry'; const sankeyNodes: SankeyNode[] = [ { id: 'gov', label: govName, color: 'cyan' }, { id: 'opp', label: oppName, color: 'magenta' }, @@ -1239,12 +1245,12 @@ async function buildDeepInspectionSections( sankeyFlows.push({ source: 'gov', target: 'sfs', value: sfsDocs.length, label: `${sfsDocs.length}` }); } if (skrDocs.length > 0) { - sankeyNodes.push({ id: 'skr', label: deepLabel('govCommunications', lang), 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: 'EU Positions', color: 'blue' }); - sankeyFlows.push({ source: 'pvt', target: 'eu', value: euDocs.length, label: `${euDocs.length}` }); + sankeyFlows.push({ source: 'gov', target: 'eu', value: euDocs.length, label: `${euDocs.length}` }); } if (pressmDocs.length > 0) { sankeyNodes.push({ id: 'pressm', label: 'Press Releases', color: 'orange' }); diff --git a/scripts/types/article.ts b/scripts/types/article.ts index 7dc94ad404..cc1f1b7d11 100644 --- a/scripts/types/article.ts +++ b/scripts/types/article.ts @@ -204,6 +204,9 @@ export interface BreakingNewsValidation { /** Impact level for a SWOT entry */ export type SwotImpact = 'high' | 'medium' | 'low'; +/** Trend direction for a SWOT entry (shared across analyzer and renderer) */ +export type TrendDirection = 'improving' | 'stable' | 'deteriorating'; + /** A single item in one of the four SWOT quadrants */ export interface SwotEntry { /** Description text for this factor */ diff --git a/scripts/types/content.ts b/scripts/types/content.ts index a2dd478e69..fd2da2f8c3 100644 --- a/scripts/types/content.ts +++ b/scripts/types/content.ts @@ -158,6 +158,11 @@ export interface ContentLabelSet { swotImpactHigh: string; swotImpactMedium: string; swotImpactLow: string; + // SWOT justification and trend labels + swotJustification: string; + swotTrendImproving: string; + swotTrendStable: string; + swotTrendDeteriorating: string; // Dashboard section labels dashboardTitle: string; dashboardSummary: string; diff --git a/styles/components/swot.css b/styles/components/swot.css index f6923cf057..0b6d2f6241 100644 --- a/styles/components/swot.css +++ b/styles/components/swot.css @@ -43,7 +43,8 @@ border: 1px solid var(--border-color, #ddd); } -.news-article .swot-quadrant h3 { +.news-article .swot-quadrant h3, +.news-article .swot-quadrant h4 { margin: 0 0 0.75rem 0; font-size: 1rem; font-weight: 600; @@ -65,28 +66,32 @@ border-color: #27ae60; } -.news-article .swot-strengths h3 { color: #1e8449; } +.news-article .swot-strengths h3, +.news-article .swot-strengths h4 { color: #1e8449; } .news-article .swot-weaknesses { background: rgba(231, 76, 60, 0.08); border-color: #e74c3c; } -.news-article .swot-weaknesses h3 { color: #c0392b; } +.news-article .swot-weaknesses h3, +.news-article .swot-weaknesses h4 { color: #c0392b; } .news-article .swot-opportunities { background: rgba(52, 152, 219, 0.08); border-color: #3498db; } -.news-article .swot-opportunities h3 { color: #2471a3; } +.news-article .swot-opportunities h3, +.news-article .swot-opportunities h4 { color: #2471a3; } .news-article .swot-threats { background: rgba(243, 156, 18, 0.08); border-color: #f39c12; } -.news-article .swot-threats h3 { color: #d68910; } +.news-article .swot-threats h3, +.news-article .swot-threats h4 { color: #d68910; } .news-article .swot-subject { font-size: 1.1rem; @@ -108,6 +113,39 @@ .news-article .swot-impact--medium { color: #f39c12; } .news-article .swot-impact--low { color: #27ae60; } +/* Trend direction indicators */ +.news-article .swot-trend { + font-weight: 700; + margin-left: 0.25em; +} +.news-article .swot-trend--improving { color: #27ae60; } +.news-article .swot-trend--stable { color: #7f8c8d; } +.news-article .swot-trend--deteriorating { color: #e74c3c; } + +/* Quantitative evidence */ +.news-article .swot-evidence { + font-size: 0.85em; + color: #6c757d; + font-style: italic; + margin-left: 0.25em; +} + +/* Expandable justification */ +.news-article .swot-justification { + margin-top: 0.25rem; + font-size: 0.85em; + color: #555; +} +.news-article .swot-justification summary { + cursor: pointer; + font-weight: 600; + color: #2471a3; +} +.news-article .swot-justification p { + margin: 0.25rem 0 0 0; + line-height: 1.4; +} + [dir="rtl"] .news-article .swot-analysis { direction: rtl; text-align: right; @@ -118,6 +156,9 @@ padding-right: 1.25rem; } +[dir="rtl"] .news-article .swot-trend { margin-left: 0; margin-right: 0.25em; } +[dir="rtl"] .news-article .swot-evidence { margin-left: 0; margin-right: 0.25em; } + /* Stakeholder SWOT Analysis Section */ .news-article .stakeholder-swot-analysis { diff --git a/tests/agentic-workflow-mcp-queries.test.ts b/tests/agentic-workflow-mcp-queries.test.ts index c33fd868f3..625d8272c1 100644 --- a/tests/agentic-workflow-mcp-queries.test.ts +++ b/tests/agentic-workflow-mcp-queries.test.ts @@ -147,6 +147,7 @@ describe('Agentic Workflow MCP Query Patterns', () => { expect(content).toMatch(/fromDate|from_date|filter.*results/i); // Should have filtering examples expect(content).toContain('.filter('); + expect(content).toMatch(/\.slice\(0,\s*10\)\s*>=\s*fromDate|new Date.*>=.*new Date/); expect(content).toMatch(/\.slice\(0,\s*10\)\s*>=\s*fromDate|new Date.*>=.*fromDate/); expect(content).toMatch(/new Date.*>=.*new Date|new Date.*>.*fromDate|>=\s*fromDate/); // Should document filtering by date fields — the workflow uses diff --git a/tests/ai-swot-analyzer.test.ts b/tests/ai-swot-analyzer.test.ts new file mode 100644 index 0000000000..534980dd5a --- /dev/null +++ b/tests/ai-swot-analyzer.test.ts @@ -0,0 +1,413 @@ +/** + * Tests for ai-swot-analyzer — AI-driven 6-stakeholder SWOT analysis builder. + * Validates stakeholder count, AISwotEntry shape, trend indicators, cross-references, + * confidence scores, localization, and XSS safety. + */ + +import { describe, it, expect } from 'vitest'; +import { + buildAISwotStakeholders, +} from '../scripts/data-transformers/content-generators/ai-swot-analyzer.js'; +import type { + AISwotEntry, +} from '../scripts/data-transformers/content-generators/ai-swot-analyzer.js'; +import type { TrendDirection } from '../scripts/types/article.js'; +import type { RawDocument } from '../scripts/data-transformers/types.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeDoc(overrides: Partial = {}): RawDocument { + return { + dok_id: 'test-123', + titel: 'Test document', + doktyp: 'prop', + ...overrides, + }; +} + +function makePropDoc(titel: string): RawDocument { + return makeDoc({ doktyp: 'prop', titel }); +} + +function makeBetDoc(titel: string): RawDocument { + return makeDoc({ doktyp: 'bet', titel }); +} + +function makeMotDoc(titel: string): RawDocument { + return makeDoc({ doktyp: 'mot', titel }); +} + +function makeEuDoc(titel: string): RawDocument { + return makeDoc({ doktyp: 'fpm', titel }); +} + +function makeSfsDoc(titel: string): RawDocument { + return makeDoc({ doktyp: 'sfs', titel }); +} + +function makeExtDoc(titel: string): RawDocument { + return makeDoc({ doktyp: 'ext', titel }); +} + +// --------------------------------------------------------------------------- +// Tests: buildAISwotStakeholders +// --------------------------------------------------------------------------- + +describe('buildAISwotStakeholders', () => { + describe('stakeholder count', () => { + it('returns exactly 6 stakeholders', () => { + const result = buildAISwotStakeholders([], null, 'en'); + expect(result).toHaveLength(6); + }); + + it('returns 6 stakeholders regardless of document count', () => { + const docs = [makePropDoc('Test prop'), makeBetDoc('Test bet')]; + const result = buildAISwotStakeholders(docs, 'Healthcare', 'en'); + expect(result).toHaveLength(6); + }); + }); + + describe('stakeholder names in English', () => { + it('includes Government Coalition as first stakeholder', () => { + const result = buildAISwotStakeholders([], null, 'en'); + expect(result[0].name).toBe('Government Coalition'); + }); + + it('includes Social Democratic Opposition as second stakeholder', () => { + const result = buildAISwotStakeholders([], null, 'en'); + expect(result[1].name).toBe('Social Democratic Opposition'); + }); + + it('includes EU & International Actors as third stakeholder', () => { + const result = buildAISwotStakeholders([], null, 'en'); + expect(result[2].name).toBe('EU & International Actors'); + }); + + it('includes Private Sector & Business as fourth stakeholder', () => { + const result = buildAISwotStakeholders([], null, 'en'); + expect(result[3].name).toBe('Private Sector & Business'); + }); + + it('includes Civil Society & NGOs as fifth stakeholder', () => { + const result = buildAISwotStakeholders([], null, 'en'); + expect(result[4].name).toBe('Civil Society & NGOs'); + }); + + it('includes Swedish Citizens & Voters as sixth stakeholder', () => { + const result = buildAISwotStakeholders([], null, 'en'); + expect(result[5].name).toBe('Swedish Citizens & Voters'); + }); + }); + + describe('stakeholder roles', () => { + it('each stakeholder has a non-empty role', () => { + const result = buildAISwotStakeholders([], null, 'en'); + for (const s of result) { + expect(s.role).toBeTruthy(); + expect(s.role!.length).toBeGreaterThan(5); + } + }); + + it('Government Coalition role mentions Tidö parties', () => { + const result = buildAISwotStakeholders([], null, 'en'); + expect(result[0].role).toContain('Tidö'); + }); + + it('Opposition role mentions S, V, C, MP', () => { + const result = buildAISwotStakeholders([], null, 'en'); + expect(result[1].role).toContain('S, V, C, MP'); + }); + }); + + describe('SWOT quadrant completeness', () => { + it('each stakeholder has all four SWOT quadrants', () => { + const result = buildAISwotStakeholders([], null, 'en'); + for (const s of result) { + expect(Array.isArray(s.swot.strengths)).toBe(true); + expect(Array.isArray(s.swot.weaknesses)).toBe(true); + expect(Array.isArray(s.swot.opportunities)).toBe(true); + expect(Array.isArray(s.swot.threats)).toBe(true); + } + }); + + it('each quadrant has at least one entry', () => { + const result = buildAISwotStakeholders([], null, 'en'); + for (const s of result) { + expect(s.swot.strengths.length).toBeGreaterThan(0); + expect(s.swot.weaknesses.length).toBeGreaterThan(0); + expect(s.swot.opportunities.length).toBeGreaterThan(0); + expect(s.swot.threats.length).toBeGreaterThan(0); + } + }); + }); + + describe('AISwotEntry shape (enhanced fields)', () => { + it('entries have text and impact fields', () => { + const result = buildAISwotStakeholders([makePropDoc('Budget proposal')], null, 'en'); + const firstEntry = result[0].swot.strengths[0] as AISwotEntry; + expect(typeof firstEntry.text).toBe('string'); + expect(firstEntry.text.length).toBeGreaterThan(0); + expect(['high', 'medium', 'low']).toContain(firstEntry.impact); + }); + + it('entries have a justification string', () => { + const result = buildAISwotStakeholders([makePropDoc('Budget proposal')], 'Budget', 'en'); + const firstEntry = result[0].swot.strengths[0] as AISwotEntry; + expect(typeof firstEntry.justification).toBe('string'); + expect(firstEntry.justification.length).toBeGreaterThan(5); + }); + + it('entries have a trendDirection field', () => { + const validDirections: TrendDirection[] = ['improving', 'stable', 'deteriorating']; + const result = buildAISwotStakeholders([makePropDoc('Test')], null, 'en'); + for (const s of result) { + const allEntries = [ + ...s.swot.strengths, + ...s.swot.weaknesses, + ...s.swot.opportunities, + ...s.swot.threats, + ] as AISwotEntry[]; + for (const e of allEntries) { + expect(validDirections).toContain(e.trendDirection); + } + } + }); + + it('entries have relatedDocuments array', () => { + const docs = [makePropDoc('Healthcare proposition')]; + const result = buildAISwotStakeholders(docs, 'Healthcare', 'en'); + const govStrengths = result[0].swot.strengths as AISwotEntry[]; + for (const e of govStrengths) { + expect(Array.isArray(e.relatedDocuments)).toBe(true); + } + }); + }); + + describe('confidence scores', () => { + it('each stakeholder has a context string with confidence percentage', () => { + const result = buildAISwotStakeholders([], null, 'en'); + for (const s of result) { + expect(s.swot.context).toContain('Confidence:'); + expect(s.swot.context).toMatch(/\d+%/); + } + }); + + it('confidence is higher with more documents', () => { + const fewDocs = [makePropDoc('A')]; + const manyDocs = Array.from({ length: 15 }, (_, i) => makePropDoc(`Doc ${i}`)); + const fewResult = buildAISwotStakeholders(fewDocs, null, 'en'); + const manyResult = buildAISwotStakeholders(manyDocs, null, 'en'); + + const extractConfidence = (context: string) => { + const m = context?.match(/Confidence: (\d+)%/); + return m ? parseInt(m[1]) : 0; + }; + + expect(extractConfidence(manyResult[0].swot.context ?? '')).toBeGreaterThanOrEqual( + extractConfidence(fewResult[0].swot.context ?? ''), + ); + }); + }); + + describe('topic integration', () => { + it('when topic provided, entries reference the topic', () => { + const result = buildAISwotStakeholders([makePropDoc('Migration policy')], 'Migration', 'en'); + const govStrengths = result[0].swot.strengths as AISwotEntry[]; + const firstEntry = govStrengths[0]; + // Either the text or justification should reference the topic + const hasTopic = firstEntry.text.includes('Migration') || firstEntry.justification.includes('Migration'); + expect(hasTopic).toBe(true); + }); + + it('works without topic (null)', () => { + const result = buildAISwotStakeholders([makePropDoc('Generic doc')], null, 'en'); + expect(result).toHaveLength(6); + for (const s of result) { + expect(s.swot.strengths.length).toBeGreaterThan(0); + } + }); + }); + + describe('document type routing', () => { + it('proposition docs appear in government coalition strengths', () => { + const docs = [makePropDoc('Proposition 2025/26:100'), makePropDoc('Proposition 2025/26:101')]; + const result = buildAISwotStakeholders(docs, null, 'en'); + const govStrengths = result[0].swot.strengths as AISwotEntry[]; + const hasPropDoc = govStrengths.some(e => e.relatedDocuments.some(r => r.includes('Proposition'))); + expect(hasPropDoc).toBe(true); + }); + + it('committee reports appear in opposition strengths', () => { + const docs = [makeBetDoc('Committee report SoU2025:1'), makeBetDoc('Committee report FiU2025:2')]; + const result = buildAISwotStakeholders(docs, null, 'en'); + const oppStrengths = result[1].swot.strengths as AISwotEntry[]; + const hasReport = oppStrengths.some(e => e.relatedDocuments.some(r => r.includes('Committee report'))); + expect(hasReport).toBe(true); + }); + + it('EU position papers appear in EU/International stakeholder strengths', () => { + const docs = [makeEuDoc('EU fact sheet on carbon neutrality')]; + const result = buildAISwotStakeholders(docs, null, 'en'); + const euStrengths = result[2].swot.strengths as AISwotEntry[]; + const hasEuDoc = euStrengths.some(e => e.relatedDocuments.some(r => r.includes('EU fact sheet'))); + expect(hasEuDoc).toBe(true); + }); + + it('external docs appear in private sector strengths', () => { + const docs = [makeExtDoc('Industry association response')]; + const result = buildAISwotStakeholders(docs, null, 'en'); + const privateStrengths = result[3].swot.strengths as AISwotEntry[]; + const hasExtDoc = privateStrengths.some(e => e.relatedDocuments.some(r => r.includes('Industry association'))); + expect(hasExtDoc).toBe(true); + }); + + it('opposition motions appear in civil society threats', () => { + const docs = [makeMotDoc('Motion on press freedom restrictions')]; + const result = buildAISwotStakeholders(docs, null, 'en'); + const civilThreats = result[4].swot.threats as AISwotEntry[]; + // Civil society threats reference motion docs + const hasMotDoc = civilThreats.some(e => e.relatedDocuments.some(r => r.includes('Motion'))); + expect(hasMotDoc).toBe(true); + }); + + it('enacted laws (SFS) appear in citizens strengths', () => { + const docs = [makeSfsDoc('SFS 2025:100 Healthcare Act')]; + const result = buildAISwotStakeholders(docs, null, 'en'); + const citizenStrengths = result[5].swot.strengths as AISwotEntry[]; + const hasLaw = citizenStrengths.some(e => e.relatedDocuments.some(r => r.includes('SFS'))); + expect(hasLaw).toBe(true); + }); + + it('government written communications (skr) appear in government coalition strengths', () => { + const docs = [makeDoc({ doktyp: 'skr', titel: 'Skr. 2025/26:1 Government annual report' })]; + const result = buildAISwotStakeholders(docs, null, 'en'); + const govStrengths = result[0].swot.strengths as AISwotEntry[]; + const hasSkr = govStrengths.some(e => + e.relatedDocuments.some(r => r.includes('Skr.')) + ); + expect(hasSkr).toBe(true); + }); + }); + + describe('cross-reference metadata', () => { + it('context includes cross-references count when proposition docs present', () => { + const docs = [makePropDoc('Budget proposition')]; + const result = buildAISwotStakeholders(docs, null, 'en'); + // Government coalition (index 0) should have cross-reference listed + const hasXRef = result.some(s => s.swot.context?.includes('Cross-references:')); + expect(hasXRef).toBe(true); + }); + }); + + describe('localisation — 14 languages', () => { + const langs = ['en', 'sv', 'da', 'no', 'fi', 'de', 'fr', 'es', 'nl', 'ar', 'he', 'ja', 'ko', 'zh'] as const; + + it('returns 6 stakeholders for every supported language', () => { + for (const lang of langs) { + const result = buildAISwotStakeholders([], null, lang); + expect(result).toHaveLength(6); + } + }); + + it('stakeholder names differ between English and Swedish', () => { + const en = buildAISwotStakeholders([], null, 'en'); + const sv = buildAISwotStakeholders([], null, 'sv'); + expect(en[0].name).not.toBe(sv[0].name); + expect(sv[0].name).toBe('Regeringskoalitionen'); + }); + + it('stakeholder names differ between English and German', () => { + const en = buildAISwotStakeholders([], null, 'en'); + const de = buildAISwotStakeholders([], null, 'de'); + expect(en[0].name).not.toBe(de[0].name); + expect(de[0].name).toBe('Regierungskoalition'); + }); + + it('Swedish government coalition name is correct', () => { + const sv = buildAISwotStakeholders([], null, 'sv'); + expect(sv[0].name).toBe('Regeringskoalitionen'); + }); + + it('French names are populated', () => { + const fr = buildAISwotStakeholders([], null, 'fr'); + expect(fr[0].name).toBe('Coalition gouvernementale'); + expect(fr[1].name).toBe('Opposition sociale-démocrate'); + }); + + it('Arabic names are populated', () => { + const ar = buildAISwotStakeholders([], null, 'ar'); + for (const s of ar) { + expect(s.name.length).toBeGreaterThan(0); + } + }); + + it('Japanese names are populated', () => { + const ja = buildAISwotStakeholders([], null, 'ja'); + expect(ja[0].name).toBe('政府連立'); + }); + + it('each language has non-empty roles', () => { + for (const lang of langs) { + const result = buildAISwotStakeholders([], null, lang); + for (const s of result) { + expect(s.role?.length ?? 0).toBeGreaterThan(0); + } + } + }); + + it('context metadata labels are localised for Swedish', () => { + const sv = buildAISwotStakeholders([], null, 'sv'); + expect(sv[0].swot.context).toContain('Konfidens:'); + }); + + it('context metadata labels are localised for Japanese', () => { + const ja = buildAISwotStakeholders([], null, 'ja'); + expect(ja[0].swot.context).toContain('信頼度:'); + }); + }); + + describe('summary text content', () => { + it('documents are summarised over title when summary is longer than 20 chars', () => { + const docs = [makeDoc({ + doktyp: 'prop', + titel: 'Short', + summary: 'A very long and informative summary that should be preferred over the title', + })]; + const result = buildAISwotStakeholders(docs, null, 'en'); + const govStrengths = result[0].swot.strengths as AISwotEntry[]; + // The text should come from the summary, not just "Short" + const usedSummary = govStrengths.some(e => e.text.includes('informative summary')); + expect(usedSummary).toBe(true); + }); + + it('falls back to title when summary is exactly 20 characters (threshold boundary)', () => { + const docs = [makeDoc({ + doktyp: 'prop', + titel: 'Proposition 2025/26:1', + summary: '12345678901234567890', // exactly 20 chars — does NOT meet > 20 threshold + })]; + const result = buildAISwotStakeholders(docs, null, 'en'); + const govStrengths = result[0].swot.strengths as AISwotEntry[]; + // Summary is exactly 20 chars → not > 20, so title is used instead + const usedTitle = govStrengths.some(e => e.text.includes('Proposition')); + const usedSummary = govStrengths.some(e => e.text === '12345678901234567890'); + expect(usedTitle).toBe(true); + expect(usedSummary).toBe(false); + }); + + it('uses summary when it is 21 characters (just above threshold)', () => { + const docs = [makeDoc({ + doktyp: 'prop', + titel: 'Should not appear', + summary: '123456789012345678901', // 21 chars — meets > 20 threshold + })]; + const result = buildAISwotStakeholders(docs, null, 'en'); + const govStrengths = result[0].swot.strengths as AISwotEntry[]; + // Summary is 21 chars → used directly + const usedSummary = govStrengths.some(e => e.text === '123456789012345678901'); + expect(usedSummary).toBe(true); + }); + }); +}); diff --git a/tests/stakeholder-swot-section.test.ts b/tests/stakeholder-swot-section.test.ts index 77ce6d8577..d09f0200ff 100644 --- a/tests/stakeholder-swot-section.test.ts +++ b/tests/stakeholder-swot-section.test.ts @@ -1,13 +1,21 @@ /** * Tests for generateStakeholderSwotSection — multi-stakeholder SWOT analysis. * Validates HTML structure, multiple stakeholder cards, impact badges, XSS - * escaping, strategic context, and TemplateSection shape. + * escaping, strategic context, TemplateSection shape, trend indicators, + * justification sections, and enhanced AI entry rendering. */ import { describe, it, expect } from 'vitest'; import { generateStakeholderSwotSection } from '../scripts/data-transformers/content-generators/stakeholder-swot-section.js'; import type { StakeholderSwot } from '../scripts/data-transformers/content-generators/stakeholder-swot-section.js'; -import type { SwotData } from '../scripts/types/article.js'; +import type { SwotData, SwotEntry, TrendDirection } from '../scripts/types/article.js'; + +/** Extended entry shape matching AISwotEntry (without importing the ai-swot-analyzer module) */ +interface EnhancedEntry extends SwotEntry { + justification?: string; + trendDirection?: TrendDirection; + quantitativeEvidence?: string; +} /** Minimal SWOT data for tests */ function makeSwot(overrides: Partial = {}): SwotData { @@ -220,4 +228,199 @@ describe('generateStakeholderSwotSection', () => { }); expect(section.html).toContain('Recent polling shows movement.'); }); + + // --------------------------------------------------------------------------- + // Enhanced AI entry rendering — trend indicators, justification, evidence + // --------------------------------------------------------------------------- + + describe('trend indicator rendering', () => { + it('renders ↑ symbol for improving trend', () => { + const entry: EnhancedEntry = { + text: 'Policy momentum', + impact: 'high', + trendDirection: 'improving', + }; + const section = generateStakeholderSwotSection({ + stakeholders: [{ name: 'Gov', swot: makeSwot({ strengths: [entry] }) }], + lang: 'en', + }); + expect(section.html).toContain('↑'); + expect(section.html).toContain('swot-trend--improving'); + }); + + it('renders → symbol for stable trend', () => { + const entry: EnhancedEntry = { + text: 'Stable situation', + impact: 'medium', + trendDirection: 'stable', + }; + const section = generateStakeholderSwotSection({ + stakeholders: [{ name: 'Gov', swot: makeSwot({ strengths: [entry] }) }], + lang: 'en', + }); + expect(section.html).toContain('→'); + expect(section.html).toContain('swot-trend--stable'); + }); + + it('renders ↓ symbol for deteriorating trend', () => { + const entry: EnhancedEntry = { + text: 'Declining support', + impact: 'low', + trendDirection: 'deteriorating', + }; + const section = generateStakeholderSwotSection({ + stakeholders: [{ name: 'Gov', swot: makeSwot({ weaknesses: [entry] }) }], + lang: 'en', + }); + expect(section.html).toContain('↓'); + expect(section.html).toContain('swot-trend--deteriorating'); + }); + + it('renders trend aria-label for accessibility', () => { + const entry: EnhancedEntry = { + text: 'Test', + impact: 'high', + trendDirection: 'improving', + }; + const section = generateStakeholderSwotSection({ + stakeholders: [{ name: 'Gov', swot: makeSwot({ strengths: [entry] }) }], + lang: 'en', + }); + expect(section.html).toContain('aria-label="Improving"'); + }); + + it('renders localised trend aria-label for Swedish', () => { + const entry: EnhancedEntry = { + text: 'Test', + impact: 'high', + trendDirection: 'improving', + }; + const section = generateStakeholderSwotSection({ + stakeholders: [{ name: 'Gov', swot: makeSwot({ strengths: [entry] }) }], + lang: 'sv', + }); + expect(section.html).toContain('aria-label="Förbättras"'); + }); + + it('does not render trend indicator when trendDirection is absent', () => { + const entry: SwotEntry = { text: 'No trend', impact: 'medium' }; + const section = generateStakeholderSwotSection({ + stakeholders: [{ name: 'Gov', swot: makeSwot({ strengths: [entry] }) }], + lang: 'en', + }); + expect(section.html).not.toContain('swot-trend'); + }); + }); + + describe('justification section rendering', () => { + it('renders expandable justification in a
    element', () => { + const entry: EnhancedEntry = { + text: 'Key policy', + impact: 'high', + justification: 'This policy is significant because it restructures the welfare system.', + }; + const section = generateStakeholderSwotSection({ + stakeholders: [{ name: 'Gov', swot: makeSwot({ strengths: [entry] }) }], + lang: 'en', + }); + expect(section.html).toContain('
    '); + expect(section.html).toContain('This policy is significant because it restructures the welfare system.'); + }); + + it('renders justification as a

    inside

    ', () => { + const entry: EnhancedEntry = { + text: 'Key policy', + impact: 'high', + justification: 'Detailed analysis here.', + }; + const section = generateStakeholderSwotSection({ + stakeholders: [{ name: 'Gov', swot: makeSwot({ strengths: [entry] }) }], + lang: 'en', + }); + expect(section.html).toContain('

    Detailed analysis here.

    '); + }); + + it('escapes XSS in justification', () => { + const entry: EnhancedEntry = { + text: 'Safe text', + impact: 'medium', + justification: '', + }; + const section = generateStakeholderSwotSection({ + stakeholders: [{ name: 'Gov', swot: makeSwot({ strengths: [entry] }) }], + lang: 'en', + }); + expect(section.html).not.toContain('', + }; + const section = generateStakeholderSwotSection({ + stakeholders: [{ name: 'Gov', swot: makeSwot({ strengths: [entry] }) }], + lang: 'en', + }); + expect(section.html).not.toContain('