euparliamentmonitor 0.8.19 → 0.8.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/package.json +7 -7
  2. package/scripts/constants/language-articles.d.ts +4 -0
  3. package/scripts/constants/language-articles.js +20 -0
  4. package/scripts/constants/language-ui.d.ts +8 -8
  5. package/scripts/constants/language-ui.js +64 -64
  6. package/scripts/constants/languages.d.ts +2 -2
  7. package/scripts/constants/languages.js +2 -2
  8. package/scripts/generators/news-enhanced.js +13 -3
  9. package/scripts/generators/pipeline/analysis-classification.d.ts +49 -0
  10. package/scripts/generators/pipeline/analysis-classification.js +333 -0
  11. package/scripts/generators/pipeline/analysis-existing.d.ts +67 -0
  12. package/scripts/generators/pipeline/analysis-existing.js +547 -0
  13. package/scripts/generators/pipeline/analysis-helpers.d.ts +140 -0
  14. package/scripts/generators/pipeline/analysis-helpers.js +266 -0
  15. package/scripts/generators/pipeline/analysis-risk.d.ts +49 -0
  16. package/scripts/generators/pipeline/analysis-risk.js +417 -0
  17. package/scripts/generators/pipeline/analysis-stage.d.ts +19 -39
  18. package/scripts/generators/pipeline/analysis-stage.js +219 -1704
  19. package/scripts/generators/pipeline/analysis-threats.d.ts +41 -0
  20. package/scripts/generators/pipeline/analysis-threats.js +142 -0
  21. package/scripts/generators/pipeline/fetch-stage.d.ts +25 -15
  22. package/scripts/generators/pipeline/fetch-stage.js +293 -117
  23. package/scripts/generators/strategies/article-strategy.d.ts +126 -7
  24. package/scripts/generators/strategies/article-strategy.js +491 -1
  25. package/scripts/generators/strategies/breaking-news-strategy.js +98 -8
  26. package/scripts/generators/strategies/committee-reports-strategy.js +23 -2
  27. package/scripts/generators/strategies/month-ahead-strategy.js +23 -2
  28. package/scripts/generators/strategies/monthly-review-strategy.js +13 -1
  29. package/scripts/generators/strategies/motions-strategy.js +15 -1
  30. package/scripts/generators/strategies/propositions-strategy.js +15 -1
  31. package/scripts/generators/strategies/week-ahead-strategy.js +19 -1
  32. package/scripts/generators/strategies/weekly-review-strategy.js +17 -1
  33. package/scripts/generators/synthesis-summary.d.ts +93 -0
  34. package/scripts/generators/synthesis-summary.js +364 -0
  35. package/scripts/index.d.ts +5 -2
  36. package/scripts/index.js +6 -1
  37. package/scripts/mcp/ep-mcp-client.d.ts +34 -1
  38. package/scripts/mcp/ep-mcp-client.js +110 -2
  39. package/scripts/mcp/mcp-connection.d.ts +3 -1
  40. package/scripts/mcp/mcp-connection.js +35 -4
  41. package/scripts/templates/article-template.js +24 -22
  42. package/scripts/templates/section-builders.js +2 -5
  43. package/scripts/types/index.d.ts +2 -1
  44. package/scripts/types/mcp.d.ts +7 -0
  45. package/scripts/types/political-classification.d.ts +1 -1
  46. package/scripts/types/quality.d.ts +9 -6
  47. package/scripts/types/significance.d.ts +130 -0
  48. package/scripts/types/significance.js +4 -0
  49. package/scripts/utils/article-quality-scorer.d.ts +13 -11
  50. package/scripts/utils/article-quality-scorer.js +36 -23
  51. package/scripts/utils/file-utils.d.ts +2 -2
  52. package/scripts/utils/file-utils.js +2 -2
  53. package/scripts/utils/html-sanitize.d.ts +10 -0
  54. package/scripts/utils/html-sanitize.js +32 -0
  55. package/scripts/utils/political-classification.d.ts +8 -7
  56. package/scripts/utils/political-classification.js +8 -7
  57. package/scripts/utils/political-risk-assessment.d.ts +1 -1
  58. package/scripts/utils/political-risk-assessment.js +1 -1
  59. package/scripts/utils/significance-scoring.d.ts +97 -0
  60. package/scripts/utils/significance-scoring.js +190 -0
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  /**
4
4
  * @module Generators/Pipeline/AnalysisStage
5
- * @description Analysis-first pre-generation pipeline stage.
5
+ * @description Analysis-first pre-generation pipeline stage — **orchestrator**.
6
6
  *
7
7
  * Executes between the Fetch and Generate stages, consuming already-fetched
8
8
  * European Parliament data and running the full suite of political intelligence
@@ -11,35 +11,25 @@
11
11
  * news articles in all 14 languages.
12
12
  *
13
13
  * This stage is **side-effect-only**: it writes analysis markdown and a
14
- * `manifest.json` to disk under `analysis/{date}/{article-type}/`. When
14
+ * `manifest.json` to disk under `analysis/daily/{date}/{article-type}/`. When
15
15
  * `articleTypeSlug` is provided (recommended for agentic workflows), each
16
16
  * article type writes to its own subdirectory, preventing merge conflicts
17
- * when multiple workflows run concurrently on the same date. The returned
18
- * {@link AnalysisContext} is informational and currently not consumed by the
19
- * generate stage; strategies read the analysis output from disk instead.
20
- * Analysis artifacts are committed to the repository for review and
21
- * political intelligence improvement.
17
+ * when multiple workflows run concurrently on the same date.
22
18
  *
23
- * All MCP data EP feeds, World Bank economic indicators, and OSINT
24
- * analytical outputs (political landscape, voting anomalies, coalition
25
- * dynamics) is persisted under `data/` subdirectories for verification
26
- * and later reuse.
19
+ * Analysis methods are grouped into four categories (each in its own module):
20
+ * - **Classification** (`analysis-classification.ts`): significance, impact-matrix, actor-mapping, forces
21
+ * - **Threat Assessment** (`analysis-threats.ts`): political-threat-landscape, actor-threat, consequence-trees, disruption
22
+ * - **Risk Scoring** (`analysis-risk.ts`): risk-matrix, capital-risk, quantitative-swot, velocity-risk, agent-workflow
23
+ * - **Existing** (`analysis-existing.ts`): deep-analysis, stakeholder-analysis, coalition-analysis, voting-patterns, cross-session-intelligence
27
24
  *
28
- * Analysis methods are grouped into four categories:
29
- * - **Classification** (Issues #804): significance, impact-matrix, actor-mapping, forces
30
- * - **Threat Assessment** (Issues #805): political-threat-landscape, actor-threat, consequence-trees, disruption
31
- * - **Risk Scoring** (Issues #806): risk-matrix, capital-risk, quantitative-swot, velocity-risk, agent-workflow
32
- * - **Existing** (current codebase): deep-analysis, stakeholder-analysis, coalition-analysis, voting-patterns, cross-session-intelligence
33
- *
34
- * Each method writes a markdown file; failures are isolated so other methods
35
- * can continue. A {@link AnalysisManifest} JSON file is written at the end.
25
+ * Shared utilities live in `analysis-helpers.ts`.
36
26
  *
37
27
  * @example
38
28
  * ```ts
39
29
  * const ctx = await runAnalysisStage(fetchedData, {
40
30
  * articleTypes: [ArticleCategory.WEEK_AHEAD],
41
31
  * date: '2026-03-26',
42
- * outputDir: 'analysis',
32
+ * outputDir: 'analysis/daily',
43
33
  * });
44
34
  * console.log(ctx.completedMethods);
45
35
  * ```
@@ -47,200 +37,15 @@
47
37
  import fs from 'fs';
48
38
  import path from 'path';
49
39
  import { randomUUID } from 'crypto';
50
- import { ArticleCategory } from '../../types/index.js';
51
- import { detectVotingTrends, computeCrossSessionCoalitionStability, } from '../../utils/intelligence-analysis.js';
52
- import { assessPoliticalSignificance, buildImpactMatrix, classifyPoliticalActors, analyzePoliticalForces, } from '../../utils/political-classification.js';
53
- import { assessPoliticalThreats, buildActorThreatProfiles, buildConsequenceTree, analyzeLegislativeDisruption, generateThreatAssessmentMarkdown, } from '../../utils/political-threat-assessment.js';
54
- import { assessLegislativeVelocityRisk, runAgentRiskAssessment, generateRiskAssessmentMarkdown, calculatePoliticalRiskScore, buildQuantitativeSWOT, createScoredSWOTItem, createScoredOpportunityOrThreat, createRiskDriver, } from '../../utils/political-risk-assessment.js';
55
- import { ensureDirectoryExists, atomicWrite, resolveUniqueAnalysisDir, } from '../../utils/file-utils.js';
56
- // ─── Markdown constants ───────────────────────────────────────────────────────
57
- /** Empty table row placeholder for 6-column tables */
58
- const EMPTY_TABLE_ROW_6 = '| | | — | — | — | — |';
59
- // ─── Sanitization helpers ─────────────────────────────────────────────────────
60
- /**
61
- * Sanitize untrusted text for safe use in a Markdown table cell.
62
- *
63
- * Escapes pipe characters, backslashes, and HTML entities, then normalizes
64
- * whitespace to prevent table layout corruption from external MCP data.
65
- *
66
- * @param input - Untrusted cell text
67
- * @returns Sanitized text safe for Markdown table cells
68
- */
69
- function sanitizeCell(input) {
70
- return input
71
- .replace(/\\/g, '\\\\')
72
- .replace(/\|/g, '\\|')
73
- .replace(/&/g, '&')
74
- .replace(/</g, '&lt;')
75
- .replace(/>/g, '&gt;')
76
- .replace(/[\r\n]+/g, ' ')
77
- .trim();
78
- }
79
- // ─── Data coercion helpers ────────────────────────────────────────────────────
80
- /**
81
- * Sanitize a document identifier for safe use as a filesystem filename.
82
- *
83
- * Replaces characters unsafe for filenames with hyphens, collapses runs of
84
- * hyphens, trims, and lowercases. When the result exceeds 80 characters,
85
- * a deterministic hash suffix is appended to avoid collisions between IDs
86
- * that share the same first 80 characters. Falls back to a deterministic
87
- * hash of the input when the sanitized result is empty.
88
- *
89
- * @param id - Raw document identifier (e.g. "TA-10-2026-0094", procedure reference)
90
- * @returns Filesystem-safe identifier string (max 80 chars)
91
- */
92
- function sanitizeDocumentId(id) {
93
- const full = id
94
- .toLowerCase()
95
- .replace(/[^a-z0-9]+/g, '-')
96
- .replace(/^-/, '')
97
- .replace(/-$/, '');
98
- if (!full) {
99
- // Deterministic fallback: simple hash from input string for reproducibility
100
- let hash = 0;
101
- for (let i = 0; i < id.length; i++) {
102
- hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0;
103
- }
104
- return `anon-${Math.abs(hash).toString(36).slice(0, 12)}`;
105
- }
106
- // When truncation occurs, append a short hash to avoid collisions
107
- if (full.length > 80) {
108
- let hash = 0;
109
- for (let i = 0; i < id.length; i++) {
110
- hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0;
111
- }
112
- return `${full.slice(0, 72)}-${Math.abs(hash).toString(36).slice(0, 7)}`;
113
- }
114
- return full;
115
- }
116
- /** All feed array keys that contain individually-analysable documents */
117
- const DOCUMENT_FEED_KEYS = [
118
- 'adoptedTexts',
119
- 'procedures',
120
- 'documents',
121
- 'plenaryDocuments',
122
- 'committeeDocuments',
123
- 'plenarySessionDocuments',
124
- 'externalDocuments',
125
- 'events',
126
- ];
127
- /**
128
- * Extract a human-readable identifier from a raw feed item.
129
- *
130
- * Tries common EP data shapes (`docId`, `procedureId`, `id`, `eventId`,
131
- * `title`) and falls back to a deterministic hash of the item's JSON
132
- * representation for truly anonymous items, ensuring reproducibility.
133
- *
134
- * @param item - Raw feed item object
135
- * @returns Best-effort identifier string
136
- */
137
- function extractDocumentId(item) {
138
- for (const key of ['docId', 'procedureId', 'id', 'eventId']) {
139
- const val = item[key]; // eslint-disable-line security/detect-object-injection -- keys are string literals
140
- if (typeof val === 'string' && val.length > 0)
141
- return val;
142
- }
143
- const title = item['title'];
144
- if (typeof title === 'string' && title.length > 0) {
145
- // Append a short hash to title-based IDs to avoid collisions when
146
- // multiple items share identical title prefixes
147
- const repr = JSON.stringify(item);
148
- let hash = 0;
149
- for (let i = 0; i < repr.length; i++) {
150
- hash = ((hash << 5) - hash + repr.charCodeAt(i)) | 0;
151
- }
152
- return `${title.slice(0, 50)}-${Math.abs(hash).toString(36).slice(0, 8)}`;
153
- }
154
- // Deterministic fallback: hash of stringified item for reproducible dedup
155
- const repr = JSON.stringify(item);
156
- let hash = 0;
157
- for (let i = 0; i < repr.length; i++) {
158
- hash = ((hash << 5) - hash + repr.charCodeAt(i)) | 0;
159
- }
160
- return `anonymous-${Math.abs(hash).toString(36)}`;
161
- }
162
- /**
163
- * Extract a human-readable title from a raw feed item.
164
- *
165
- * @param item - Raw feed item object
166
- * @returns Title string or fallback
167
- */
168
- function extractDocumentTitle(item) {
169
- const title = item['title'];
170
- if (typeof title === 'string' && title.length > 0)
171
- return title;
172
- const label = item['label'] ?? item['name'] ?? item['description'];
173
- if (typeof label === 'string' && label.length > 0)
174
- return label;
175
- return 'Untitled document';
176
- }
177
- /**
178
- * Safely extract an array from fetchedData by key.
179
- * @param data - Raw fetched data record
180
- * @param key - Key to extract
181
- * @returns Array or empty array if missing/invalid
182
- */
183
- function safeArr(data, key) {
184
- const val = data[key]; // eslint-disable-line security/detect-object-injection -- key is a literal string from caller
185
- return Array.isArray(val) ? val : [];
186
- }
187
- /**
188
- * Cast fetchedData to ClassificationInput for the classification functions.
189
- * @param data - Raw fetched data record
190
- * @returns ClassificationInput-compatible object
191
- */
192
- function toClassificationInput(data) {
193
- return data;
194
- }
195
- /**
196
- * Cast fetchedData to ThreatAssessmentInput for the threat assessment functions.
197
- * @param data - Raw fetched data record
198
- * @returns ThreatAssessmentInput-compatible object
199
- */
200
- function toThreatInput(data) {
201
- return {
202
- votingRecords: safeArr(data, 'votingRecords'),
203
- coalitionData: safeArr(data, 'coalitions'),
204
- mepInfluence: safeArr(data, 'mepUpdates'),
205
- procedures: safeArr(data, 'procedures'),
206
- anomalies: safeArr(data, 'anomalies'),
207
- questions: safeArr(data, 'questions'),
208
- };
209
- }
210
- /** Keys in fetchedData that count as substantive EP data */
211
- const SUBSTANTIVE_DATA_KEYS = [
212
- 'events',
213
- 'procedures',
214
- 'adoptedTexts',
215
- 'documents',
216
- 'votingRecords',
217
- 'coalitions',
218
- 'questions',
219
- 'mepUpdates',
220
- 'plenaryDocuments',
221
- 'committeeDocuments',
222
- 'plenarySessionDocuments',
223
- 'externalDocuments',
224
- 'declarations',
225
- 'corporateBodies',
226
- ];
227
- /**
228
- * Check whether the fetched data contains any substantive EP data.
229
- *
230
- * Returns `true` when at least one data category has non-empty arrays.
231
- * Used to gate analysis execution — analysis should not run on empty data.
232
- *
233
- * @param data - Raw fetched data record
234
- * @returns true if any substantive data is present
235
- */
236
- export function hasSubstantiveData(data) {
237
- for (const key of SUBSTANTIVE_DATA_KEYS) {
238
- const arr = safeArr(data, key);
239
- if (arr.length > 0)
240
- return true;
241
- }
242
- return false;
243
- }
40
+ import { ensureDirectoryExists, resolveUniqueAnalysisDir } from '../../utils/file-utils.js';
41
+ // ─── Re-export shared helpers used by downstream consumers ────────────────────
42
+ import { hasSubstantiveData, sanitizeDocumentId, writeTextFile } from './analysis-helpers.js';
43
+ export { hasSubstantiveData } from './analysis-helpers.js';
44
+ // ─── Import category-specific builders ────────────────────────────────────────
45
+ import { CLASSIFICATION_BUILDERS, METHOD_SIGNIFICANCE_SCORING_ID, } from './analysis-classification.js';
46
+ import { THREAT_BUILDERS } from './analysis-threats.js';
47
+ import { RISK_BUILDERS } from './analysis-risk.js';
48
+ import { EXISTING_BUILDERS, METHOD_SYNTHESIS_SUMMARY_ID, METHOD_DOCUMENT_ANALYSIS, } from './analysis-existing.js';
244
49
  /** All analysis methods in default execution order */
245
50
  export const ALL_ANALYSIS_METHODS = [
246
51
  // Classification
@@ -265,6 +70,9 @@ export const ALL_ANALYSIS_METHODS = [
265
70
  'coalition-analysis',
266
71
  'voting-patterns',
267
72
  'cross-session-intelligence',
73
+ // Publication scoring & synthesis
74
+ 'significance-scoring',
75
+ 'synthesis-summary',
268
76
  // NOTE: 'document-analysis' is intentionally excluded from the default set.
269
77
  // It writes one markdown + one JSON file per feed item and can significantly
270
78
  // increase runtime and repository output size. Callers must opt-in by
@@ -301,35 +109,6 @@ function aggregateConfidence(results) {
301
109
  return 'medium';
302
110
  return 'low';
303
111
  }
304
- /**
305
- * Build a YAML-frontmatter header block for analysis markdown files.
306
- *
307
- * @param method - Analysis method identifier
308
- * @param date - ISO date of the analysis
309
- * @param confidence - Confidence level for this result
310
- * @returns Markdown frontmatter string
311
- */
312
- function buildMarkdownHeader(method, date, confidence) {
313
- return `---
314
- method: ${method}
315
- date: ${date}
316
- confidence: ${confidence}
317
- generated: ${new Date().toISOString()}
318
- ---
319
-
320
- `;
321
- }
322
- /**
323
- * Write a text file to disk.
324
- *
325
- * Used for both analysis markdown files and the analysis `manifest.json`.
326
- *
327
- * @param filePath - Absolute file path
328
- * @param content - File content as a UTF-8 string
329
- */
330
- function writeTextFile(filePath, content) {
331
- atomicWrite(filePath, content);
332
- }
333
112
  /**
334
113
  * Check whether a method's output file already exists (for incremental runs).
335
114
  *
@@ -344,1315 +123,101 @@ function methodOutputExists(filePath) {
344
123
  return false;
345
124
  }
346
125
  }
347
- // ─── Mermaid chart helpers ────────────────────────────────────────────────────
348
- /**
349
- * Map an impact level to a numeric value for Mermaid pie charts.
350
- *
351
- * @param level - Impact level string (e.g. 'none', 'low', 'moderate', 'high', 'critical')
352
- * @returns Numeric value for chart rendering
353
- */
354
- function impactToNum(level) {
355
- const map = {
356
- none: 5,
357
- low: 20,
358
- moderate: 45,
359
- high: 70,
360
- critical: 90,
361
- };
362
- return map[level.toLowerCase()] ?? 30;
363
- }
364
- /**
365
- * Map an impact level string to a coloured indicator emoji.
366
- *
367
- * @param level - Impact level string
368
- * @returns Emoji indicator
369
- */
370
- function impactIndicator(level) {
371
- const lower = level.toLowerCase();
372
- return lower === 'high' || lower === 'critical' ? '🔴' : lower === 'moderate' ? '🟡' : '🟢';
373
- }
374
- /**
375
- * Return the name of the highest-impact dimension from an impact matrix.
376
- *
377
- * @param matrix - Impact matrix with five dimension levels
378
- * @param matrix.legislativeImpact - Legislative impact level
379
- * @param matrix.coalitionImpact - Coalition impact level
380
- * @param matrix.publicOpinionImpact - Public opinion impact level
381
- * @param matrix.institutionalImpact - Institutional impact level
382
- * @param matrix.economicImpact - Economic impact level
383
- * @returns Name of the dimension with the highest impact score
384
- */
385
- function highestImpactDimension(matrix) {
386
- return ([
387
- { name: 'Legislative', level: matrix.legislativeImpact },
388
- { name: 'Coalition', level: matrix.coalitionImpact },
389
- { name: 'Public Opinion', level: matrix.publicOpinionImpact },
390
- { name: 'Institutional', level: matrix.institutionalImpact },
391
- { name: 'Economic', level: matrix.economicImpact },
392
- ].sort((a, b) => impactToNum(b.level) - impactToNum(a.level))[0]?.name ?? 'N/A');
393
- }
394
- // ─── Per-method markdown builders ────────────────────────────────────────────
395
- /**
396
- * Build markdown for the significance classification method.
397
- * Scores and ranks legislative items by political significance.
398
- *
399
- * @param fetchedData - Raw fetched EP data
400
- * @param date - Analysis date
401
- * @returns Markdown content string
402
- */
403
- function buildSignificanceClassificationMarkdown(fetchedData, date) {
404
- const input = toClassificationInput(fetchedData);
405
- const significance = assessPoliticalSignificance(input);
406
- const events = safeArr(fetchedData, 'events');
407
- const docs = safeArr(fetchedData, 'documents');
408
- const procedures = safeArr(fetchedData, 'procedures');
409
- const adoptedTexts = safeArr(fetchedData, 'adoptedTexts');
410
- const header = buildMarkdownHeader('significance-classification', date, significance === 'routine' ? 'medium' : 'high');
411
- const sigMap = {
412
- historic: 0.95,
413
- critical: 0.8,
414
- significant: 0.65,
415
- notable: 0.45,
416
- routine: 0.25,
417
- };
418
- const sigScore = sigMap[significance] ?? 0.25;
419
- return (header +
420
- `# Political Significance Classification
421
-
422
- ## Overall Significance: **${significance.toUpperCase()}**
423
-
424
- \`\`\`mermaid
425
- quadrantChart
426
- title Political Significance Assessment — ${date}
427
- x-axis Low Volume --> High Volume
428
- y-axis Low Impact --> High Impact
429
- quadrant-1 Critical Watch
430
- quadrant-2 Strategic Priority
431
- quadrant-3 Monitor
432
- quadrant-4 Routine Track
433
- Current Assessment: [${sigScore.toFixed(2)}, ${sigScore.toFixed(2)}]
434
- Events Signal: [${Math.min(events.length / 20, 0.95).toFixed(2)}, 0.60]
435
- Documents Signal: [${Math.min(docs.length / 20, 0.95).toFixed(2)}, 0.55]
436
- Procedures Signal: [${Math.min(procedures.length / 10, 0.95).toFixed(2)}, 0.75]
437
- Adopted Texts: [${Math.min(adoptedTexts.length / 10, 0.95).toFixed(2)}, 0.85]
438
- \`\`\`
439
-
440
- ## 5-Signal Model Scores
441
-
442
- | Signal | Raw Data | Score |
443
- |--------|----------|-------|
444
- | Volume | ${events.length} events, ${docs.length} documents | ${Math.min((events.length + docs.length) / 10, 5).toFixed(1)}/5 |
445
- | Pipeline | ${procedures.length} procedures | ${Math.min(procedures.length / 5, 5).toFixed(1)}/5 |
446
- | Output | ${adoptedTexts.length} adopted texts | ${Math.min(adoptedTexts.length / 5, 5).toFixed(1)}/5 |
447
- | Anomalies | Pattern deviation detection | — |
448
- | Coalition | Group alignment analysis | — |
449
-
450
- ## Data Summary
451
-
452
- | Metric | Value |
453
- |--------|-------|
454
- | Computed significance | ${significance.toUpperCase()} |
455
- | Total data points | ${events.length + docs.length + procedures.length + adoptedTexts.length} |
456
- | Events | ${events.length} |
457
- | Documents | ${docs.length} |
458
- | Procedures | ${procedures.length} |
459
- | Adopted texts | ${adoptedTexts.length} |
460
- | Date | ${date} |
461
-
462
- ## Date: ${date}
463
- `);
464
- }
465
- /**
466
- * Build markdown for the impact matrix method.
467
- *
468
- * @param fetchedData - Raw fetched EP data
469
- * @param date - Analysis date
470
- * @returns Markdown content string
471
- */
472
- function buildImpactMatrixMarkdown(fetchedData, date) {
473
- const input = toClassificationInput(fetchedData);
474
- const matrix = buildImpactMatrix(input);
475
- const header = buildMarkdownHeader('impact-matrix', date, 'medium');
476
- return (header +
477
- `# Political Impact Matrix
478
-
479
- ## Overall Significance: **${matrix.overallSignificance.toUpperCase()}**
480
-
481
- \`\`\`mermaid
482
- pie title Impact Distribution by Dimension — ${date}
483
- "Legislative" : ${impactToNum(matrix.legislativeImpact)}
484
- "Coalition" : ${impactToNum(matrix.coalitionImpact)}
485
- "Public Opinion" : ${impactToNum(matrix.publicOpinionImpact)}
486
- "Institutional" : ${impactToNum(matrix.institutionalImpact)}
487
- "Economic" : ${impactToNum(matrix.economicImpact)}
488
- \`\`\`
489
-
490
- ## Impact Dimensions
491
-
492
- | Dimension | Level | Indicator | Numeric |
493
- |-----------|-------|-----------|---------|
494
- | Legislative | ${matrix.legislativeImpact} | ${impactIndicator(matrix.legislativeImpact)} | ${impactToNum(matrix.legislativeImpact)} |
495
- | Coalition | ${matrix.coalitionImpact} | ${impactIndicator(matrix.coalitionImpact)} | ${impactToNum(matrix.coalitionImpact)} |
496
- | Public Opinion | ${matrix.publicOpinionImpact} | ${impactIndicator(matrix.publicOpinionImpact)} | ${impactToNum(matrix.publicOpinionImpact)} |
497
- | Institutional | ${matrix.institutionalImpact} | ${impactIndicator(matrix.institutionalImpact)} | ${impactToNum(matrix.institutionalImpact)} |
498
- | Economic | ${matrix.economicImpact} | ${impactIndicator(matrix.economicImpact)} | ${impactToNum(matrix.economicImpact)} |
499
-
500
- ## Summary
501
-
502
- | Metric | Value |
503
- |--------|-------|
504
- | Overall significance | ${matrix.overallSignificance.toUpperCase()} |
505
- | Highest impact | ${highestImpactDimension(matrix)} |
506
- | Date | ${date} |
507
-
508
- ## Date: ${date}
509
- `);
510
- }
511
- /**
512
- * Build markdown for the actor mapping method.
513
- *
514
- * @param fetchedData - Raw fetched EP data
515
- * @param date - Analysis date
516
- * @returns Markdown content string
517
- */
518
- function buildActorMappingMarkdown(fetchedData, date) {
519
- const input = toClassificationInput(fetchedData);
520
- const actors = classifyPoliticalActors(input);
521
- const header = buildMarkdownHeader('actor-mapping', date, actors.length > 0 ? 'medium' : 'low');
522
- const actorRows = actors.length > 0
523
- ? actors
524
- .map((a) => `| ${sanitizeCell(a.name)} | ${sanitizeCell(a.actorType)} | ${sanitizeCell(String(a.influence))} | ${sanitizeCell(a.position)} | ${sanitizeCell(a.role)} |`)
525
- .join('\n')
526
- : '| — | — | — | — | — |';
527
- // Build actor type distribution for Mermaid
528
- const actorTypes = actors.length > 0 ? [...new Set(actors.map((a) => a.actorType))] : [];
529
- const typeCounts = actorTypes.map((t) => ({
530
- type: t,
531
- count: actors.filter((a) => a.actorType === t).length,
532
- }));
533
- const mermaidPie = typeCounts.length > 0
534
- ? typeCounts.map((tc) => ` "${tc.type}" : ${tc.count}`).join('\n')
535
- : ' "No actors classified" : 1';
536
- return (header +
537
- `# Political Actor Mapping
538
-
539
- ## Actors Identified: ${actors.length}
540
-
541
- \`\`\`mermaid
542
- pie title Actor Type Distribution — ${date}
543
- ${mermaidPie}
544
- \`\`\`
545
-
546
- ## Actor Classification
547
-
548
- | Actor | Type | Influence | Position | Role |
549
- |-------|------|-----------|----------|------|
550
- ${actorRows}
551
-
552
- ## Type Counts
553
-
554
- | Type | Count |
555
- |------|-------|
556
- ${typeCounts.length > 0 ? typeCounts.map((tc) => `| ${tc.type} | ${tc.count} |`).join('\n') : '| — | 0 |'}
557
-
558
- ## Date: ${date}
559
- `);
560
- }
561
- /**
562
- * Build markdown for the political forces analysis method.
563
- *
564
- * @param fetchedData - Raw fetched EP data
565
- * @param date - Analysis date
566
- * @returns Markdown content string
567
- */
568
- function buildForcesAnalysisMarkdown(fetchedData, date) {
569
- const input = toClassificationInput(fetchedData);
570
- const forces = analyzePoliticalForces(input);
571
- const header = buildMarkdownHeader('forces-analysis', date, 'medium');
572
- const forceRow = (name, f) => `| ${name} | ${f.trend} | ${(f.strength * 100).toFixed(0)}% | ${f.keyActors.length > 0 ? f.keyActors.join(', ') : '—'} | ${f.confidence} |`;
573
- const cp = Math.max(1, Math.min(99, Math.round(forces.coalitionPower.strength * 100)));
574
- const op = Math.max(1, Math.min(99, Math.round(forces.oppositionPower.strength * 100)));
575
- const ib = Math.max(1, Math.min(99, Math.round(forces.institutionalBarriers.strength * 100)));
576
- const pp = Math.max(1, Math.min(99, Math.round(forces.publicPressure.strength * 100)));
577
- const ei = Math.max(1, Math.min(99, Math.round(forces.externalInfluences.strength * 100)));
578
- return (header +
579
- `# Political Forces Analysis
580
-
581
- \`\`\`mermaid
582
- pie title Political Force Distribution — ${date}
583
- "Coalition Power" : ${cp}
584
- "Opposition Power" : ${op}
585
- "Institutional Barriers" : ${ib}
586
- "Public Pressure" : ${pp}
587
- "External Influences" : ${ei}
588
- \`\`\`
589
-
590
- ## Forces Data
591
-
592
- | Force | Trend | Strength | Key Actors | Confidence |
593
- |-------|-------|----------|------------|------------|
594
- ${forceRow('Coalition Power', forces.coalitionPower)}
595
- ${forceRow('Opposition Power', forces.oppositionPower)}
596
- ${forceRow('Institutional Barriers', forces.institutionalBarriers)}
597
- ${forceRow('Public Pressure', forces.publicPressure)}
598
- ${forceRow('External Influences', forces.externalInfluences)}
599
-
600
- ## Balance
601
-
602
- | Metric | Value |
603
- |--------|-------|
604
- | Coalition vs Opposition | ${cp}% vs ${op}% |
605
- | Dominant force | ${cp > op ? 'Coalition' : op > cp ? 'Opposition' : 'Balanced'} |
606
- | Date | ${date} |
607
-
608
- ## Date: ${date}
609
- `);
610
- }
611
- /**
612
- * Build markdown for the political threat landscape assessment.
613
- *
614
- * Uses the pipeline `date` parameter to ensure the assessment date in the
615
- * generated markdown matches the `analysis/{date}/` folder, overriding
616
- * the `new Date()` timestamp that `assessPoliticalThreats()` stamps internally.
617
- *
618
- * @param fetchedData - Raw fetched EP data
619
- * @param date - Analysis date (used to override assessment date for consistency)
620
- * @returns Markdown content string
621
- */
622
- function buildThreatLandscapeMarkdown(fetchedData, date) {
623
- const input = toThreatInput(fetchedData);
624
- const assessment = assessPoliticalThreats(input);
625
- return generateThreatAssessmentMarkdown({ ...assessment, date });
626
- }
627
- /**
628
- * Build markdown for actor threat profiling.
629
- *
630
- * @param fetchedData - Raw fetched EP data
631
- * @param date - Analysis date
632
- * @returns Markdown content string
633
- */
634
- function buildActorThreatProfilingMarkdown(fetchedData, date) {
635
- const input = toThreatInput(fetchedData);
636
- const profiles = buildActorThreatProfiles(input);
637
- const header = buildMarkdownHeader('actor-threat-profiling', date, profiles.length > 0 ? 'medium' : 'low');
638
- const profileRows = profiles.length > 0
639
- ? profiles
640
- .map((p) => `| ${p.actor} | ${p.actorType} | ${p.capability} | ${p.motivation} | ${p.opportunity} | ${p.overallThreatLevel} |`)
641
- .join('\n')
642
- : EMPTY_TABLE_ROW_6;
643
- return (header +
644
- `# Actor Threat Profiles
645
-
646
- ## Overview
647
- Individual threat profiles for ${profiles.length} political actors.
648
-
649
- ## Actor Threat Matrix
650
- | Actor | Type | Capability | Motivation | Opportunity | Threat Level |
651
- |-------|------|------------|------------|-------------|--------------|
652
- ${profileRows}
653
-
654
- ## Date: ${date}
655
- `);
656
- }
657
- /**
658
- * Build markdown for consequence tree analysis.
659
- *
660
- * @param fetchedData - Raw fetched EP data
661
- * @param date - Analysis date
662
- * @returns Markdown content string
663
- */
664
- function buildConsequenceTreesMarkdown(fetchedData, date) {
665
- const input = toThreatInput(fetchedData);
666
- const procedures = safeArr(fetchedData, 'procedures');
667
- const header = buildMarkdownHeader('consequence-trees', date, 'medium');
668
- const trees = [];
669
- for (const raw of procedures.slice(0, 5)) {
670
- const proc = raw && typeof raw === 'object' ? raw : null;
671
- const title = proc ? String(proc['title'] ?? '') : '';
672
- if (!title)
673
- continue;
674
- const tree = buildConsequenceTree(title, input);
675
- trees.push(`### ${title}\n` +
676
- `- **Immediate**: ${tree.immediateConsequences.map((c) => c.description).join('; ') || 'No immediate consequences identified'}\n` +
677
- `- **Secondary**: ${tree.secondaryEffects.map((c) => c.description).join('; ') || 'No secondary effects identified'}\n` +
678
- `- **Long-term**: ${tree.longTermImplications.map((c) => c.description).join('; ') || 'No long-term implications identified'}\n` +
679
- `- **Mitigating factors**: ${tree.mitigatingFactors.join(', ') || '—'}\n` +
680
- `- **Amplifying factors**: ${tree.amplifyingFactors.join(', ') || '—'}`);
681
- }
682
- return (header +
683
- `# Consequence Tree Analysis
684
-
685
- ## Overview
686
- Structured analysis of action-consequence chains for ${Math.min(procedures.length, 5)} legislative procedures.
687
-
688
- ${trees.length > 0 ? trees.join('\n\n') : '## No procedures available for consequence analysis'}
689
-
690
- ## Date: ${date}
691
- `);
692
- }
693
126
  /**
694
- * Build markdown for legislative disruption analysis.
127
+ * Check whether a legacy-named output exists for a method.
695
128
  *
696
- * @param fetchedData - Raw fetched EP data
697
- * @param date - Analysis date
698
- * @returns Markdown content string
129
+ * @param method - The analysis method to check
130
+ * @param dateOutputDir - Absolute path to the date-scoped output directory
131
+ * @param subdir - The method's subdirectory
132
+ * @returns The legacy filename that matched, or `undefined`
699
133
  */
700
- function buildLegislativeDisruptionMarkdown(fetchedData, date) {
701
- const input = toThreatInput(fetchedData);
702
- const procedures = safeArr(fetchedData, 'procedures');
703
- const header = buildMarkdownHeader('legislative-disruption', date, 'medium');
704
- const disruptions = [];
705
- for (const raw of procedures.slice(0, 5)) {
706
- const proc = raw && typeof raw === 'object' ? raw : null;
707
- const id = proc ? String(proc['procedureId'] ?? proc['id'] ?? '') : '';
708
- const title = proc ? String(proc['title'] ?? '') : '';
709
- if (!id || !title)
710
- continue;
711
- const analysis = analyzeLegislativeDisruption(id, input);
712
- const disruptionCount = analysis.disruptionPoints.length;
713
- disruptions.push(`| ${sanitizeCell(id)} | ${sanitizeCell(title.slice(0, 50))} | ${sanitizeCell(analysis.currentStage)} | ${sanitizeCell(analysis.resilience)} | ${disruptionCount} |`);
134
+ function findLegacyOutput(method, dateOutputDir, subdir) {
135
+ const legacyNames = LEGACY_FILENAMES[method];
136
+ if (!legacyNames)
137
+ return undefined;
138
+ for (const legacy of legacyNames) {
139
+ if (methodOutputExists(path.join(dateOutputDir, subdir, legacy))) {
140
+ return legacy;
141
+ }
714
142
  }
715
- return (header +
716
- `# Legislative Disruption Analysis
717
-
718
- ## Overview
719
- Identification of factors disrupting the normal legislative process.
720
-
721
- ## Disruption Assessment
722
- | Procedure ID | Title | Stage | Resilience | Disruption Points |
723
- |-------------|-------|-------|-----------|-------------------|
724
- ${disruptions.length > 0 ? disruptions.join('\n') : '| — | — | — | — | — |'}
725
-
726
- ## Date: ${date}
727
- `);
143
+ return undefined;
728
144
  }
729
145
  /**
730
- * Build markdown for the risk scoring matrix.
146
+ * Attempt to migrate a legacy-named output file to its canonical path.
731
147
  *
732
- * @param fetchedData - Raw fetched EP data
733
- * @param date - Analysis date
734
- * @returns Markdown content string
148
+ * @param legacyAbsolutePath - Absolute path to the existing legacy file
149
+ * @param canonicalAbsolutePath - Absolute path to the target canonical file
150
+ * @returns `true` if migration succeeded, `false` otherwise
735
151
  */
736
- function buildRiskMatrixMarkdown(fetchedData, date) {
737
- const procedures = safeArr(fetchedData, 'procedures');
738
- const risks = [];
739
- // Generate risk scores for identifiable political risks from data
740
- if (procedures.length > 0) {
741
- risks.push(calculatePoliticalRiskScore('possible', 'moderate', 'RISK-001', 'Legislative blockage risk from procedure backlog', [`${procedures.length} procedures in pipeline`], ['Established committee procedures'], 'medium'));
742
- }
743
- const coalitions = safeArr(fetchedData, 'coalitions');
744
- if (coalitions.length > 0) {
745
- risks.push(calculatePoliticalRiskScore('unlikely', 'major', 'RISK-002', 'Coalition instability risk', [`${coalitions.length} coalition data points`], ['Established political group structures'], 'medium'));
152
+ function migrateLegacyFile(legacyAbsolutePath, canonicalAbsolutePath) {
153
+ try {
154
+ fs.renameSync(legacyAbsolutePath, canonicalAbsolutePath);
155
+ return true;
746
156
  }
747
- const anomalies = safeArr(fetchedData, 'anomalies');
748
- if (anomalies.length > 0) {
749
- risks.push(calculatePoliticalRiskScore('possible', 'moderate', 'RISK-003', 'Voting pattern anomaly risk', [`${anomalies.length} anomalies detected`], [], 'medium'));
157
+ catch {
158
+ try {
159
+ fs.copyFileSync(legacyAbsolutePath, canonicalAbsolutePath);
160
+ fs.unlinkSync(legacyAbsolutePath);
161
+ return true;
162
+ }
163
+ catch {
164
+ return false;
165
+ }
750
166
  }
751
- const header = buildMarkdownHeader('risk-matrix', date, risks.length > 0 ? 'medium' : 'low');
752
- const riskRows = risks.length > 0
753
- ? risks
754
- .map((r) => `| ${r.riskId} | ${r.description} | ${r.likelihood} | ${r.impact} | ${r.riskScore} | ${r.riskLevel} |`)
755
- .join('\n')
756
- : EMPTY_TABLE_ROW_6;
757
- return (header +
758
- `# Political Risk Scoring Matrix
759
-
760
- ## Overview
761
-
762
- Quantitative risk scoring across ${risks.length} identified political dimensions.
763
- This matrix uses a standardized likelihood × impact framework to quantify and
764
- prioritize political risks affecting the European Parliament legislative process.
765
-
766
- ## Risk Heat Map
767
-
768
- \`\`\`mermaid
769
- quadrantChart
770
- title Political Risk Heat Map — ${date}
771
- x-axis Low Likelihood --> High Likelihood
772
- y-axis Low Impact --> High Impact
773
- quadrant-1 Critical Risk Zone
774
- quadrant-2 High Impact / Low Likelihood
775
- quadrant-3 Acceptable Risk Zone
776
- quadrant-4 High Likelihood / Low Impact
777
- ${risks
778
- .map((r) => {
779
- const likelihoodMap = {
780
- rare: 0.15,
781
- unlikely: 0.3,
782
- possible: 0.5,
783
- likely: 0.7,
784
- 'almost certain': 0.9,
785
- };
786
- const impactMap = {
787
- minor: 0.2,
788
- moderate: 0.45,
789
- major: 0.7,
790
- critical: 0.9,
791
- };
792
- const lx = likelihoodMap[r.likelihood] ?? 0.5;
793
- const ly = impactMap[r.impact] ?? 0.45;
794
- return ` ${sanitizeCell(r.riskId)}: [${lx.toFixed(2)}, ${ly.toFixed(2)}]`;
795
- })
796
- .join('\n')}
797
- \`\`\`
798
-
799
- ## Risk Matrix
800
-
801
- | Risk ID | Description | Likelihood | Impact | Score | Level |
802
- |---------|-------------|------------|--------|-------|-------|
803
- ${riskRows}
804
-
805
- > **Risk Score** = Likelihood × Impact. **Levels**: 🟢 LOW (≤1.0), 🟡 MEDIUM (≤2.0), 🟠 HIGH (≤3.5), 🔴 CRITICAL (>3.5)
806
-
807
- ## Risk Assessment Details
808
-
809
- ${risks.length > 0
810
- ? risks
811
- .map((r) => `### ${r.riskId}: ${r.description}
812
-
813
- | Metric | Value |
814
- |--------|-------|
815
- | Risk Score | ${r.riskScore.toFixed(2)} |
816
- | Risk Level | ${r.riskLevel.toUpperCase()} |
817
- | Likelihood | ${r.likelihood} |
818
- | Impact | ${r.impact} |
819
- `)
820
- .join('\n')
821
- : '| — | — | — | — | — | — |'}
822
-
823
- ## Risk Mitigation Framework
824
-
825
- | Risk Level | Count | Tolerance | Action Required |
826
- |------------|-------|-----------|-----------------|
827
- | 🔴 CRITICAL | ${risks.filter((r) => r.riskLevel === 'critical').length} | Zero tolerance | Immediate escalation |
828
- | 🟠 HIGH | ${risks.filter((r) => r.riskLevel === 'high').length} | Low tolerance | Active mitigation |
829
- | 🟡 MEDIUM | ${risks.filter((r) => r.riskLevel === 'medium').length} | Moderate | Enhanced monitoring |
830
- | 🟢 LOW | ${risks.filter((r) => r.riskLevel === 'low').length} | Acceptable | Routine tracking |
831
-
832
- ## Date: ${date}
833
- `);
834
- }
835
- /**
836
- * Build markdown for political capital at risk analysis.
837
- * Outputs data-derived group metrics for AI agent to perform actual analysis.
838
- *
839
- * @param fetchedData - Raw fetched EP data
840
- * @param date - Analysis date
841
- * @returns Markdown content string
842
- */
843
- function buildPoliticalCapitalRiskMarkdown(fetchedData, date) {
844
- const header = buildMarkdownHeader('political-capital-risk', date, 'medium');
845
- const coalitions = safeArr(fetchedData, 'coalitions');
846
- const votingRecords = safeArr(fetchedData, 'votingRecords');
847
- const patterns = safeArr(fetchedData, 'patterns');
848
- const procedures = safeArr(fetchedData, 'procedures');
849
- return (header +
850
- `# Political Capital at Risk
851
-
852
- ## Data Inventory for Capital Risk Assessment
853
- | Data Source | Count | Relevance |
854
- |-------------|-------|-----------|
855
- | Coalition data points | ${coalitions.length} | Group cohesion indicators |
856
- | Voting records | ${votingRecords.length} | Voting alignment metrics |
857
- | Voting patterns | ${patterns.length} | Trend and anomaly data |
858
- | Active procedures | ${procedures.length} | Legislative engagement |
859
-
860
- ## Date: ${date}
861
- `);
862
167
  }
863
168
  /**
864
- * Build the data-driven SWOT items for the political SWOT analysis.
865
- *
866
- * Descriptions are derived purely from data metrics — no pre-written
867
- * political conclusions. AI enrichment markers indicate where the agentic
868
- * workflow should inject real political intelligence analysis.
869
- *
870
- * Extracted from `buildQuantitativeSwotMarkdown` to reduce cognitive complexity.
169
+ * Check whether a method's output already exists (canonical or legacy) and
170
+ * return a skip status if so.
871
171
  *
872
- * @param counts - Count of items per data category
873
- * @param counts.procedures - Number of active legislative procedures
874
- * @param counts.adoptedTexts - Number of adopted texts
875
- * @param counts.documents - Number of published documents
876
- * @param counts.votingRecords - Number of roll-call voting records
877
- * @param counts.questions - Number of parliamentary questions
878
- * @param counts.mepUpdates - Number of MEP activity updates
879
- * @param counts.events - Number of scheduled events
880
- * @param counts.coalitions - Number of coalition data points
881
- * @returns Object with strengths, weaknesses, opportunities, and threats arrays
882
- */
883
- function buildPoliticalSwotItems(counts) {
884
- const strengths = [
885
- createScoredSWOTItem(`${counts.procedures} procedures in active legislative pipeline`, Math.min(counts.procedures / 5, 5), [
886
- `${counts.procedures} procedures tracked in current period`,
887
- `${counts.adoptedTexts} texts adopted`,
888
- `${counts.documents} documents published`,
889
- ], counts.procedures > 0 ? 'medium' : 'low', counts.procedures > 5 ? 'improving' : 'stable'),
890
- createScoredSWOTItem(`${counts.votingRecords} roll-call votes recorded with ${counts.questions} questions`, Math.min(counts.votingRecords / 3, 5), [
891
- `${counts.votingRecords} voting records available`,
892
- `${counts.questions} parliamentary questions filed`,
893
- `${counts.mepUpdates} MEP activity updates`,
894
- ], counts.votingRecords > 0 ? 'medium' : 'low', 'stable'),
895
- ];
896
- const weaknesses = [
897
- createScoredSWOTItem(`${counts.mepUpdates} MEP updates — data coverage gap assessment`, Math.max(2, 5 - counts.mepUpdates / 10), [
898
- `${counts.mepUpdates} MEP updates in current period`,
899
- `${counts.documents} documents vs ${counts.procedures} procedures ratio`,
900
- `Data freshness depends on EP feed update frequency`,
901
- ], 'medium', 'stable'),
902
- ];
903
- const opportunities = [
904
- createScoredOpportunityOrThreat(`${counts.events} parliamentary events scheduled`, counts.events > 3 ? 'likely' : 'possible', counts.events > 5 ? 'major' : 'moderate', [
905
- `${counts.events} events in analysis period`,
906
- `${counts.adoptedTexts} texts adopted indicates legislative throughput`,
907
- `${counts.procedures} procedures in various stages`,
908
- ], 'medium', counts.events > 3 ? 'improving' : 'stable'),
909
- ];
910
- const threats = [
911
- createScoredOpportunityOrThreat(`${counts.coalitions} coalition data points — cohesion monitoring`, counts.coalitions > 0 ? 'possible' : 'unlikely', 'moderate', [
912
- `${counts.coalitions} coalition observations recorded`,
913
- `Cross-reference with ${counts.votingRecords} voting records`,
914
- `${counts.procedures} procedures may be affected by coalition shifts`,
915
- ], counts.coalitions > 0 ? 'medium' : 'low', 'stable'),
916
- ];
917
- return { strengths, weaknesses, opportunities, threats };
918
- }
919
- /**
920
- * Build markdown for the quantitative SWOT analysis.
921
- *
922
- * Produces a full narrative SWOT analysis modelled after the repository's
923
- * SWOT.md — each quadrant item has a description, strategic value, evidence
924
- * bullets, and a scored impact rating derived from actual fetched EP data.
925
- *
926
- * @param fetchedData - Raw fetched EP data
927
- * @param date - Analysis date
928
- * @returns Markdown content string
929
- */
930
- function buildQuantitativeSwotMarkdown(fetchedData, date) {
931
- const header = buildMarkdownHeader('quantitative-swot', date, 'medium');
932
- const events = safeArr(fetchedData, 'events');
933
- const procedures = safeArr(fetchedData, 'procedures');
934
- const adoptedTexts = safeArr(fetchedData, 'adoptedTexts');
935
- const documents = safeArr(fetchedData, 'documents');
936
- const votingRecords = safeArr(fetchedData, 'votingRecords');
937
- const coalitions = safeArr(fetchedData, 'coalitions');
938
- const questions = safeArr(fetchedData, 'questions');
939
- const mepUpdates = safeArr(fetchedData, 'mepUpdates');
940
- const counts = {
941
- procedures: procedures.length,
942
- adoptedTexts: adoptedTexts.length,
943
- documents: documents.length,
944
- votingRecords: votingRecords.length,
945
- questions: questions.length,
946
- mepUpdates: mepUpdates.length,
947
- events: events.length,
948
- coalitions: coalitions.length,
949
- };
950
- // Build data-driven SWOT items with narrative descriptions
951
- const { strengths, weaknesses, opportunities, threats } = buildPoliticalSwotItems(counts);
952
- const swot = buildQuantitativeSWOT(`Political SWOT Assessment ${date}`, strengths, weaknesses, opportunities, threats);
953
- // Build narrative sections for each quadrant
954
- const strengthsNarrative = swot.strengths
955
- .map((s, i) => `### S${i + 1}: ${s.description}\n` +
956
- `- **Score**: ${s.score.toFixed(1)}/5\n` +
957
- `- **Confidence**: ${s.confidence}\n` +
958
- `- **Trend**: ${s.trend}\n` +
959
- `- **Evidence**:\n${s.evidence.map((e) => ` - ${e}`).join('\n')}`)
960
- .join('\n\n');
961
- const weaknessesNarrative = swot.weaknesses
962
- .map((w, i) => `### W${i + 1}: ${w.description}\n` +
963
- `- **Score**: ${w.score.toFixed(1)}/5\n` +
964
- `- **Confidence**: ${w.confidence}\n` +
965
- `- **Trend**: ${w.trend}\n` +
966
- `- **Evidence**:\n${w.evidence.map((e) => ` - ${e}`).join('\n')}`)
967
- .join('\n\n');
968
- const opportunitiesNarrative = swot.opportunities
969
- .map((o, i) => `### O${i + 1}: ${o.description}\n` +
970
- `- **Score**: ${o.score.toFixed(1)}/5\n` +
971
- `- **Confidence**: ${o.confidence}\n` +
972
- `- **Trend**: ${o.trend}\n` +
973
- `- **Evidence**:\n${o.evidence.map((e) => ` - ${e}`).join('\n')}`)
974
- .join('\n\n');
975
- const threatsNarrative = swot.threats
976
- .map((t, i) => `### T${i + 1}: ${t.description}\n` +
977
- `- **Score**: ${t.score.toFixed(1)}/5\n` +
978
- `- **Confidence**: ${t.confidence}\n` +
979
- `- **Trend**: ${t.trend}\n` +
980
- `- **Evidence**:\n${t.evidence.map((e) => ` - ${e}`).join('\n')}`)
981
- .join('\n\n');
982
- return (header +
983
- `# Full Political SWOT Analysis
984
-
985
- ## Executive Summary
986
-
987
- **Strategic Position Score**: ${swot.strategicPositionScore.toFixed(1)}/10
988
- **Overall Assessment**: ${swot.overallAssessment}
989
- **Analysis Date**: ${date}
990
-
991
- > This SWOT analysis is derived from ${procedures.length} procedures, ${events.length} events, ${adoptedTexts.length} adopted texts, ${documents.length} documents, ${votingRecords.length} voting records, and ${coalitions.length} coalition data points fetched from the European Parliament.
992
-
993
- ## SWOT Quadrant Chart
994
-
995
- \`\`\`mermaid
996
- quadrantChart
997
- title Political SWOT — Strategic Position (${date})
998
- x-axis Low Impact --> High Impact
999
- y-axis Low Priority --> High Priority
1000
- quadrant-1 Opportunities
1001
- quadrant-2 Strengths
1002
- quadrant-3 Weaknesses
1003
- quadrant-4 Threats
1004
- ${swot.strengths.map((s, i) => ` S${i + 1} ${sanitizeCell(s.description).slice(0, 25)}: [${Math.max(0.55, Math.min(0.95, 0.5 + s.score / 10)).toFixed(2)}, ${Math.max(0.55, Math.min(0.95, 0.5 + s.score / 10)).toFixed(2)}]`).join('\n')}
1005
- ${swot.weaknesses.map((w, i) => ` W${i + 1} ${sanitizeCell(w.description).slice(0, 25)}: [${Math.max(0.05, Math.min(0.45, 0.5 - w.score / 10)).toFixed(2)}, ${Math.max(0.05, Math.min(0.45, 0.5 - w.score / 10)).toFixed(2)}]`).join('\n')}
1006
- ${swot.opportunities.map((o, i) => ` O${i + 1} ${sanitizeCell(o.description).slice(0, 25)}: [${Math.max(0.55, Math.min(0.95, 0.5 + o.score / 10)).toFixed(2)}, ${Math.max(0.55, Math.min(0.95, 0.5 + o.score / 10)).toFixed(2)}]`).join('\n')}
1007
- ${swot.threats.map((t, i) => ` T${i + 1} ${sanitizeCell(t.description).slice(0, 25)}: [${Math.max(0.55, Math.min(0.95, 0.5 + t.score / 10)).toFixed(2)}, ${Math.max(0.05, Math.min(0.45, 0.5 - t.score / 10)).toFixed(2)}]`).join('\n')}
1008
- \`\`\`
1009
-
1010
- ## SWOT Overview
1011
-
1012
- | Category | Items | Avg Score | Trend |
1013
- |----------|-------|-----------|-------|
1014
- | 🟢 Strengths | ${swot.strengths.length} | ${swot.strengths.length > 0 ? (swot.strengths.reduce((s, i) => s + i.score, 0) / swot.strengths.length).toFixed(1) : '—'} | ${swot.strengths[0]?.trend ?? '—'} |
1015
- | 🔴 Weaknesses | ${swot.weaknesses.length} | ${swot.weaknesses.length > 0 ? (swot.weaknesses.reduce((s, i) => s + i.score, 0) / swot.weaknesses.length).toFixed(1) : '—'} | ${swot.weaknesses[0]?.trend ?? '—'} |
1016
- | 🔵 Opportunities | ${swot.opportunities.length} | ${swot.opportunities.length > 0 ? (swot.opportunities.reduce((s, i) => s + i.score, 0) / swot.opportunities.length).toFixed(1) : '—'} | ${swot.opportunities[0]?.trend ?? '—'} |
1017
- | 🟠 Threats | ${swot.threats.length} | ${swot.threats.length > 0 ? (swot.threats.reduce((s, i) => s + i.score, 0) / swot.threats.length).toFixed(1) : '—'} | ${swot.threats[0]?.trend ?? '—'} |
1018
-
1019
- ## 🟢 Strengths
1020
-
1021
- ${strengthsNarrative || '_No strengths identified from available data._'}
1022
-
1023
- ## 🔴 Weaknesses
1024
-
1025
- ${weaknessesNarrative || '_No weaknesses identified from available data._'}
1026
-
1027
- ## 🔵 Opportunities
1028
-
1029
- ${opportunitiesNarrative || '_No opportunities identified from available data._'}
1030
-
1031
- ## 🟠 Threats
1032
-
1033
- ${threatsNarrative || '_No threats identified from available data._'}
1034
-
1035
- ## Cross-Impact Matrix
1036
-
1037
- ${swot.crossImpactMatrix.length > 0
1038
- ? '| Interaction | Net Effect | Rationale |\n|-------------|-----------|----------|\n' +
1039
- swot.crossImpactMatrix
1040
- .slice(0, 10)
1041
- .map((e) => `| ${e.swotType} #${e.swotIndex + 1} × threat #${e.threatIndex + 1} | ${e.netEffect.toFixed(2)} | ${sanitizeCell(e.rationale)} |`)
1042
- .join('\n')
1043
- : '- No cross-impacts identified from available data'}
1044
-
1045
- ## Strategic Priorities Matrix
1046
-
1047
- ## Data Summary
1048
-
1049
- | Data Source | Count |
1050
- |-------------|-------|
1051
- | Procedures | ${procedures.length} |
1052
- | Events | ${events.length} |
1053
- | Documents | ${documents.length} |
1054
- | Voting Records | ${votingRecords.length} |
1055
- | Adopted Texts | ${adoptedTexts.length} |
1056
- | Coalitions | ${coalitions.length} |
1057
- | Questions | ${questions.length} |
1058
- | MEP Updates | ${mepUpdates.length} |
1059
- | **Total Data Points** | **${procedures.length + events.length + documents.length + votingRecords.length + adoptedTexts.length}** |
1060
-
1061
- ## Date: ${date}
1062
- `);
1063
- }
1064
- /**
1065
- * Build markdown for legislative velocity risk analysis.
1066
- *
1067
- * @param fetchedData - Raw fetched EP data
1068
- * @param date - Analysis date
1069
- * @returns Markdown content string
1070
- */
1071
- function buildLegislativeVelocityRiskMarkdown(fetchedData, date) {
1072
- const procedures = safeArr(fetchedData, 'procedures');
1073
- const velocityRisks = assessLegislativeVelocityRisk(procedures);
1074
- const header = buildMarkdownHeader('legislative-velocity-risk', date, velocityRisks.length > 0 ? 'medium' : 'low');
1075
- const riskRows = velocityRisks.length > 0
1076
- ? velocityRisks
1077
- .slice(0, 10)
1078
- .map((r) => `| ${sanitizeCell(r.procedureId)} | ${sanitizeCell(r.title.slice(0, 40))} | ${sanitizeCell(r.currentStage)} | ${r.daysInCurrentStage}d / ${r.expectedDaysForStage}d | ${r.velocityRisk.riskScore.toFixed(2)} | ${sanitizeCell(r.velocityRisk.riskLevel)} |`)
1079
- .join('\n')
1080
- : EMPTY_TABLE_ROW_6;
1081
- return (header +
1082
- `# Legislative Velocity Risk
1083
-
1084
- ## Overview
1085
- Risk assessment based on legislative processing speed for ${procedures.length} procedures.
1086
-
1087
- ## Top Velocity Risks
1088
- | Procedure | Title | Stage | Days (actual/expected) | Risk Score | Level |
1089
- |-----------|-------|-------|----------------------|------------|-------|
1090
- ${riskRows}
1091
-
1092
- ## Summary
1093
- - **Procedures analysed**: ${procedures.length}
1094
- - **High/Critical risks**: ${velocityRisks.filter((r) => r.velocityRisk.riskLevel === 'high' || r.velocityRisk.riskLevel === 'critical').length}
1095
- - **Date**: ${date}
1096
- `);
1097
- }
1098
- /**
1099
- * Build markdown for the agent risk assessment workflow.
1100
- *
1101
- * @param fetchedData - Raw fetched EP data
1102
- * @param date - Analysis date
1103
- * @returns Markdown content string
172
+ * @param method - The analysis method to check
173
+ * @param dateOutputDir - Absolute path to the date-scoped output directory
174
+ * @param subdir - The method's subdirectory
175
+ * @param filename - The canonical output filename
176
+ * @param absolutePath - Absolute path to the canonical output file
177
+ * @param relativeOutputFile - Portable relative output path for manifests
178
+ * @param confidence - Default confidence level for the method
179
+ * @param verbose - Whether to print verbose progress
180
+ * @returns A skip status record, or `undefined` to proceed with execution.
1104
181
  */
1105
- function buildAgentRiskWorkflowMarkdown(fetchedData, date) {
1106
- const procedures = safeArr(fetchedData, 'procedures');
1107
- const coalitions = safeArr(fetchedData, 'coalitions');
1108
- // Build identified risks
1109
- const identifiedRisks = [];
1110
- if (procedures.length > 0) {
1111
- identifiedRisks.push(calculatePoliticalRiskScore('possible', 'moderate', 'RISK-W01', 'Legislative backlog risk', [`${procedures.length} active procedures`], ['Committee oversight'], 'medium'));
1112
- }
1113
- if (coalitions.length > 0) {
1114
- identifiedRisks.push(calculatePoliticalRiskScore('unlikely', 'moderate', 'RISK-W02', 'Coalition cohesion risk', [`${coalitions.length} coalitions monitored`], ['Group discipline mechanisms'], 'medium'));
1115
- }
1116
- if (identifiedRisks.length === 0) {
1117
- identifiedRisks.push(calculatePoliticalRiskScore('rare', 'minor', 'RISK-W00', 'Baseline political risk', ['Routine parliamentary activity'], ['Stable institutional framework'], 'low'));
182
+ function checkSkipCompleted(method, dateOutputDir, subdir, filename, absolutePath, relativeOutputFile, confidence, verbose) {
183
+ if (methodOutputExists(absolutePath)) {
184
+ if (verbose)
185
+ console.log(` ⏭️ [analysis] Skipping already-completed method: ${method}`);
186
+ return {
187
+ method,
188
+ status: 'skipped',
189
+ outputFile: relativeOutputFile,
190
+ confidence,
191
+ duration: 0,
192
+ summary: `Skipped — output already exists at ${relativeOutputFile}`,
193
+ };
1118
194
  }
1119
- const riskDrivers = [
1120
- createRiskDriver('Legislative pipeline complexity', 'legislative_delay', Math.min(procedures.length * 2, 30), 'stable'),
1121
- createRiskDriver('Coalition dynamics', 'coalition_fracture', 15, 'stable'),
1122
- ];
1123
- const workflow = runAgentRiskAssessment(`ASSESS-${date}`, date, ArticleCategory.WEEK_AHEAD, identifiedRisks, riskDrivers, ['Monitor legislative velocity indicators', 'Track coalition voting patterns']);
1124
- return generateRiskAssessmentMarkdown(workflow);
1125
- }
1126
- /**
1127
- * Build markdown for the deep multi-perspective analysis.
1128
- * Outputs raw data metrics per stakeholder group for AI agent enrichment.
1129
- *
1130
- * @param fetchedData - Raw fetched EP data
1131
- * @param date - Analysis date
1132
- * @returns Markdown content string
1133
- */
1134
- function buildDeepAnalysisMarkdown(fetchedData, date) {
1135
- const header = buildMarkdownHeader('deep-analysis', date, 'high');
1136
- const events = safeArr(fetchedData, 'events');
1137
- const procedures = safeArr(fetchedData, 'procedures');
1138
- const documents = safeArr(fetchedData, 'documents');
1139
- const adoptedTexts = safeArr(fetchedData, 'adoptedTexts');
1140
- const questions = safeArr(fetchedData, 'questions');
1141
- const mepUpdates = safeArr(fetchedData, 'mepUpdates');
1142
- const total = events.length +
1143
- procedures.length +
1144
- documents.length +
1145
- adoptedTexts.length +
1146
- questions.length +
1147
- mepUpdates.length;
1148
- return (header +
1149
- `# Deep Multi-Perspective Analysis
1150
-
1151
- ## Raw Data Inventory (Script-Generated Context for AI)
1152
- | Data Source | Count |
1153
- |-------------|-------|
1154
- | Events | ${events.length} |
1155
- | Procedures | ${procedures.length} |
1156
- | Documents | ${documents.length} |
1157
- | Adopted Texts | ${adoptedTexts.length} |
1158
- | Questions | ${questions.length} |
1159
- | MEP Updates | ${mepUpdates.length} |
1160
- | **Total** | **${total}** |
1161
-
1162
- ## Stakeholder Groups — Data Points Available
1163
- | Stakeholder Group | Data Points Available |
1164
- |-------------------|---------------------|
1165
- | Political Groups | ${procedures.length + adoptedTexts.length} (procedures + adopted texts) |
1166
- | Civil Society | ${documents.length + questions.length} (documents + questions) |
1167
- | Industry | ${procedures.length} (procedures) |
1168
- | National Governments | ${adoptedTexts.length} (adopted texts) |
1169
- | Citizens | ${questions.length + mepUpdates.length} (questions + MEP updates) |
1170
- | EU Institutions | ${events.length + procedures.length} (events + procedures) |
1171
-
1172
- ## AI Analysis Prompt
1173
-
1174
- > **Instructions for AI Agent (Opus 4.6):** Using the data inventory above and the raw EP MCP data files, produce a deep multi-perspective analysis following the political-style-guide.md depth Level 3 format. Your analysis MUST:
1175
- >
1176
- > 1. **Identify the 3-5 most politically significant items** from the available data, citing specific document IDs
1177
- > 2. **Analyse each from ≥3 stakeholder perspectives** (Political Groups, Civil Society, Industry, National Governments, Citizens, EU Institutions)
1178
- > 3. **Apply the SWOT framework** to the overall parliamentary activity pattern for this date
1179
- > 4. **Assess coalition dynamics** — which groups are aligning/diverging based on the adopted texts?
1180
- > 5. **Rate confidence** for each analytical claim: 🟢 High / 🟡 Medium / 🔴 Low
1181
- > 6. **Provide forward-looking indicators** — what should be monitored in the next 7-14 days?
1182
- > 7. **Include a Mermaid diagram** showing key actor relationships or policy connection mapping
1183
- >
1184
- > Evidence requirement: ≥3 citations per section from EP MCP data (document IDs, vote references, procedure numbers).
1185
-
1186
- ## AI-Produced Analysis
1187
-
1188
- [TO BE FILLED BY AI AGENT — This section must contain substantive political intelligence analysis, not data summaries. Quality gate: minimum 500 words of original analytical prose with evidence citations.]
1189
-
1190
- ## Date: ${date}
1191
- `);
1192
- }
1193
- /**
1194
- * Build markdown for the stakeholder impact analysis.
1195
- * Outputs data inventory per stakeholder group with AI analysis prompts.
1196
- *
1197
- * @param fetchedData - Raw fetched EP data
1198
- * @param date - Analysis date
1199
- * @returns Markdown content string
1200
- */
1201
- function buildStakeholderAnalysisMarkdown(fetchedData, date) {
1202
- const header = buildMarkdownHeader('stakeholder-analysis', date, 'high');
1203
- const procedures = safeArr(fetchedData, 'procedures');
1204
- const adoptedTexts = safeArr(fetchedData, 'adoptedTexts');
1205
- const documents = safeArr(fetchedData, 'documents');
1206
- const events = safeArr(fetchedData, 'events');
1207
- const questions = safeArr(fetchedData, 'questions');
1208
- const mepUpdates = safeArr(fetchedData, 'mepUpdates');
1209
- const votingRecords = safeArr(fetchedData, 'votingRecords');
1210
- const coalitions = safeArr(fetchedData, 'coalitions');
1211
- return (header +
1212
- `# Stakeholder Impact Analysis
1213
-
1214
- ## Data Available for Stakeholder Assessment (Script-Generated Context)
1215
- | Stakeholder Group | Primary Data Sources | Data Points |
1216
- |-------------------|---------------------|-------------|
1217
- | Political Groups | Procedures, Adopted Texts, Voting Records, Coalitions | ${procedures.length + adoptedTexts.length + votingRecords.length + coalitions.length} |
1218
- | Civil Society | Documents, Questions, Events | ${documents.length + questions.length + events.length} |
1219
- | Industry | Procedures, Adopted Texts | ${procedures.length + adoptedTexts.length} |
1220
- | National Governments | Adopted Texts, Procedures, Coalitions | ${adoptedTexts.length + procedures.length + coalitions.length} |
1221
- | Citizens | Questions, MEP Updates, Events | ${questions.length + mepUpdates.length + events.length} |
1222
- | EU Institutions | Events, Procedures, Adopted Texts, Voting Records | ${events.length + procedures.length + adoptedTexts.length + votingRecords.length} |
1223
-
1224
- ## Data Source Summary
1225
- | Source | Count |
1226
- |--------|-------|
1227
- ${Object.keys(fetchedData)
1228
- .filter((k) => Array.isArray(fetchedData[k]))
1229
- .map((k) => `| ${k} | ${fetchedData[k].length} |`)
1230
- .join('\n')}
1231
-
1232
- ## AI Analysis Prompt
1233
-
1234
- > **Instructions for AI Agent (Opus 4.6):** Using the stakeholder-impact.md template and the data inventory above, produce a stakeholder impact analysis for each of the 6 stakeholder groups. For each group:
1235
- >
1236
- > 1. **Impact direction**: positive / negative / neutral / mixed
1237
- > 2. **Impact severity**: high / medium / low
1238
- > 3. **Specific evidence**: Cite ≥2 specific EP documents, votes, or procedures that affect this stakeholder
1239
- > 4. **Reasoning**: 2-3 sentences explaining WHY this stakeholder is affected and HOW
1240
- > 5. **Action items**: What should this stakeholder watch or do in response?
1241
- > 6. **Confidence level**: 🟢 High / 🟡 Medium / 🔴 Low
1242
- >
1243
- > Focus on the MOST RECENT adopted texts and procedures. Do not produce generic stakeholder descriptions — every assessment must be grounded in specific EP data from this date period.
1244
-
1245
- ## AI-Produced Stakeholder Assessment
1246
-
1247
- [TO BE FILLED BY AI AGENT — Each stakeholder group must have impact direction, severity, evidence citations, and reasoning. Quality gate: minimum 300 words of original analytical prose.]
1248
-
1249
- ## Date: ${date}
1250
- `);
1251
- }
1252
- /**
1253
- * Build markdown for coalition cohesion analysis.
1254
- * Uses `computeCrossSessionCoalitionStability` to aggregate VotingPattern cohesion
1255
- * and provides AI analysis prompts for deeper intelligence.
1256
- *
1257
- * @param fetchedData - Raw fetched EP data
1258
- * @param date - Analysis date
1259
- * @returns Markdown content string
1260
- */
1261
- function buildCoalitionAnalysisMarkdown(fetchedData, date) {
1262
- const header = buildMarkdownHeader('coalition-analysis', date, 'high');
1263
- const rawPatterns = Array.isArray(fetchedData['patterns']) ? fetchedData['patterns'] : [];
1264
- // VotingPattern[] data doesn't contain the `coalitionId`/`id` fields required
1265
- // by analyzeCoalitionCohesion(). Use computeCrossSessionCoalitionStability()
1266
- // instead — it is designed to aggregate cohesion across VotingPattern arrays.
1267
- const stabilityReport = computeCrossSessionCoalitionStability(rawPatterns);
1268
- return (header +
1269
- `# Coalition Cohesion Analysis
1270
-
1271
- ## Computed Metrics (Script-Generated Context)
1272
- - **Overall Stability**: ${(stabilityReport.overallStability * 100).toFixed(1)}%
1273
- - **Forecast**: ${stabilityReport.forecast}
1274
- - **Patterns Analysed**: ${stabilityReport.patternCount}
1275
- - **Stable Groups**: ${stabilityReport.stableGroups.length > 0 ? stabilityReport.stableGroups.join(', ') : 'No stable groups identified from voting data'}
1276
- - **Declining Groups**: ${stabilityReport.decliningGroups.length > 0 ? stabilityReport.decliningGroups.join(', ') : 'No declining groups identified from voting data'}
1277
- - **Raw Patterns Evaluated**: ${rawPatterns.length}
1278
-
1279
- ## AI Analysis Prompt
1280
-
1281
- > **Instructions for AI Agent (Opus 4.6):** Using the political-risk-methodology.md coalition risk framework and the computed metrics above, produce a coalition intelligence analysis. Your analysis MUST:
1282
- >
1283
- > 1. **Assess the Grand Coalition** (EPP + S&D + Renew): Is it holding? What are the stress points?
1284
- > 2. **Identify emerging alliances**: Are ECR, PfE, or Greens/EFA forming tactical voting blocs?
1285
- > 3. **Analyse abstention patterns**: High abstention rates signal internal group conflicts — identify which groups and why
1286
- > 4. **Cross-party voting**: Identify any cases where MEPs voted against their group line on recent adopted texts
1287
- > 5. **Predict coalition evolution**: Based on current patterns, which coalitions will strengthen/weaken in the next month?
1288
- > 6. **Include a Mermaid diagram** showing group-to-group voting alignment strength
1289
- > 7. **Confidence levels**: Rate each coalition assessment as 🟢 High / 🟡 Medium / 🔴 Low
1290
- >
1291
- > If voting data is limited (patterns analysed = 0), use adopted texts and political landscape data to infer coalition dynamics from the policy positions embedded in recent legislation.
1292
-
1293
- ## AI-Produced Coalition Intelligence
1294
-
1295
- [TO BE FILLED BY AI AGENT — Substantive coalition dynamics analysis with evidence citations, confidence levels, and forward-looking predictions. Quality gate: minimum 400 words.]
1296
-
1297
- ## Date: ${date}
1298
- `);
1299
- }
1300
- /**
1301
- * Build markdown for voting pattern analysis.
1302
- * Uses `detectVotingTrends` and provides AI analysis prompts.
1303
- *
1304
- * @param fetchedData - Raw fetched EP data
1305
- * @param date - Analysis date
1306
- * @returns Markdown content string
1307
- */
1308
- function buildVotingPatternsMarkdown(fetchedData, date) {
1309
- const header = buildMarkdownHeader('voting-patterns', date, 'high');
1310
- // detectVotingTrends accepts readonly VotingRecord[] — pass raw records directly
1311
- const rawRecords = Array.isArray(fetchedData['votingRecords'])
1312
- ? fetchedData['votingRecords']
1313
- : [];
1314
- const trends = detectVotingTrends(rawRecords);
1315
- const trendsText = trends
1316
- .map((t) => `| ${t.trendId} | ${t.direction} | ${(t.confidence * 100).toFixed(0)}% | ${t.recordCount} records |`)
1317
- .join('\n');
1318
- return (header +
1319
- `# Voting Pattern Analysis
1320
-
1321
- ## Detected Trends (Script-Generated Context)
1322
- | Trend ID | Direction | Confidence | Data Points |
1323
- |----------|-----------|------------|-------------|
1324
- ${trendsText || '| No trend data available from voting records | — | — | — |'}
1325
-
1326
- ## Computed Summary
1327
- - **Trends identified**: ${trends.length}
1328
- - **Records analysed**: ${rawRecords.length}
1329
-
1330
- ## AI Analysis Prompt
1331
-
1332
- > **Instructions for AI Agent (Opus 4.6):** Using the voting pattern data above and the adopted texts from EP MCP feeds, produce a voting pattern intelligence analysis. Your analysis MUST:
1333
- >
1334
- > 1. **Identify voting blocs**: Which groups consistently vote together on recent adopted texts?
1335
- > 2. **Detect anomalies**: Any unexpected votes, close margins (<50 vote difference), or high abstention rates?
1336
- > 3. **Analyse by policy domain**: Do voting patterns differ between economic, environmental, and social legislation?
1337
- > 4. **Group discipline assessment**: Rate each major group's internal cohesion (high/medium/low) with evidence
1338
- > 5. **Trend detection**: Compare recent voting patterns to historical trends — is the Parliament becoming more/less fragmented?
1339
- > 6. **Forward-looking**: Which upcoming votes are likely to be contested based on current alignment patterns?
1340
- >
1341
- > If voting records are limited, analyse the adopted texts' policy positions to infer likely voting alignments and coalition patterns.
1342
-
1343
- ## AI-Produced Voting Intelligence
1344
-
1345
- [TO BE FILLED BY AI AGENT — Substantive voting pattern analysis with specific vote references, group cohesion ratings, and anomaly detection. Quality gate: minimum 300 words.]
1346
-
1347
- ## Date: ${date}
1348
- `);
1349
- }
1350
- /**
1351
- * Build markdown for cross-session intelligence analysis.
1352
- * Uses `computeCrossSessionCoalitionStability`.
1353
- *
1354
- * @param fetchedData - Raw fetched EP data
1355
- * @param date - Analysis date
1356
- * @returns Markdown content string
1357
- */
1358
- function buildCrossSessionIntelligenceMarkdown(fetchedData, date) {
1359
- const header = buildMarkdownHeader('cross-session-intelligence', date, 'high');
1360
- const rawPatterns = Array.isArray(fetchedData['patterns']) ? fetchedData['patterns'] : [];
1361
- // computeCrossSessionCoalitionStability accepts readonly VotingPattern[]
1362
- const stabilityReport = computeCrossSessionCoalitionStability(rawPatterns);
1363
- return (header +
1364
- `# Cross-Session Coalition Intelligence
1365
-
1366
- ## Computed Stability Metrics (Script-Generated Context)
1367
- - **Overall Stability**: ${(stabilityReport.overallStability * 100).toFixed(1)}%
1368
- - **Forecast**: ${stabilityReport.forecast}
1369
- - **Patterns Analysed**: ${stabilityReport.patternCount}
1370
- - **Stable Groups**: ${stabilityReport.stableGroups.length > 0 ? stabilityReport.stableGroups.join(', ') : 'None identified from voting data'}
1371
- - **Declining Groups**: ${stabilityReport.decliningGroups.length > 0 ? stabilityReport.decliningGroups.join(', ') : 'None identified from voting data'}
1372
-
1373
- ## AI Analysis Prompt
1374
-
1375
- > **Instructions for AI Agent (Opus 4.6):** Using the cross-session stability metrics above and the adopted texts/voting records from recent plenary sessions, produce a cross-session intelligence synthesis. Your analysis MUST:
1376
- >
1377
- > 1. **Compare coalition patterns** across the last 3-5 plenary sessions — are alliances strengthening or fragmenting?
1378
- > 2. **Identify session-over-session trends**: Which policy areas show increasing/decreasing consensus?
1379
- > 3. **Detect coalition realignment signals**: Are new voting blocs forming? Is the Grand Coalition showing stress?
1380
- > 4. **Institutional dynamics**: How are EP-Council-Commission dynamics evolving based on recent legislative outcomes?
1381
- > 5. **Predictive assessment**: Based on cross-session patterns, forecast likely coalition behavior for upcoming votes
1382
- > 6. **Confidence levels**: Rate each finding as 🟢 High / 🟡 Medium / 🔴 Low
1383
- >
1384
- > Cross-reference with adopted texts from the most recent plenary session to ground the analysis in specific legislative outcomes.
1385
-
1386
- ## AI-Produced Cross-Session Intelligence
1387
-
1388
- [TO BE FILLED BY AI AGENT — Cross-session trend analysis with specific plenary session references, coalition evolution assessment, and predictive indicators. Quality gate: minimum 400 words.]
1389
-
1390
- ## Date: ${date}
1391
- `);
1392
- }
1393
- /**
1394
- * Process a single feed item: deduplicate, write per-document files, and
1395
- * collect the index entry. Returns `undefined` if the item is invalid or
1396
- * already analyzed.
1397
- *
1398
- * @param raw - Raw feed item (may be null, non-object, or a valid record)
1399
- * @param feedKey - Feed category key (e.g. 'adoptedTexts', 'procedures')
1400
- * @param date - Analysis date string
1401
- * @param analyzedIds - Set of already-processed document IDs for deduplication
1402
- * @param docDir - Output directory for per-document markdown (empty string to skip writing)
1403
- * @param rawDataDir - Output directory for raw JSON data
1404
- * @param significance - Precomputed global political significance
1405
- * @param threats - Precomputed global threat assessment
1406
- * @returns Document entry for the index, or undefined if skipped
1407
- */
1408
- function processDocumentItem(raw, feedKey, date, analyzedIds, docDir, rawDataDir, significance, threats) {
1409
- if (!raw || typeof raw !== 'object')
195
+ const legacyHit = findLegacyOutput(method, dateOutputDir, subdir);
196
+ if (!legacyHit)
1410
197
  return undefined;
1411
- const item = raw;
1412
- const docId = extractDocumentId(item);
1413
- const dedupeKey = docId.toLowerCase().trim();
1414
- if (analyzedIds.has(dedupeKey))
1415
- return undefined;
1416
- analyzedIds.add(dedupeKey);
1417
- const title = extractDocumentTitle(item);
1418
- const safeId = sanitizeDocumentId(docId);
1419
- const filename = `${sanitizeDocumentId(feedKey)}-${safeId}-analysis.md`;
1420
- if (docDir) {
1421
- const docContent = buildSingleDocumentAnalysis(item, docId, title, feedKey, date, significance, threats);
1422
- writeTextFile(path.join(docDir, filename), docContent);
1423
- const rawJsonFilename = `${sanitizeDocumentId(feedKey)}-${safeId}-raw.json`;
1424
- writeTextFile(path.join(rawDataDir, rawJsonFilename), JSON.stringify(item, null, 2));
1425
- }
1426
- return { category: feedKey, id: docId, title, filename };
1427
- }
1428
- function buildDocumentAnalysisMarkdown(fetchedData, date) {
1429
- const header = buildMarkdownHeader(METHOD_DOCUMENT_ANALYSIS, date, 'high');
1430
- const dateOutputDir = fetchedData['_dateOutputDir'];
1431
- const outputBase = typeof dateOutputDir === 'string' ? dateOutputDir : '';
1432
- // Create output directories once, before iterating over items
1433
- const docDir = outputBase ? path.join(outputBase, 'documents') : '';
1434
- const rawDataDir = outputBase ? path.join(outputBase, 'documents', 'raw-data') : '';
1435
- if (docDir)
1436
- ensureDirectoryExists(docDir);
1437
- if (rawDataDir)
1438
- ensureDirectoryExists(rawDataDir);
1439
- // Pre-compute global significance and threat assessments once per run
1440
- // (both are based on the entire fetchedData, not individual documents)
1441
- const globalInput = toClassificationInput(fetchedData);
1442
- const globalSignificance = assessPoliticalSignificance(globalInput);
1443
- const globalThreatInput = toThreatInput(fetchedData);
1444
- const globalThreats = assessPoliticalThreats(globalThreatInput);
1445
- // Collect all documents across feed categories with deduplication
1446
- const analyzedIds = new Set();
1447
- const documentEntries = [];
1448
- for (const feedKey of DOCUMENT_FEED_KEYS) {
1449
- const items = safeArr(fetchedData, feedKey);
1450
- for (const raw of items) {
1451
- const entry = processDocumentItem(raw, feedKey, date, analyzedIds, docDir, rawDataDir, globalSignificance, globalThreats);
1452
- if (entry)
1453
- documentEntries.push(entry);
198
+ const legacyAbsolutePath = path.join(dateOutputDir, subdir, legacyHit);
199
+ const migrated = migrateLegacyFile(legacyAbsolutePath, absolutePath);
200
+ if (migrated || methodOutputExists(absolutePath)) {
201
+ if (verbose) {
202
+ const action = migrated ? 'Migrated legacy output and skipped' : 'Skipping';
203
+ console.log(` ⏭️ [analysis] ${action} ${method} — output at ${relativeOutputFile}`);
1454
204
  }
205
+ return {
206
+ method,
207
+ status: 'skipped',
208
+ outputFile: relativeOutputFile,
209
+ confidence,
210
+ duration: 0,
211
+ summary: migrated
212
+ ? `Skipped — migrated legacy ${legacyHit} → ${filename}`
213
+ : `Skipped — output already exists at ${relativeOutputFile}`,
214
+ };
1455
215
  }
1456
- // Store analyzed IDs for manifest consumption as a non-enumerable property
1457
- // to prevent leaking into dataSourcesUsed which iterates enumerable keys
1458
- Object.defineProperty(fetchedData, '_analyzedDocumentIds', {
1459
- value: [...analyzedIds],
1460
- writable: false,
1461
- configurable: true,
1462
- enumerable: false,
1463
- });
1464
- // Build index table
1465
- const tableRows = documentEntries.length > 0
1466
- ? documentEntries
1467
- .map((d) => `| ${sanitizeCell(d.id)} | ${sanitizeCell(d.title.slice(0, 60))} | ${sanitizeCell(d.category)} | [${d.filename}](${d.filename}) |`)
1468
- .join('\n')
1469
- : '| — | No documents available | — | — |';
1470
- return (header +
1471
- `# Per-Document Intelligence Analysis Index
1472
-
1473
- ## Executive Summary
1474
-
1475
- Full per-document political intelligence analysis for ${documentEntries.length} unique documents
1476
- across ${DOCUMENT_FEED_KEYS.length} feed categories. Each document has been individually
1477
- analyzed from fetched European Parliament data with comprehensive significance assessment,
1478
- SWOT analysis, and threat profiling.
1479
-
1480
- - **Total Documents Analyzed**: ${documentEntries.length}
1481
- - **Feed Categories Scanned**: ${DOCUMENT_FEED_KEYS.length}
1482
- - **Duplicates Deduplicated**: ${[...DOCUMENT_FEED_KEYS].reduce((s, k) => s + safeArr(fetchedData, k).length, 0) - documentEntries.length}
1483
- - **Date**: ${date}
1484
-
1485
- ## Document Analysis Index
1486
-
1487
- | Document ID | Title | Category | Analysis File |
1488
- |-------------|-------|----------|---------------|
1489
- ${tableRows}
1490
-
1491
- ## Category Breakdown
1492
-
1493
- ${DOCUMENT_FEED_KEYS.map((k) => `- **${k}**: ${safeArr(fetchedData, k).length} items (${documentEntries.filter((d) => d.category === k).length} unique analyzed)`).join('\n')}
1494
-
1495
- ## Methodology
1496
-
1497
- Each document receives:
1498
- 1. **Raw Data Storage** — Full document JSON stored in \`documents/raw-data/\` for complete data preservation
1499
- 2. **Significance Classification** — Political importance on 5-level scale
1500
- 3. **SWOT Assessment** — Strengths, weaknesses, opportunities, threats specific to the document
1501
- 4. **Threat Profiling** — Political threat landscape analysis for disruption potential
1502
- 5. **Stakeholder Impact** — Projected effects on key stakeholder groups
1503
- 6. **Intelligence Summary** — Key findings and actionable insights
1504
-
1505
- ## Document Storage
1506
-
1507
- All ${documentEntries.length} documents have been stored in their entirety:
1508
- - **Analysis files**: \`documents/{category}-{id}-analysis.md\`
1509
- - **Raw JSON data**: \`documents/raw-data/{category}-{id}-raw.json\`
1510
- - **Deduplication**: Documents appearing in multiple feed categories are stored once with primary category reference
1511
-
1512
- ## Date: ${date}
1513
- `);
1514
- }
1515
- /**
1516
- * Build comprehensive analysis markdown for a single document.
1517
- *
1518
- * Produces a standalone analysis file containing significance assessment,
1519
- * full narrative SWOT, threat profiling, and stakeholder impact for one
1520
- * individual EP document.
1521
- *
1522
- * @param item - Raw document item from feed data
1523
- * @param docId - Document identifier
1524
- * @param title - Document title
1525
- * @param category - Feed category the document came from
1526
- * @param date - Analysis date
1527
- * @param significance - Precomputed global political significance
1528
- * @param threats - Precomputed global threat assessment
1529
- * @returns Markdown content for single document analysis
1530
- */
1531
- function buildSingleDocumentAnalysis(item, docId, title, category, date, significance, threats) {
1532
- // Extract available metadata from the document
1533
- const docType = typeof item['type'] === 'string' ? item['type'] : category;
1534
- const docDate = typeof item['date'] === 'string' ? item['date'] : date;
1535
- const docStatus = typeof item['status'] === 'string' ? item['status'] : 'unknown';
1536
- const docStage = typeof item['stage'] === 'string' ? item['stage'] : 'N/A';
1537
- const docDescription = typeof item['description'] === 'string'
1538
- ? item['description']
1539
- : typeof item['summary'] === 'string'
1540
- ? item['summary']
1541
- : 'No description available';
1542
- // Build document-specific SWOT items — data-derived only, no pre-written conclusions
1543
- const docStrengths = [
1544
- createScoredSWOTItem(`Document ${sanitizeDocumentId(docId)} available in ${category} feed`, 3, [`Document ID: ${docId}`, `Category: ${category}`, `Status: ${docStatus}`], 'medium', 'stable'),
1545
- ];
1546
- const docWeaknesses = [
1547
- createScoredSWOTItem(`Document stage: ${docStage}, status: ${docStatus}`, 2, [`Current stage: ${docStage}`, `Type: ${docType}`, `Date: ${docDate}`], 'medium', 'stable'),
1548
- ];
1549
- const docOpportunities = [
1550
- createScoredOpportunityOrThreat(`${category} document with ID ${sanitizeDocumentId(docId)}`, 'possible', 'moderate', [`Category: ${category}`, `Date: ${docDate}`], 'medium', 'stable'),
1551
- ];
1552
- const docThreats = [
1553
- createScoredOpportunityOrThreat(`Document ${sanitizeDocumentId(docId)} — pipeline risk assessment`, 'possible', 'moderate', [`Stage: ${docStage}`, `Status: ${docStatus}`], 'medium', 'stable'),
1554
- ];
1555
- const docSwot = buildQuantitativeSWOT(`SWOT: ${title}`, docStrengths, docWeaknesses, docOpportunities, docThreats);
1556
- return `---
1557
- method: ${METHOD_DOCUMENT_ANALYSIS}
1558
- documentId: ${JSON.stringify(docId)}
1559
- category: ${JSON.stringify(category)}
1560
- date: ${JSON.stringify(date)}
1561
- confidence: medium
1562
- generated: ${JSON.stringify(new Date().toISOString())}
1563
- ---
1564
-
1565
- # Document Analysis: ${sanitizeCell(title)}
1566
-
1567
- ## Document Metadata
1568
-
1569
- | Field | Value |
1570
- |-------|-------|
1571
- | **Document ID** | ${sanitizeCell(docId)} |
1572
- | **Title** | ${sanitizeCell(title)} |
1573
- | **Type** | ${sanitizeCell(docType)} |
1574
- | **Category** | ${sanitizeCell(category)} |
1575
- | **Date** | ${sanitizeCell(docDate)} |
1576
- | **Status** | ${sanitizeCell(docStatus)} |
1577
- | **Stage** | ${sanitizeCell(docStage)} |
1578
-
1579
- ## Description
1580
-
1581
- ${sanitizeCell(docDescription)}
1582
-
1583
- ## Political Significance Assessment
1584
-
1585
- - **Overall Significance**: ${significance.toUpperCase()}
1586
- - **Context**: Document ${sanitizeCell(docId)} within ${category} feed
1587
-
1588
- ## Document-Specific SWOT Analysis
1589
-
1590
- ### Strategic Position Score: ${docSwot.strategicPositionScore.toFixed(1)}/10
1591
-
1592
- | Category | Score | Assessment |
1593
- |----------|-------|------------|
1594
- | Strengths | ${docSwot.strengths.reduce((s, i) => s + i.score, 0).toFixed(1)} | ${docSwot.strengths.map((s) => s.description).join('; ')} |
1595
- | Weaknesses | ${docSwot.weaknesses.reduce((s, i) => s + i.score, 0).toFixed(1)} | ${docSwot.weaknesses.map((w) => w.description).join('; ')} |
1596
- | Opportunities | ${docSwot.opportunities.reduce((s, i) => s + i.score, 0).toFixed(1)} | ${docSwot.opportunities.map((o) => o.description).join('; ')} |
1597
- | Threats | ${docSwot.threats.reduce((s, i) => s + i.score, 0).toFixed(1)} | ${docSwot.threats.map((t) => t.description).join('; ')} |
1598
-
1599
- ## Threat Assessment
1600
-
1601
- - **Threat Dimensions Evaluated**: ${threats.threatDimensions.length}
1602
- - **Overall Threat Level**: ${threats.overallThreatLevel}
1603
- - **Assessment Date**: ${threats.date}
1604
-
1605
- ## Stakeholder Impact
1606
-
1607
- | Stakeholder Group | Impact Level |
1608
- |-------------------|-------------|
1609
- | Political Groups | ${significance === 'routine' ? 'Low' : 'Medium'} |
1610
- | Civil Society | ${significance === 'routine' ? 'Low' : 'Medium'} |
1611
- | Industry | ${String(docType).toLowerCase() === 'resolution' || String(docType).toLowerCase() === 'directive' ? 'Medium' : 'Low'} |
1612
- | National Governments | ${String(docStage).toLowerCase() === 'trilogue' ? 'High' : 'Low'} |
1613
- | Citizens | Low |
1614
- | EU Institutions | ${significance === 'critical' || significance === 'historic' ? 'High' : 'Low'} |
1615
-
1616
- ## Intelligence Summary
1617
-
1618
- | Metric | Value |
1619
- |--------|-------|
1620
- | Document | ${sanitizeCell(docId)} |
1621
- | Category | ${sanitizeCell(category)} |
1622
- | Type | ${sanitizeCell(docType)} |
1623
- | Stage | ${sanitizeCell(docStage)} |
1624
- | Status | ${sanitizeCell(docStatus)} |
1625
- | Significance | ${significance} |
1626
- | SWOT Score | ${docSwot.strategicPositionScore.toFixed(1)}/10 |
1627
- | Overall Assessment | ${docSwot.overallAssessment} |
1628
- | Threat Dimensions | ${threats.threatDimensions.length} |
1629
- | Overall Threat Level | ${threats.overallThreatLevel} |
1630
-
1631
- ## Analysis Date: ${date}
1632
- `;
216
+ if (verbose) {
217
+ console.log(` ↻ [analysis] Legacy output found for ${method} but migration failed: ${legacyHit}. Regenerating canonical output ${relativeOutputFile}`);
218
+ }
219
+ return undefined;
1633
220
  }
1634
- /** Map from AnalysisMethod to its markdown builder function */
1635
- const METHOD_BUILDERS = {
1636
- 'significance-classification': buildSignificanceClassificationMarkdown,
1637
- 'impact-matrix': buildImpactMatrixMarkdown,
1638
- 'actor-mapping': buildActorMappingMarkdown,
1639
- 'forces-analysis': buildForcesAnalysisMarkdown,
1640
- 'political-threat-landscape': buildThreatLandscapeMarkdown,
1641
- 'actor-threat-profiling': buildActorThreatProfilingMarkdown,
1642
- 'consequence-trees': buildConsequenceTreesMarkdown,
1643
- 'legislative-disruption': buildLegislativeDisruptionMarkdown,
1644
- 'risk-matrix': buildRiskMatrixMarkdown,
1645
- 'political-capital-risk': buildPoliticalCapitalRiskMarkdown,
1646
- 'quantitative-swot': buildQuantitativeSwotMarkdown,
1647
- 'legislative-velocity-risk': buildLegislativeVelocityRiskMarkdown,
1648
- 'agent-risk-workflow': buildAgentRiskWorkflowMarkdown,
1649
- 'deep-analysis': buildDeepAnalysisMarkdown,
1650
- 'stakeholder-analysis': buildStakeholderAnalysisMarkdown,
1651
- 'coalition-analysis': buildCoalitionAnalysisMarkdown,
1652
- 'voting-patterns': buildVotingPatternsMarkdown,
1653
- 'cross-session-intelligence': buildCrossSessionIntelligenceMarkdown,
1654
- 'document-analysis': buildDocumentAnalysisMarkdown,
1655
- };
1656
221
  // ─── Method subdir constants ──────────────────────────────────────────────────
1657
222
  /** Subdirectory name for classification analysis methods */
1658
223
  const SUBDIR_CLASSIFICATION = 'classification';
@@ -1664,19 +229,114 @@ const SUBDIR_RISK_SCORING = 'risk-scoring';
1664
229
  const SUBDIR_EXISTING = 'existing';
1665
230
  /** Subdirectory name for per-document analysis methods */
1666
231
  const SUBDIR_DOCUMENTS = 'documents';
1667
- // ─── MCP data persistence subdirectories ──────────────────────────────────────
232
+ /**
233
+ * Canonical subdirectory for each analysis method group.
234
+ *
235
+ * Exported so that agentic workflows and downstream consumers can
236
+ * construct paths that are guaranteed to match the pipeline output.
237
+ */
238
+ export const ANALYSIS_METHOD_SUBDIRS = Object.freeze({
239
+ 'significance-classification': SUBDIR_CLASSIFICATION,
240
+ 'impact-matrix': SUBDIR_CLASSIFICATION,
241
+ 'actor-mapping': SUBDIR_CLASSIFICATION,
242
+ 'forces-analysis': SUBDIR_CLASSIFICATION,
243
+ 'political-threat-landscape': SUBDIR_THREAT_ASSESSMENT,
244
+ 'actor-threat-profiling': SUBDIR_THREAT_ASSESSMENT,
245
+ 'consequence-trees': SUBDIR_THREAT_ASSESSMENT,
246
+ 'legislative-disruption': SUBDIR_THREAT_ASSESSMENT,
247
+ 'risk-matrix': SUBDIR_RISK_SCORING,
248
+ 'political-capital-risk': SUBDIR_RISK_SCORING,
249
+ 'quantitative-swot': SUBDIR_RISK_SCORING,
250
+ 'legislative-velocity-risk': SUBDIR_RISK_SCORING,
251
+ 'agent-risk-workflow': SUBDIR_RISK_SCORING,
252
+ 'deep-analysis': SUBDIR_EXISTING,
253
+ 'stakeholder-analysis': SUBDIR_EXISTING,
254
+ 'coalition-analysis': SUBDIR_EXISTING,
255
+ 'voting-patterns': SUBDIR_EXISTING,
256
+ 'cross-session-intelligence': SUBDIR_EXISTING,
257
+ [METHOD_SIGNIFICANCE_SCORING_ID]: SUBDIR_CLASSIFICATION,
258
+ [METHOD_SYNTHESIS_SUMMARY_ID]: SUBDIR_EXISTING,
259
+ 'document-analysis': SUBDIR_DOCUMENTS,
260
+ });
261
+ /**
262
+ * Canonical filename for each analysis method.
263
+ *
264
+ * Exported so that agentic workflows and downstream consumers can
265
+ * reference the exact file names the pipeline produces.
266
+ */
267
+ export const ANALYSIS_METHOD_FILENAMES = Object.freeze({
268
+ 'significance-classification': 'significance-classification.md',
269
+ 'impact-matrix': 'impact-matrix.md',
270
+ 'actor-mapping': 'actor-mapping.md',
271
+ 'forces-analysis': 'forces-analysis.md',
272
+ 'political-threat-landscape': 'political-threat-landscape.md',
273
+ 'actor-threat-profiling': 'actor-threat-profiling.md',
274
+ 'consequence-trees': 'consequence-trees.md',
275
+ 'legislative-disruption': 'legislative-disruption.md',
276
+ 'risk-matrix': 'risk-matrix.md',
277
+ 'political-capital-risk': 'political-capital-risk.md',
278
+ 'quantitative-swot': 'quantitative-swot.md',
279
+ 'legislative-velocity-risk': 'legislative-velocity-risk.md',
280
+ 'agent-risk-workflow': 'agent-risk-workflow.md',
281
+ 'deep-analysis': 'deep-analysis.md',
282
+ 'stakeholder-analysis': 'stakeholder-impact.md',
283
+ 'coalition-analysis': 'coalition-dynamics.md',
284
+ 'voting-patterns': 'voting-patterns.md',
285
+ 'cross-session-intelligence': 'cross-session-intelligence.md',
286
+ [METHOD_SIGNIFICANCE_SCORING_ID]: 'significance-scoring.md',
287
+ [METHOD_SYNTHESIS_SUMMARY_ID]: 'synthesis-summary.md',
288
+ 'document-analysis': 'document-analysis-index.md',
289
+ });
290
+ /**
291
+ * Legacy filenames that were renamed during the canonical normalization.
292
+ *
293
+ * Used by {@link runSingleMethod} so that `skipCompleted` recognises
294
+ * previously-generated outputs that still exist under their old names.
295
+ */
296
+ const LEGACY_FILENAMES = Object.freeze({
297
+ 'significance-classification': Object.freeze(['significance-assessment.md']),
298
+ 'stakeholder-analysis': Object.freeze(['stakeholder-analysis.md']),
299
+ 'coalition-analysis': Object.freeze(['coalition-analysis.md']),
300
+ 'actor-threat-profiling': Object.freeze(['actor-threat-profiles.md']),
301
+ });
302
+ // ─── Assembled method builders ────────────────────────────────────────────────
303
+ /** Map from AnalysisMethod to its markdown builder function */
304
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- assembled from sub-module builders indexed by string
305
+ const METHOD_BUILDERS = {
306
+ ...CLASSIFICATION_BUILDERS,
307
+ ...THREAT_BUILDERS,
308
+ ...RISK_BUILDERS,
309
+ ...EXISTING_BUILDERS,
310
+ };
311
+ /** Default confidence level for each analysis method group */
312
+ const METHOD_DEFAULT_CONFIDENCE = {
313
+ 'significance-classification': 'medium',
314
+ 'impact-matrix': 'medium',
315
+ 'actor-mapping': 'medium',
316
+ 'forces-analysis': 'medium',
317
+ 'political-threat-landscape': 'medium',
318
+ 'actor-threat-profiling': 'low',
319
+ 'consequence-trees': 'medium',
320
+ 'legislative-disruption': 'medium',
321
+ 'risk-matrix': 'medium',
322
+ 'political-capital-risk': 'medium',
323
+ 'quantitative-swot': 'medium',
324
+ 'legislative-velocity-risk': 'medium',
325
+ 'agent-risk-workflow': 'medium',
326
+ 'deep-analysis': 'high',
327
+ 'stakeholder-analysis': 'high',
328
+ 'coalition-analysis': 'high',
329
+ 'voting-patterns': 'high',
330
+ 'cross-session-intelligence': 'high',
331
+ [METHOD_SIGNIFICANCE_SCORING_ID]: 'medium',
332
+ [METHOD_SYNTHESIS_SUMMARY_ID]: 'medium',
333
+ 'document-analysis': 'medium',
334
+ };
335
+ // ─── MCP data persistence ─────────────────────────────────────────────────────
1668
336
  /** Subdirectory name for raw MCP data storage */
1669
337
  const SUBDIR_DATA = 'data';
1670
338
  /**
1671
339
  * MCP data category → filesystem subdirectory mapping.
1672
- *
1673
- * Each EP data category fetched via MCP is stored in a dedicated subdirectory
1674
- * under `{dateOutputDir}/data/`. Filenames use EP entity IDs for consistency
1675
- * and traceability (e.g. `data/events/EVT-001.json`).
1676
- *
1677
- * Includes World Bank economic indicators (`world-bank/`), OSINT analytical
1678
- * outputs (`osint/`), and MCP tool responses (`mcp-responses/`) so that ALL
1679
- * MCP-sourced data is committed for verification and later reuse.
1680
340
  */
1681
341
  const DATA_CATEGORY_DIRS = {
1682
342
  events: 'events',
@@ -1693,24 +353,18 @@ const DATA_CATEGORY_DIRS = {
1693
353
  corporateBodies: 'corporate-bodies',
1694
354
  votingRecords: 'votes',
1695
355
  speeches: 'speeches',
1696
- // World Bank economic data (CSV parsed to JSON)
1697
356
  worldBankIndicators: 'world-bank',
1698
- // OSINT analytical tool outputs
1699
357
  politicalLandscape: 'osint',
1700
358
  votingAnomalies: 'osint',
1701
359
  coalitionDynamics: 'osint',
1702
360
  countryDelegations: 'osint',
1703
361
  mepInfluence: 'osint',
1704
- // Raw MCP tool call responses
1705
362
  mcpResponses: 'mcp-responses',
1706
363
  };
1707
364
  /**
1708
365
  * Extract a stable identifier from an MCP data item for consistent filenames.
1709
366
  *
1710
- * Inspects common EP identifier fields (eventId, procedureId, docId, etc.)
1711
- * and falls back to an index-based name when no recognised ID is found.
1712
- *
1713
- * @param item - Single EP data item (object with potential ID fields)
367
+ * @param item - Single EP data item
1714
368
  * @param index - Fallback index when no ID field is found
1715
369
  * @returns Filesystem-safe identifier string
1716
370
  */
@@ -1741,12 +395,12 @@ function extractItemId(item, index) {
1741
395
  return `item-${String(index).padStart(4, '0')}`;
1742
396
  }
1743
397
  /**
1744
- * Persist a singleton OSINT data category (non-array object) to a file.
398
+ * Persist a singleton OSINT data category to a file.
1745
399
  *
1746
400
  * @param data - The singleton data object to persist
1747
- * @param category - The fetchedData key name (camelCase)
401
+ * @param category - The fetchedData key name
1748
402
  * @param dataBaseDir - Base directory for data persistence
1749
- * @param subdir - Target subdirectory under dataBaseDir
403
+ * @param subdir - Target subdirectory
1750
404
  * @returns 1 if written, 0 if skipped
1751
405
  */
1752
406
  function persistSingletonData(data, category, dataBaseDir, subdir) {
@@ -1759,11 +413,11 @@ function persistSingletonData(data, category, dataBaseDir, subdir) {
1759
413
  return 1;
1760
414
  }
1761
415
  /**
1762
- * Persist MCP tool responses (object keyed by tool name) to individual files.
416
+ * Persist MCP tool responses to individual files.
1763
417
  *
1764
418
  * @param data - Object keyed by MCP tool name
1765
419
  * @param dataBaseDir - Base directory for data persistence
1766
- * @param subdir - Target subdirectory under dataBaseDir
420
+ * @param subdir - Target subdirectory
1767
421
  * @returns Number of items written
1768
422
  */
1769
423
  function persistMCPResponses(data, dataBaseDir, subdir) {
@@ -1783,16 +437,7 @@ function persistMCPResponses(data, dataBaseDir, subdir) {
1783
437
  return count;
1784
438
  }
1785
439
  /**
1786
- * Persist raw MCP-fetched data to structured subdirectories for verification
1787
- * and later reuse.
1788
- *
1789
- * Creates `{dateOutputDir}/data/{category}/` directories and writes each item
1790
- * as an individual JSON file named by its EP identifier. Existing files are
1791
- * overwritten to support update workflows.
1792
- *
1793
- * For OSINT categories that share the `osint/` subdirectory (politicalLandscape,
1794
- * votingAnomalies, coalitionDynamics, etc.), files are prefixed with the category
1795
- * name to avoid collisions (e.g. `osint/political-landscape.json`).
440
+ * Persist raw MCP-fetched data to structured subdirectories.
1796
441
  *
1797
442
  * @param fetchedData - Raw EP data keyed by data category
1798
443
  * @param dateOutputDir - Absolute path to the date-scoped output directory
@@ -1801,7 +446,6 @@ function persistMCPResponses(data, dataBaseDir, subdir) {
1801
446
  function persistMCPData(fetchedData, dateOutputDir, verbose) {
1802
447
  const dataBaseDir = path.join(dateOutputDir, SUBDIR_DATA);
1803
448
  let totalItems = 0;
1804
- /** Categories that share the osint/ subdir — use category name as filename prefix */
1805
449
  const OSINT_SINGLETON_CATEGORIES = new Set([
1806
450
  'politicalLandscape',
1807
451
  'votingAnomalies',
@@ -1811,12 +455,10 @@ function persistMCPData(fetchedData, dateOutputDir, verbose) {
1811
455
  ]);
1812
456
  for (const [category, subdir] of Object.entries(DATA_CATEGORY_DIRS)) {
1813
457
  const items = fetchedData[category];
1814
- // Handle singleton objects (e.g. politicalLandscape is one big response, not an array)
1815
458
  if (OSINT_SINGLETON_CATEGORIES.has(category)) {
1816
459
  totalItems += persistSingletonData(items, category, dataBaseDir, subdir);
1817
460
  continue;
1818
461
  }
1819
- // Handle mcpResponses: single object, not an array
1820
462
  if (category === 'mcpResponses') {
1821
463
  totalItems += persistMCPResponses(items, dataBaseDir, subdir);
1822
464
  continue;
@@ -1837,80 +479,10 @@ function persistMCPData(fetchedData, dateOutputDir, verbose) {
1837
479
  console.log(` 📂 [analysis] Persisted ${totalItems} MCP data items to ${dataBaseDir}`);
1838
480
  }
1839
481
  }
1840
- /** Analysis method identifier for per-document intelligence analysis */
1841
- const METHOD_DOCUMENT_ANALYSIS = 'document-analysis';
1842
- /** Subdirectory for each analysis method group */
1843
- const METHOD_SUBDIRS = {
1844
- 'significance-classification': SUBDIR_CLASSIFICATION,
1845
- 'impact-matrix': SUBDIR_CLASSIFICATION,
1846
- 'actor-mapping': SUBDIR_CLASSIFICATION,
1847
- 'forces-analysis': SUBDIR_CLASSIFICATION,
1848
- 'political-threat-landscape': SUBDIR_THREAT_ASSESSMENT,
1849
- 'actor-threat-profiling': SUBDIR_THREAT_ASSESSMENT,
1850
- 'consequence-trees': SUBDIR_THREAT_ASSESSMENT,
1851
- 'legislative-disruption': SUBDIR_THREAT_ASSESSMENT,
1852
- 'risk-matrix': SUBDIR_RISK_SCORING,
1853
- 'political-capital-risk': SUBDIR_RISK_SCORING,
1854
- 'quantitative-swot': SUBDIR_RISK_SCORING,
1855
- 'legislative-velocity-risk': SUBDIR_RISK_SCORING,
1856
- 'agent-risk-workflow': SUBDIR_RISK_SCORING,
1857
- 'deep-analysis': SUBDIR_EXISTING,
1858
- 'stakeholder-analysis': SUBDIR_EXISTING,
1859
- 'coalition-analysis': SUBDIR_EXISTING,
1860
- 'voting-patterns': SUBDIR_EXISTING,
1861
- 'cross-session-intelligence': SUBDIR_EXISTING,
1862
- 'document-analysis': SUBDIR_DOCUMENTS,
1863
- };
1864
- /** Default confidence level for each analysis method group */
1865
- const METHOD_DEFAULT_CONFIDENCE = {
1866
- 'significance-classification': 'medium',
1867
- 'impact-matrix': 'medium',
1868
- 'actor-mapping': 'medium',
1869
- 'forces-analysis': 'medium',
1870
- 'political-threat-landscape': 'medium',
1871
- 'actor-threat-profiling': 'low',
1872
- 'consequence-trees': 'medium',
1873
- 'legislative-disruption': 'medium',
1874
- 'risk-matrix': 'medium',
1875
- 'political-capital-risk': 'medium',
1876
- 'quantitative-swot': 'medium',
1877
- 'legislative-velocity-risk': 'medium',
1878
- 'agent-risk-workflow': 'medium',
1879
- 'deep-analysis': 'high',
1880
- 'stakeholder-analysis': 'high',
1881
- 'coalition-analysis': 'high',
1882
- 'voting-patterns': 'high',
1883
- 'cross-session-intelligence': 'high',
1884
- 'document-analysis': 'medium',
1885
- };
1886
- /** Filename for each analysis method */
1887
- const METHOD_FILENAMES = {
1888
- 'significance-classification': 'significance-assessment.md',
1889
- 'impact-matrix': 'impact-matrix.md',
1890
- 'actor-mapping': 'actor-mapping.md',
1891
- 'forces-analysis': 'forces-analysis.md',
1892
- 'political-threat-landscape': 'political-threat-landscape.md',
1893
- 'actor-threat-profiling': 'actor-threat-profiles.md',
1894
- 'consequence-trees': 'consequence-trees.md',
1895
- 'legislative-disruption': 'legislative-disruption.md',
1896
- 'risk-matrix': 'risk-matrix.md',
1897
- 'political-capital-risk': 'political-capital-risk.md',
1898
- 'quantitative-swot': 'quantitative-swot.md',
1899
- 'legislative-velocity-risk': 'legislative-velocity-risk.md',
1900
- 'agent-risk-workflow': 'agent-risk-workflow.md',
1901
- 'deep-analysis': 'deep-analysis.md',
1902
- 'stakeholder-analysis': 'stakeholder-analysis.md',
1903
- 'coalition-analysis': 'coalition-analysis.md',
1904
- 'voting-patterns': 'voting-patterns.md',
1905
- 'cross-session-intelligence': 'cross-session-intelligence.md',
1906
- 'document-analysis': 'document-analysis-index.md',
1907
- };
1908
482
  // ─── Core runner ──────────────────────────────────────────────────────────────
1909
483
  /**
1910
484
  * Run a single analysis method and return its status record.
1911
485
  *
1912
- * Wraps the builder call in a try/catch so failures are isolated.
1913
- *
1914
486
  * @param method - The analysis method to run
1915
487
  * @param fetchedData - Raw fetched EP data
1916
488
  * @param date - ISO date string
@@ -1920,30 +492,22 @@ const METHOD_FILENAMES = {
1920
492
  * @returns Status record for the method
1921
493
  */
1922
494
  function runSingleMethod(method, fetchedData, date, dateOutputDir, skipCompleted, verbose) {
1923
- const subdir = METHOD_SUBDIRS[method];
1924
- const filename = METHOD_FILENAMES[method];
495
+ const subdir = ANALYSIS_METHOD_SUBDIRS[method];
496
+ const filename = ANALYSIS_METHOD_FILENAMES[method];
1925
497
  const absolutePath = path.join(dateOutputDir, subdir, filename);
1926
- // Store a portable relative path (relative to the date-scoped output dir)
1927
- // in the manifest to avoid exposing runner/local filesystem layout.
1928
498
  const relativeOutputFile = path.posix.join(subdir, filename);
1929
499
  const confidence = METHOD_DEFAULT_CONFIDENCE[method];
1930
- if (skipCompleted && methodOutputExists(absolutePath)) {
1931
- if (verbose)
1932
- console.log(` ⏭️ [analysis] Skipping already-completed method: ${method}`);
1933
- return {
1934
- method,
1935
- status: 'skipped',
1936
- outputFile: relativeOutputFile,
1937
- confidence,
1938
- duration: 0,
1939
- summary: `Skipped — output already exists at ${relativeOutputFile}`,
1940
- };
500
+ if (skipCompleted) {
501
+ const skipResult = checkSkipCompleted(method, dateOutputDir, subdir, filename, absolutePath, relativeOutputFile, confidence, verbose);
502
+ if (skipResult)
503
+ return skipResult;
1941
504
  }
1942
505
  const start = Date.now();
1943
506
  try {
1944
507
  const builder = METHOD_BUILDERS[method];
1945
508
  // Inject dateOutputDir for the document-analysis builder to write per-document files
1946
- if (method === METHOD_DOCUMENT_ANALYSIS) {
509
+ // and for the synthesis-summary builder to read existing analysis outputs
510
+ if (method === METHOD_DOCUMENT_ANALYSIS || method === METHOD_SYNTHESIS_SUMMARY_ID) {
1947
511
  fetchedData['_dateOutputDir'] = dateOutputDir;
1948
512
  }
1949
513
  const markdown = builder(fetchedData, date);
@@ -1978,15 +542,6 @@ function runSingleMethod(method, fetchedData, date, dateOutputDir, skipCompleted
1978
542
  /**
1979
543
  * Derive a filesystem-safe slug from a list of article types.
1980
544
  *
1981
- * Each agentic workflow runs a single article type (e.g. `week-ahead`).
1982
- * The slug is used to scope analysis output to
1983
- * `{outputDir}/{date}/{slug}/` so that concurrent workflows for different
1984
- * article types never collide on the same files.
1985
- *
1986
- * When multiple types are present the slug is the sorted, hyphen-joined list.
1987
- * The result is sanitised to contain only lowercase alphanumeric characters
1988
- * and hyphens, preventing path traversal or filesystem issues.
1989
- *
1990
545
  * @param articleTypes - One or more article category identifiers
1991
546
  * @returns Filesystem-safe slug (lowercase, alphanumeric + hyphens only)
1992
547
  *
@@ -2004,8 +559,6 @@ export function deriveArticleTypeSlug(articleTypes) {
2004
559
  .map((t) => t.trim().toLowerCase())
2005
560
  .sort()
2006
561
  .join('-');
2007
- // Sanitise: strip anything that isn't lowercase alphanumeric or hyphen,
2008
- // collapse multiple hyphens, and trim leading/trailing hyphens.
2009
562
  const sanitised = raw.replace(/[^a-z0-9-]+/gu, '-').replace(/-{2,}/gu, '-');
2010
563
  const trimmed = sanitised.replace(/^-/u, '').replace(/-$/u, '');
2011
564
  return trimmed.length > 0 ? trimmed : 'default';
@@ -2014,42 +567,18 @@ export function deriveArticleTypeSlug(articleTypes) {
2014
567
  * Run the full analysis pipeline stage.
2015
568
  *
2016
569
  * Executes all enabled analysis methods sequentially, writing markdown files
2017
- * and a `manifest.json` summary. When {@link AnalysisStageOptions.articleTypeSlug}
2018
- * is provided the output is scoped to `outputDir/{date}/{slug}/` — this prevents
2019
- * merge conflicts when multiple agentic workflows run on the same date.
2020
- *
2021
- * Individual method failures are isolated — other methods continue regardless.
570
+ * and a `manifest.json` summary.
2022
571
  *
2023
- * @param fetchedData - Raw EP data fetched by the fetch stage (keyed by data type)
572
+ * @param fetchedData - Raw EP data fetched by the fetch stage
2024
573
  * @param options - Analysis stage configuration
2025
574
  * @returns Analysis context object for consumption by the generate stage
2026
- *
2027
- * @example
2028
- * ```ts
2029
- * const ctx = await runAnalysisStage(fetchedData, {
2030
- * articleTypes: [ArticleCategory.WEEK_AHEAD],
2031
- * date: '2026-03-26',
2032
- * outputDir: 'analysis',
2033
- * articleTypeSlug: 'week-ahead',
2034
- * skipCompleted: true,
2035
- * verbose: true,
2036
- * });
2037
- * ```
2038
575
  */
2039
576
  export async function runAnalysisStage(fetchedData, options) {
2040
577
  const { articleTypes, date, outputDir, articleTypeSlug, enabledMethods = ALL_ANALYSIS_METHODS, skipCompleted = true, verbose = false, requireData = false, } = options;
2041
- // Validate date to prevent path traversal (e.g. "../../.." escaping outputDir)
2042
578
  if (!/^\d{4}-\d{2}-\d{2}$/u.test(date)) {
2043
579
  throw new Error(`Invalid analysis date "${date}": must match YYYY-MM-DD format`);
2044
580
  }
2045
- // Deduplicate enabledMethods (preserving order) so programmatic callers
2046
- // that accidentally pass duplicates don't run the same method twice.
2047
581
  const deduplicatedMethods = [...new Set(enabledMethods)];
2048
- // When requireData is set (agentic workflows), abort immediately when no
2049
- // substantive EP data was fetched — running analysis on empty data produces
2050
- // hollow output that should never feed into article generation.
2051
- // This check runs BEFORE directory claiming so aborted runs don't leave
2052
- // behind orphan directories that would force subsequent runs to suffix.
2053
582
  if (!hasSubstantiveData(fetchedData)) {
2054
583
  if (requireData) {
2055
584
  throw new Error('Analysis aborted: no substantive EP data available. ' +
@@ -2061,11 +590,6 @@ export async function runAnalysisStage(fetchedData, options) {
2061
590
  }
2062
591
  const startTime = new Date().toISOString();
2063
592
  const runId = randomUUID();
2064
- // When articleTypeSlug is provided, scope output to a per-article-type
2065
- // subdirectory so concurrent workflows on the same date never collide.
2066
- // resolveUniqueAnalysisDir atomically claims a directory, appending a
2067
- // numeric suffix (-2, -3, …) when the preferred path is already taken,
2068
- // preventing repeated workflow runs from overwriting previous analysis.
2069
593
  const preferredDir = articleTypeSlug
2070
594
  ? path.resolve(outputDir, date, articleTypeSlug)
2071
595
  : path.resolve(outputDir, date);
@@ -2079,19 +603,12 @@ export async function runAnalysisStage(fetchedData, options) {
2079
603
  console.log(` Output: ${dateOutputDir}`);
2080
604
  }
2081
605
  ensureDirectoryExists(dateOutputDir);
2082
- // Persist raw MCP data to structured data/ subdirectories for verification,
2083
- // traceability, and later reuse. Each category gets its own directory and
2084
- // each item is named by its EP identifier for consistent, ID-based filenames.
2085
606
  persistMCPData(fetchedData, dateOutputDir, verbose);
2086
- // Run all enabled methods sequentially; isolate failures
2087
607
  const methodResults = [];
2088
608
  for (const method of deduplicatedMethods) {
2089
609
  const result = runSingleMethod(method, fetchedData, date, dateOutputDir, skipCompleted, verbose);
2090
610
  methodResults.push(result);
2091
611
  }
2092
- // When requireData is set (agentic workflows), abort if ANY method failed.
2093
- // Incomplete analysis must never feed into article generation — the agentic
2094
- // workflow should fix issues rather than produce articles from partial data.
2095
612
  const failedMethods = methodResults.filter((r) => r.status === 'failed');
2096
613
  if (requireData && failedMethods.length > 0) {
2097
614
  const failedNames = failedMethods.map((r) => r.method).join(', ');
@@ -2102,7 +619,6 @@ export async function runAnalysisStage(fetchedData, options) {
2102
619
  const endTime = new Date().toISOString();
2103
620
  const overallConfidence = aggregateConfidence(methodResults);
2104
621
  const dataSourcesUsed = Object.keys(fetchedData).filter((k) => Array.isArray(fetchedData[k]) && fetchedData[k].length > 0);
2105
- // Collect per-document analysis tracking from the document-analysis builder
2106
622
  const analyzedDocIds = Array.isArray(fetchedData['_analyzedDocumentIds'])
2107
623
  ? fetchedData['_analyzedDocumentIds']
2108
624
  : [];
@@ -2119,7 +635,6 @@ export async function runAnalysisStage(fetchedData, options) {
2119
635
  documentsAnalyzed: analyzedDocIds.length,
2120
636
  analyzedDocumentIds: analyzedDocIds,
2121
637
  };
2122
- // Write manifest.json
2123
638
  const manifestPath = path.join(dateOutputDir, 'manifest.json');
2124
639
  writeTextFile(manifestPath, JSON.stringify(manifest, null, 2));
2125
640
  if (verbose) {