Skip to content

Commit 77fd2b0

Browse files
committed
feat(intelligence): add risk analysis, confidence levels, and influence scoring
- Create risk-analysis.ts with calculateCoalitionRiskIndex, detectAnomalousPatterns, and generateTrendComparison for political intelligence analysis - Add ConfidenceLevel type and assessConfidenceLevel() to policy-analysis.ts - Add NarrativeFrame type and detectNarrativeFrames() to policy-analysis.ts - Add calculateInfluenceScore() to document-analysis.ts for committee influence scoring - Add early warning flags to generateDocumentIntelligenceAnalysis() when CIA data indicates coalition instability and high-influence documents are at risk - Export all new functions and types from data-transformers/index.ts - Add 32 tests in tests/risk-analysis.test.ts with full coverage of new functions All 1855 tests pass (1823 pre-existing + 32 new).
1 parent 5eb5554 commit 77fd2b0

5 files changed

Lines changed: 896 additions & 1 deletion

File tree

scripts/data-transformers/document-analysis.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,58 @@ import {
2525
} from './helpers.js';
2626
import { detectPolicyDomains, generatePolicySignificance, generateDeepPolicyAnalysis } from './policy-analysis.js';
2727

28+
/** Committee codes with known high-influence weighting */
29+
const HIGH_INFLUENCE_COMMITTEES = new Set(['FiU', 'KU', 'JuU', 'UU', 'FöU', 'SoU']);
30+
/** Document types that carry higher parliamentary influence */
31+
const HIGH_INFLUENCE_TYPES = new Set(['prop', 'bet', 'skr', 'dir']);
32+
33+
/**
34+
* Calculate an influence score (0-100) for a parliamentary document.
35+
* Considers committee tier, document type, policy domain breadth,
36+
* and the presence of full content as a proxy for document depth.
37+
*
38+
* @param doc - The document to score
39+
* @returns Influence score 0-100
40+
*/
41+
export function calculateInfluenceScore(doc: RawDocument): number {
42+
let score = 0;
43+
44+
// Document type weighting (propositions > reports > motions)
45+
const docType = doc.doktyp || doc.documentType || '';
46+
if (HIGH_INFLUENCE_TYPES.has(docType)) {
47+
score += docType === 'prop' ? 35 : docType === 'bet' ? 30 : 20;
48+
} else if (docType === 'mot') {
49+
score += 10;
50+
} else {
51+
score += 15; // unknown type gets moderate weight
52+
}
53+
54+
// Committee tier weighting
55+
const organ = doc.organ || doc.committee || '';
56+
if (HIGH_INFLUENCE_COMMITTEES.has(organ)) {
57+
score += 30;
58+
} else if (organ) {
59+
score += 15;
60+
}
61+
62+
// Policy domain breadth (more domains = broader impact)
63+
const domains = detectPolicyDomains(doc);
64+
score += Math.min(20, domains.length * 7);
65+
66+
// Content richness (full text available indicates substantive document)
67+
if (doc.fullText || doc.fullContent) {
68+
score += 10;
69+
}
70+
71+
// Party sponsorship (government documents inherently carry more weight)
72+
const isGovernment = !doc.parti || doc.doktyp === 'prop';
73+
if (isGovernment && docType !== 'mot') {
74+
score += 5;
75+
}
76+
77+
return Math.min(100, score);
78+
}
79+
2880
/** Matches a strict proposition ID (YYYY/YY:NNN) in a motion title. */
2981
const PROP_REFERENCE_REGEX = /med anledning av prop\.\s+(\d{4}\/\d{2}:\d+)/i;
3082

@@ -255,6 +307,27 @@ export function generateDocumentIntelligenceAnalysis(doc: RawDocument, docType:
255307
}
256308
}
257309

310+
// ── EARLY WARNING: high-influence documents with stability risk indicators ─
311+
if (cia) {
312+
const influenceScore = calculateInfluenceScore(doc);
313+
const stabilityScore = cia.coalitionStability.stabilityScore;
314+
const majorityMargin = cia.coalitionStability.majorityMargin;
315+
316+
// Flag high-influence documents during coalition instability
317+
if (influenceScore >= 60 && stabilityScore < 50) {
318+
parts.push(
319+
`<small class="early-warning">⚠ Early warning: This high-influence document (score: ${influenceScore}) ` +
320+
`arrives during a period of coalition instability (stability: ${stabilityScore}). ` +
321+
`Monitor closely for defections or procedural delays.</small>`
322+
);
323+
} else if (majorityMargin <= 2 && (docType === 'prop' || docType === 'bet')) {
324+
parts.push(
325+
`<small class="early-warning">⚠ Thin majority alert: With only ${majorityMargin} seat majority, ` +
326+
`this ${docType === 'prop' ? 'government bill' : 'committee report'} faces elevated defeat risk.</small>`
327+
);
328+
}
329+
}
330+
258331
// For propositions: coalition note is already in the article-level summary; skip per-document repetition.
259332
// (Moved to generateGenericContent key-takeaways section.)
260333

scripts/data-transformers/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,15 @@ export { L, isPersonProfileText } from './helpers.js';
3737
export { transformCalendarToEventGrid, extractTopics, extractWatchPoints } from './calendar.js';
3838

3939
// ── Re-export document analysis ────────────────────────────────────────────
40-
export { groupMotionsByProposition, groupPropositionsByCommittee } from './document-analysis.js';
40+
export { groupMotionsByProposition, groupPropositionsByCommittee, calculateInfluenceScore } from './document-analysis.js';
41+
42+
// ── Re-export policy analysis (confidence levels & narrative framing) ──────
43+
export type { ConfidenceLevel, NarrativeFrame } from './policy-analysis.js';
44+
export { assessConfidenceLevel, detectNarrativeFrames } from './policy-analysis.js';
45+
46+
// ── Re-export risk analysis ────────────────────────────────────────────────
47+
export type { RiskLevel, CoalitionRiskIndex, CoalitionRiskComponents, AnomalyFlag, TrendDirection, TrendDataPoint, TrendComparison } from './risk-analysis.js';
48+
export { calculateCoalitionRiskIndex, detectAnomalousPatterns, generateTrendComparison } from './risk-analysis.js';
4149

4250
// ── Re-export metadata ─────────────────────────────────────────────────────
4351
export {

scripts/data-transformers/policy-analysis.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,43 @@
55
* migration, EU, justice, labour, housing, transport, and trade domains
66
* using keyword matching against Swedish document titles.
77
*
8+
* Also provides confidence level assessment for intelligence analysis.
9+
*
810
* @author Hack23 AB
911
* @license Apache-2.0
1012
*/
1113

14+
/**
15+
* Confidence level for intelligence assessments.
16+
* Reflects the quality and quantity of supporting evidence.
17+
*/
18+
export type ConfidenceLevel = 'HIGH' | 'MEDIUM' | 'LOW';
19+
20+
/**
21+
* Assess the confidence level of an intelligence analysis based on
22+
* the number of corroborating evidence items and the quality of sources.
23+
*
24+
* @param evidenceCount - Number of distinct evidence items supporting the assessment
25+
* @param sourceQuality - Quality score of sources (0-100, higher = better)
26+
* @returns Confidence level classification
27+
*/
28+
export function assessConfidenceLevel(evidenceCount: number, sourceQuality: number): ConfidenceLevel {
29+
const normalizedEvidence = Math.max(0, evidenceCount);
30+
const normalizedQuality = Math.max(0, Math.min(100, sourceQuality));
31+
32+
// HIGH: Multiple evidence items with good-quality sources
33+
if (normalizedEvidence >= 5 && normalizedQuality >= 70) return 'HIGH';
34+
if (normalizedEvidence >= 3 && normalizedQuality >= 85) return 'HIGH';
35+
36+
// LOW: Very few evidence items or very poor source quality
37+
if (normalizedEvidence === 0) return 'LOW';
38+
if (normalizedEvidence <= 1 && normalizedQuality < 50) return 'LOW';
39+
if (normalizedQuality < 30) return 'LOW';
40+
41+
// MEDIUM: everything in between
42+
return 'MEDIUM';
43+
}
44+
1245
import { escapeHtml } from '../html-utils.js';
1346
import type { Language } from '../types/language.js';
1447
import type { RawDocument } from './types.js';
@@ -86,6 +119,75 @@ export function detectPolicyDomains(doc: RawDocument, lang: Language | string =
86119
return Array.from(set);
87120
}
88121

122+
/**
123+
* Dominant political narrative frames detected in document titles.
124+
* These represent recurring rhetorical frames used across parties.
125+
*/
126+
export type NarrativeFrame =
127+
| 'law-and-order'
128+
| 'welfare-state-defence'
129+
| 'fiscal-responsibility'
130+
| 'green-transition'
131+
| 'national-security'
132+
| 'integration-challenge'
133+
| 'eu-sovereignty'
134+
| 'workers-rights';
135+
136+
/**
137+
* Detect dominant narrative frames in a document title.
138+
* Narrative framing reveals which rhetorical strategies are being employed
139+
* regardless of the specific policy domain.
140+
*
141+
* @param doc - Document to analyse
142+
* @returns Array of detected narrative frames (deduplicated)
143+
*/
144+
export function detectNarrativeFrames(doc: RawDocument): NarrativeFrame[] {
145+
const title = (doc.titel || doc.title || '').toLowerCase();
146+
const frames = new Set<NarrativeFrame>();
147+
148+
// Law-and-order: crime, punishment, police
149+
if (title.includes('brott') || title.includes('straff') || title.includes('polis') ||
150+
title.includes('kriminal') || title.includes('gäng') || title.includes('säker'))
151+
frames.add('law-and-order');
152+
153+
// Welfare-state defence: healthcare, social services, welfare
154+
if (title.includes('välfärd') || title.includes('omsorg') || title.includes('social') ||
155+
title.includes('pension') || title.includes('bidrag') || title.includes('trygghet'))
156+
frames.add('welfare-state-defence');
157+
158+
// Fiscal responsibility: budgets, debt, taxes
159+
if (title.includes('budget') || title.includes('skuld') || title.includes('bespar') ||
160+
title.includes('effektiv') || title.includes('kostnad') || title.includes('överskott'))
161+
frames.add('fiscal-responsibility');
162+
163+
// Green transition: climate, environment, energy
164+
if (title.includes('klimat') || title.includes('hållbar') || title.includes('grön') ||
165+
title.includes('utsläpp') || title.includes('förnybar') || title.includes('energiomstäl'))
166+
frames.add('green-transition');
167+
168+
// National security: defence, preparedness, NATO
169+
if (title.includes('försvar') || title.includes('nato') || title.includes('beredskap') ||
170+
title.includes('totalförsvar') || title.includes('säkerhetsskydd'))
171+
frames.add('national-security');
172+
173+
// Integration challenge: migration, asylum, citizenship
174+
if (title.includes('integr') || title.includes('migration') || title.includes('invandring') ||
175+
title.includes('asyl') || title.includes('utvisning'))
176+
frames.add('integration-challenge');
177+
178+
// EU sovereignty: EU, European, sovereignty
179+
if (/\beu\b/.test(title) || title.includes('europa') || title.includes('suveränitet') ||
180+
title.includes('direktiv') || title.includes('förordning'))
181+
frames.add('eu-sovereignty');
182+
183+
// Workers' rights: labour, unions, wages
184+
if (title.includes('facklig') || title.includes('lön') || title.includes('arbetsrätt') ||
185+
title.includes('kollektivavtal') || title.includes('strejk') || title.includes('anstäl'))
186+
frames.add('workers-rights');
187+
188+
return Array.from(frames);
189+
}
190+
89191
type _LangPair = { en: Record<string, string>; sv: Record<string, string> };
90192

91193
/** Module-level constant — allocated once, shared across all calls. */

0 commit comments

Comments
 (0)