euparliamentmonitor 0.8.21 → 0.8.23
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.
- package/package.json +5 -5
- package/scripts/generators/news-enhanced.d.ts +12 -0
- package/scripts/generators/news-enhanced.js +33 -1
- package/scripts/generators/pipeline/analysis-classification.js +79 -13
- package/scripts/generators/pipeline/analysis-existing.js +55 -3
- package/scripts/generators/pipeline/analysis-stage.js +3 -4
- package/scripts/generators/pipeline/generate-stage.d.ts +8 -0
- package/scripts/generators/pipeline/generate-stage.js +38 -7
- package/scripts/generators/strategies/breaking-news-strategy.js +38 -34
- package/scripts/generators/strategies/committee-reports-strategy.js +32 -30
- package/scripts/generators/strategies/motions-strategy.js +31 -31
- package/scripts/generators/strategies/propositions-strategy.js +25 -33
- package/scripts/mcp/ep-mcp-client.js +35 -16
- package/scripts/templates/article-template.js +15 -0
- package/scripts/utils/content-metadata.js +60 -17
- package/scripts/utils/metadata-utils.d.ts +11 -4
- package/scripts/utils/metadata-utils.js +17 -0
- package/scripts/utils/significance-scoring.d.ts +5 -5
- package/scripts/utils/significance-scoring.js +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "euparliamentmonitor",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.23",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "European Parliament Intelligence Platform - Monitor political activity with systematic transparency",
|
|
6
6
|
"main": "scripts/index.js",
|
|
@@ -142,8 +142,8 @@
|
|
|
142
142
|
"@types/papaparse": "5.5.2",
|
|
143
143
|
"@typescript-eslint/eslint-plugin": "8.58.1",
|
|
144
144
|
"@typescript-eslint/parser": "8.58.1",
|
|
145
|
-
"@vitest/coverage-v8": "4.1.
|
|
146
|
-
"@vitest/ui": "4.1.
|
|
145
|
+
"@vitest/coverage-v8": "4.1.4",
|
|
146
|
+
"@vitest/ui": "4.1.4",
|
|
147
147
|
"chart.js": "4.5.1",
|
|
148
148
|
"chartjs-plugin-annotation": "3.1.0",
|
|
149
149
|
"d3": "7.9.0",
|
|
@@ -163,13 +163,13 @@
|
|
|
163
163
|
"tsx": "4.21.0",
|
|
164
164
|
"typedoc": "0.28.18",
|
|
165
165
|
"typescript": "6.0.2",
|
|
166
|
-
"vitest": "4.1.
|
|
166
|
+
"vitest": "4.1.4"
|
|
167
167
|
},
|
|
168
168
|
"engines": {
|
|
169
169
|
"node": ">=25"
|
|
170
170
|
},
|
|
171
171
|
"dependencies": {
|
|
172
|
-
"european-parliament-mcp-server": "1.1
|
|
172
|
+
"european-parliament-mcp-server": "1.2.1"
|
|
173
173
|
},
|
|
174
174
|
"optionalDependencies": {
|
|
175
175
|
"worldbank-mcp": "1.0.1"
|
|
@@ -11,4 +11,16 @@ export { applyCommitteeInfo, applyDocuments, applyEffectiveness, FEATURED_COMMIT
|
|
|
11
11
|
export { PLACEHOLDER_MARKER, getMotionsFallbackData, generateMotionsContent, buildPoliticalAlignmentSection, };
|
|
12
12
|
export { buildPropositionsContent };
|
|
13
13
|
export type { PipelineData };
|
|
14
|
+
/**
|
|
15
|
+
* AI-generated article title passed by the agentic workflow.
|
|
16
|
+
* When provided, this OVERRIDES any script-generated title.
|
|
17
|
+
* The AI agent (Opus 4.6) must analyse the content and produce this.
|
|
18
|
+
*/
|
|
19
|
+
export declare const aiTitle: string;
|
|
20
|
+
/**
|
|
21
|
+
* AI-generated article description/subtitle passed by the agentic workflow.
|
|
22
|
+
* When provided, this OVERRIDES any script-generated description.
|
|
23
|
+
* The AI agent (Opus 4.6) must analyse the content and produce this.
|
|
24
|
+
*/
|
|
25
|
+
export declare const aiDescription: string;
|
|
14
26
|
//# sourceMappingURL=news-enhanced.d.ts.map
|
|
@@ -45,7 +45,7 @@ import { closeEPMCPClient } from '../mcp/ep-mcp-client.js';
|
|
|
45
45
|
import { ensureDirectoryExists } from '../utils/file-utils.js';
|
|
46
46
|
// ─── Pipeline-stage imports ───────────────────────────────────────────────────
|
|
47
47
|
import { initializeMCPClient, fetchEPFeedData } from './pipeline/fetch-stage.js';
|
|
48
|
-
import { createStrategyRegistry, generateArticleForStrategy } from './pipeline/generate-stage.js';
|
|
48
|
+
import { createStrategyRegistry, generateArticleForStrategy, setAIMetadata, } from './pipeline/generate-stage.js';
|
|
49
49
|
import { writeGenerationMetadata } from './pipeline/output-stage.js';
|
|
50
50
|
import { runAnalysisStage, ALL_ANALYSIS_METHODS, VALID_ANALYSIS_METHODS, hasSubstantiveData, deriveArticleTypeSlug, } from './pipeline/analysis-stage.js';
|
|
51
51
|
// ─── Content-module imports (bounded contexts) ───────────────────────────────
|
|
@@ -73,6 +73,22 @@ const analysisOnlyArg = args.includes('--analysis-only');
|
|
|
73
73
|
const analysisVerboseArg = args.includes('--analysis-verbose');
|
|
74
74
|
const analysisDirArg = args.find((arg) => arg.startsWith('--analysis-dir='));
|
|
75
75
|
const analysisMethodsArg = args.find((arg) => arg.startsWith('--analysis-methods='));
|
|
76
|
+
const titleArg = args.find((arg) => arg.startsWith('--title='));
|
|
77
|
+
const descriptionArg = args.find((arg) => arg.startsWith('--description='));
|
|
78
|
+
/**
|
|
79
|
+
* AI-generated article title passed by the agentic workflow.
|
|
80
|
+
* When provided, this OVERRIDES any script-generated title.
|
|
81
|
+
* The AI agent (Opus 4.6) must analyse the content and produce this.
|
|
82
|
+
*/
|
|
83
|
+
export const aiTitle = titleArg ? titleArg.slice('--title='.length).trim() : '';
|
|
84
|
+
/**
|
|
85
|
+
* AI-generated article description/subtitle passed by the agentic workflow.
|
|
86
|
+
* When provided, this OVERRIDES any script-generated description.
|
|
87
|
+
* The AI agent (Opus 4.6) must analyse the content and produce this.
|
|
88
|
+
*/
|
|
89
|
+
export const aiDescription = descriptionArg
|
|
90
|
+
? descriptionArg.slice('--description='.length).trim()
|
|
91
|
+
: '';
|
|
76
92
|
/** Path to a JSON file containing pre-fetched EP feed data (optional). */
|
|
77
93
|
const feedDataPath = feedDataArg?.startsWith('--feed-data=')
|
|
78
94
|
? feedDataArg.slice('--feed-data='.length).trim()
|
|
@@ -332,6 +348,20 @@ async function runAnalysisWithGuard(date, client) {
|
|
|
332
348
|
}
|
|
333
349
|
return analysisCtx;
|
|
334
350
|
}
|
|
351
|
+
/**
|
|
352
|
+
* Wire AI-provided title/description from CLI `--title` and `--description` flags.
|
|
353
|
+
* The AI agent (Opus 4.6) passes these after analysing the content.
|
|
354
|
+
* They override ALL script-generated metadata for the English version.
|
|
355
|
+
*/
|
|
356
|
+
function wireAIMetadata() {
|
|
357
|
+
if (aiTitle || aiDescription) {
|
|
358
|
+
setAIMetadata(aiTitle, aiDescription);
|
|
359
|
+
if (aiTitle)
|
|
360
|
+
console.log(`📝 AI-provided title: "${aiTitle}"`);
|
|
361
|
+
if (aiDescription)
|
|
362
|
+
console.log(`📝 AI-provided description: "${aiDescription}"`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
335
365
|
/**
|
|
336
366
|
* Main execution: initialise the MCP client, optionally run analysis stage,
|
|
337
367
|
* iterate over requested article types, delegate to the appropriate strategy,
|
|
@@ -341,6 +371,8 @@ async function main() {
|
|
|
341
371
|
console.log('');
|
|
342
372
|
console.log('🚀 Starting news generation...');
|
|
343
373
|
console.log('');
|
|
374
|
+
// Wire AI-provided title/description from CLI flags.
|
|
375
|
+
wireAIMetadata();
|
|
344
376
|
// When --feed-data is provided, expose the path via env so strategies can
|
|
345
377
|
// load pre-fetched data without requiring a live MCP connection.
|
|
346
378
|
if (feedDataPath) {
|
|
@@ -34,12 +34,77 @@ const EVENT_PUBLIC_LOW = 3;
|
|
|
34
34
|
const EVENT_DEFAULT_URGENCY = 5;
|
|
35
35
|
const EVENT_INSTITUTIONAL_HIGH = 7;
|
|
36
36
|
const EVENT_INSTITUTIONAL_LOW = 4;
|
|
37
|
-
/**
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
const
|
|
37
|
+
/** Base dimension scores for adopted texts (plenary-approved) */
|
|
38
|
+
const ADOPTED_PARLIAMENTARY_BASE = 6;
|
|
39
|
+
const ADOPTED_POLICY_BASE = 5;
|
|
40
|
+
const ADOPTED_PUBLIC_BASE = 4;
|
|
41
|
+
const ADOPTED_URGENCY_BASE = 3;
|
|
42
|
+
const ADOPTED_INSTITUTIONAL_BASE = 5;
|
|
43
|
+
// ─── Content-aware scoring keywords for adopted text differentiation ──────────
|
|
44
|
+
/** Title keywords indicating high-impact legislative texts (directives, regulations) */
|
|
45
|
+
const HIGH_IMPACT_TITLE_KEYWORDS = /\b(?:directive|regulation|regulation\s+\(eu\)|codecision|ordinary\s+legislative|COD|budget|defence|security|tariff|anti-corruption|banking|single\s+market)\b/i;
|
|
46
|
+
/** Title keywords indicating moderate-impact texts */
|
|
47
|
+
const MODERATE_IMPACT_TITLE_KEYWORDS = /\b(?:resolution|decision|recommendation|amendment|framework|strategy|agreement|trade|environment|climate|digital|data\s+protection|consumer|health)\b/i;
|
|
48
|
+
/** Procedure references indicating ordinary legislative procedure (highest significance) */
|
|
49
|
+
const COD_PROCEDURE_PATTERN = /\bCOD\b|\b\d{4}\/\d{4}\(COD\)/i;
|
|
50
|
+
/** Pattern for recent EP10 adopted texts (current term) */
|
|
51
|
+
const EP10_ADOPTED_TEXT_PATTERN = /TA-10-202[5-9]/i;
|
|
52
|
+
/**
|
|
53
|
+
* Score an adopted text based on its actual content metadata.
|
|
54
|
+
*
|
|
55
|
+
* Analyses the title, reference, and any procedure type information to
|
|
56
|
+
* produce differentiated scores rather than flat constants. High-impact
|
|
57
|
+
* legislative texts (directives, regulations, COD procedures) score higher
|
|
58
|
+
* than routine administrative texts.
|
|
59
|
+
*
|
|
60
|
+
* @param title - Adopted text title or label
|
|
61
|
+
* @param reference - EP reference identifier
|
|
62
|
+
* @param workType - Optional work type field from EP data
|
|
63
|
+
* @param procedureReference - Optional procedure reference
|
|
64
|
+
* @returns Per-dimension scoring input
|
|
65
|
+
*/
|
|
66
|
+
function scoreAdoptedText(title, reference, workType, procedureReference) {
|
|
67
|
+
let parliamentary = ADOPTED_PARLIAMENTARY_BASE;
|
|
68
|
+
let policy = ADOPTED_POLICY_BASE;
|
|
69
|
+
let publicInterest = ADOPTED_PUBLIC_BASE;
|
|
70
|
+
let urgency = ADOPTED_URGENCY_BASE;
|
|
71
|
+
let institutional = ADOPTED_INSTITUTIONAL_BASE;
|
|
72
|
+
const combined = [title, reference, workType, procedureReference].filter(Boolean).join(' ');
|
|
73
|
+
// Boost for high-impact legislative keywords
|
|
74
|
+
if (HIGH_IMPACT_TITLE_KEYWORDS.test(combined)) {
|
|
75
|
+
parliamentary += 2;
|
|
76
|
+
policy += 2;
|
|
77
|
+
publicInterest += 2;
|
|
78
|
+
institutional += 2;
|
|
79
|
+
}
|
|
80
|
+
else if (MODERATE_IMPACT_TITLE_KEYWORDS.test(combined)) {
|
|
81
|
+
parliamentary += 1;
|
|
82
|
+
policy += 1;
|
|
83
|
+
publicInterest += 1;
|
|
84
|
+
institutional += 1;
|
|
85
|
+
}
|
|
86
|
+
// Boost for ordinary legislative procedure (COD) — highest parliamentary significance
|
|
87
|
+
if (COD_PROCEDURE_PATTERN.test(combined)) {
|
|
88
|
+
parliamentary += 1;
|
|
89
|
+
policy += 1;
|
|
90
|
+
}
|
|
91
|
+
// Boost urgency for current-term (EP10) adopted texts
|
|
92
|
+
if (EP10_ADOPTED_TEXT_PATTERN.test(reference)) {
|
|
93
|
+
urgency += 2;
|
|
94
|
+
}
|
|
95
|
+
// Boost for legislative resolution work types
|
|
96
|
+
if (workType && /legislative.*resolution|position.*first.*reading/i.test(workType)) {
|
|
97
|
+
parliamentary += 1;
|
|
98
|
+
policy += 1;
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
parliamentarySignificance: Math.min(10, parliamentary),
|
|
102
|
+
policyImpact: Math.min(10, policy),
|
|
103
|
+
publicInterest: Math.min(10, publicInterest),
|
|
104
|
+
temporalUrgency: Math.min(10, urgency),
|
|
105
|
+
institutionalRelevance: Math.min(10, institutional),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
43
108
|
/** Analysis method identifier for significance scoring */
|
|
44
109
|
export const METHOD_SIGNIFICANCE_SCORING_ID = 'significance-scoring';
|
|
45
110
|
// ─── Per-method markdown builders ────────────────────────────────────────────
|
|
@@ -290,14 +355,15 @@ export function buildSignificanceScoringMarkdown(fetchedData, date) {
|
|
|
290
355
|
}),
|
|
291
356
|
...adoptedTexts.map((t) => {
|
|
292
357
|
const at = t;
|
|
358
|
+
const title = String(at['title'] ?? at['label'] ?? 'Adopted Text');
|
|
359
|
+
const reference = String(at['id'] ?? '');
|
|
360
|
+
const workType = typeof at['work_type'] === 'string' ? at['work_type'] : undefined;
|
|
361
|
+
const procedureRef = typeof at['procedure_reference'] === 'string' ? at['procedure_reference'] : undefined;
|
|
362
|
+
const scores = scoreAdoptedText(title, reference, workType, procedureRef);
|
|
293
363
|
return {
|
|
294
|
-
title
|
|
295
|
-
reference
|
|
296
|
-
|
|
297
|
-
policyImpact: ADOPTED_POLICY,
|
|
298
|
-
publicInterest: ADOPTED_PUBLIC,
|
|
299
|
-
temporalUrgency: ADOPTED_URGENCY,
|
|
300
|
-
institutionalRelevance: ADOPTED_INSTITUTIONAL,
|
|
364
|
+
title,
|
|
365
|
+
reference,
|
|
366
|
+
...scores,
|
|
301
367
|
};
|
|
302
368
|
}),
|
|
303
369
|
];
|
|
@@ -49,12 +49,42 @@ export function buildDeepAnalysisMarkdown(fetchedData, date) {
|
|
|
49
49
|
adoptedTexts.length +
|
|
50
50
|
questions.length +
|
|
51
51
|
mepUpdates.length;
|
|
52
|
+
// Build a concrete list of the top adopted texts for the AI agent to analyze
|
|
53
|
+
const topAdoptedTexts = adoptedTexts
|
|
54
|
+
.slice(0, 20)
|
|
55
|
+
.map((t) => {
|
|
56
|
+
const adoptedText = t;
|
|
57
|
+
const title = String(adoptedText['title'] ?? adoptedText['label'] ?? 'Untitled');
|
|
58
|
+
const id = String(adoptedText['id'] ?? '');
|
|
59
|
+
const workType = String(adoptedText['work_type'] ?? '');
|
|
60
|
+
const procRef = String(adoptedText['procedure_reference'] ?? '');
|
|
61
|
+
return `| ${sanitizeCell(id)} | ${sanitizeCell(title.slice(0, 100))} | ${sanitizeCell(workType)} | ${sanitizeCell(procRef)} |`;
|
|
62
|
+
})
|
|
63
|
+
.join('\n');
|
|
64
|
+
const topEvents = events
|
|
65
|
+
.slice(0, 10)
|
|
66
|
+
.map((e) => {
|
|
67
|
+
const ev = e;
|
|
68
|
+
const title = String(ev['title'] ?? ev['label'] ?? 'Untitled');
|
|
69
|
+
const id = String(ev['id'] ?? ev['eventId'] ?? '');
|
|
70
|
+
return `| ${sanitizeCell(id)} | ${sanitizeCell(title.slice(0, 120))} |`;
|
|
71
|
+
})
|
|
72
|
+
.join('\n');
|
|
73
|
+
const topProcedures = procedures
|
|
74
|
+
.slice(0, 10)
|
|
75
|
+
.map((p) => {
|
|
76
|
+
const pr = p;
|
|
77
|
+
const title = String(pr['title'] ?? pr['label'] ?? 'Untitled');
|
|
78
|
+
const id = String(pr['procedureId'] ?? pr['id'] ?? '');
|
|
79
|
+
return `| ${sanitizeCell(id)} | ${sanitizeCell(title.slice(0, 120))} |`;
|
|
80
|
+
})
|
|
81
|
+
.join('\n');
|
|
52
82
|
return (header +
|
|
53
83
|
`# Deep Multi-Perspective Analysis
|
|
54
84
|
|
|
55
85
|
## Pipeline Data Context
|
|
56
86
|
|
|
57
|
-
> **Note:** This section contains script-generated data inventory for
|
|
87
|
+
> **Note:** This section contains script-generated data inventory AND concrete document references for the AI agent to analyze. The AI agent must replace everything starting from the "AI Agent Instructions" heading below with substantive political intelligence analysis.
|
|
58
88
|
|
|
59
89
|
| Data Source | Count |
|
|
60
90
|
|-------------|-------|
|
|
@@ -75,13 +105,35 @@ export function buildDeepAnalysisMarkdown(fetchedData, date) {
|
|
|
75
105
|
| Citizens | ${questions.length + mepUpdates.length} (questions + MEP updates) |
|
|
76
106
|
| EU Institutions | ${events.length + procedures.length} (events + procedures) |
|
|
77
107
|
|
|
108
|
+
${adoptedTexts.length > 0
|
|
109
|
+
? `### Key Adopted Texts Available for Analysis
|
|
110
|
+
|
|
111
|
+
| Reference | Title | Work Type | Procedure |
|
|
112
|
+
|-----------|-------|-----------|-----------|
|
|
113
|
+
${topAdoptedTexts}
|
|
114
|
+
`
|
|
115
|
+
: ''}${events.length > 0
|
|
116
|
+
? `### Key Events Available for Analysis
|
|
117
|
+
|
|
118
|
+
| Reference | Title |
|
|
119
|
+
|-----------|-------|
|
|
120
|
+
${topEvents}
|
|
121
|
+
`
|
|
122
|
+
: ''}${procedures.length > 0
|
|
123
|
+
? `### Key Procedures Available for Analysis
|
|
124
|
+
|
|
125
|
+
| Reference | Title |
|
|
126
|
+
|-----------|-------|
|
|
127
|
+
${topProcedures}
|
|
128
|
+
`
|
|
129
|
+
: ''}
|
|
78
130
|
---
|
|
79
131
|
|
|
80
132
|
## AI Agent Instructions
|
|
81
133
|
|
|
82
|
-
> **Instructions for AI Agent
|
|
134
|
+
> **Instructions for AI Agent:** Read ALL methodology documents in analysis/methodologies/ before writing. Using the concrete document references above and the raw EP MCP data, produce a deep multi-perspective analysis following the political-style-guide.md depth Level 3 format. Your analysis MUST:
|
|
83
135
|
>
|
|
84
|
-
> 1. **Identify the 3-5 most politically significant items** from the
|
|
136
|
+
> 1. **Identify the 3-5 most politically significant items** from the document tables above, citing specific document IDs (e.g. TA-10-2026-0092)
|
|
85
137
|
> 2. **Analyse each from ≥3 stakeholder perspectives** (Political Groups, Civil Society, Industry, National Governments, Citizens, EU Institutions)
|
|
86
138
|
> 3. **Apply the SWOT framework** to the overall parliamentary activity pattern for this date
|
|
87
139
|
> 4. **Assess coalition dynamics** — which groups are aligning/diverging based on the adopted texts?
|
|
@@ -73,10 +73,9 @@ export const ALL_ANALYSIS_METHODS = [
|
|
|
73
73
|
// Publication scoring & synthesis
|
|
74
74
|
'significance-scoring',
|
|
75
75
|
'synthesis-summary',
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
// explicitly listing it in `enabledMethods`.
|
|
76
|
+
// Per-document intelligence analysis — stores complete EP document data
|
|
77
|
+
// alongside per-document political intelligence analysis markdown files.
|
|
78
|
+
'document-analysis',
|
|
80
79
|
];
|
|
81
80
|
/**
|
|
82
81
|
* All valid analysis method names, including opt-in methods like
|
|
@@ -12,6 +12,14 @@ import { ArticleCategory } from '../../types/index.js';
|
|
|
12
12
|
import type { LanguageCode, GenerationStats, GenerationResult } from '../../types/index.js';
|
|
13
13
|
import type { ArticleStrategyBase } from '../strategies/article-strategy.js';
|
|
14
14
|
import type { OutputOptions } from './output-stage.js';
|
|
15
|
+
/**
|
|
16
|
+
* Set AI-provided article metadata from CLI flags.
|
|
17
|
+
* Called by the main entry point after parsing `--title=` and `--description=`.
|
|
18
|
+
*
|
|
19
|
+
* @param title - AI-analysed article title
|
|
20
|
+
* @param description - AI-analysed article description
|
|
21
|
+
*/
|
|
22
|
+
export declare function setAIMetadata(title: string, description: string): void;
|
|
15
23
|
/** Map from {@link ArticleCategory} to its registered strategy */
|
|
16
24
|
export type StrategyRegistry = Map<ArticleCategory, ArticleStrategyBase>;
|
|
17
25
|
/**
|
|
@@ -15,6 +15,31 @@ import { monthAheadStrategy } from '../strategies/month-ahead-strategy.js';
|
|
|
15
15
|
import { weeklyReviewStrategy } from '../strategies/weekly-review-strategy.js';
|
|
16
16
|
import { monthlyReviewStrategy } from '../strategies/monthly-review-strategy.js';
|
|
17
17
|
import { writeSingleArticle } from './output-stage.js';
|
|
18
|
+
// ─── AI-provided metadata (set by agentic workflow via CLI flags) ────────────
|
|
19
|
+
/**
|
|
20
|
+
* AI-generated article title provided by the agentic workflow.
|
|
21
|
+
* When non-empty, this OVERRIDES any script-generated title for the
|
|
22
|
+
* English version. The AI agent (Opus 4.6) must analyse the article
|
|
23
|
+
* content and produce this — titles must NEVER be generated by code.
|
|
24
|
+
*/
|
|
25
|
+
let _aiTitle = '';
|
|
26
|
+
/**
|
|
27
|
+
* AI-generated article description provided by the agentic workflow.
|
|
28
|
+
* When non-empty, this OVERRIDES any script-generated description for
|
|
29
|
+
* the English version.
|
|
30
|
+
*/
|
|
31
|
+
let _aiDescription = '';
|
|
32
|
+
/**
|
|
33
|
+
* Set AI-provided article metadata from CLI flags.
|
|
34
|
+
* Called by the main entry point after parsing `--title=` and `--description=`.
|
|
35
|
+
*
|
|
36
|
+
* @param title - AI-analysed article title
|
|
37
|
+
* @param description - AI-analysed article description
|
|
38
|
+
*/
|
|
39
|
+
export function setAIMetadata(title, description) {
|
|
40
|
+
_aiTitle = title;
|
|
41
|
+
_aiDescription = description;
|
|
42
|
+
}
|
|
18
43
|
/**
|
|
19
44
|
* Build the default strategy registry containing all built-in strategies.
|
|
20
45
|
*
|
|
@@ -100,15 +125,21 @@ function generateSingleLanguageArticle(strategy, data, lang, dateStr, slug, outp
|
|
|
100
125
|
const content = strategy.buildContent(data, lang);
|
|
101
126
|
const baseMetadata = strategy.getMetadata(data, lang);
|
|
102
127
|
// Enrich metadata by analysing the actual rendered content.
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
128
|
+
// Keyword extraction (committee abbreviations, political groups) is
|
|
129
|
+
// preserved, but title and description enrichment is now subordinate
|
|
130
|
+
// to AI-provided values from --title and --description CLI flags.
|
|
131
|
+
//
|
|
132
|
+
// Architecture: The AI agent (Opus 4.6) analyses the content and
|
|
133
|
+
// provides titles/descriptions via CLI flags. Script code NEVER
|
|
134
|
+
// generates final titles or descriptions — it only provides fallbacks.
|
|
109
135
|
const enrichedMetadata = enrichMetadataFromContent(content, baseMetadata);
|
|
110
136
|
const metadata = lang === 'en'
|
|
111
|
-
?
|
|
137
|
+
? {
|
|
138
|
+
...enrichedMetadata,
|
|
139
|
+
// AI-provided title/description ALWAYS override script-generated ones
|
|
140
|
+
title: _aiTitle || enrichedMetadata.title,
|
|
141
|
+
subtitle: _aiDescription || enrichedMetadata.subtitle,
|
|
142
|
+
}
|
|
112
143
|
: {
|
|
113
144
|
...baseMetadata,
|
|
114
145
|
keywords: enrichedMetadata.keywords ?? baseMetadata.keywords,
|
|
@@ -10,7 +10,7 @@ import { buildSwotSection } from '../swot-content.js';
|
|
|
10
10
|
import { buildDashboardSection } from '../dashboard-content.js';
|
|
11
11
|
import { buildIntelligenceMindmapSection } from '../mindmap-content.js';
|
|
12
12
|
import { loadAnalysisContext, buildAnalysisInsightsSection, extractAnalysisSummary, } from './article-strategy.js';
|
|
13
|
-
import {
|
|
13
|
+
import { truncateTitle, MIN_MEANINGFUL_TITLE_LENGTH } from '../../utils/metadata-utils.js';
|
|
14
14
|
/** Base keywords shared by all Breaking News articles */
|
|
15
15
|
const BREAKING_NEWS_BASE_KEYWORDS = [
|
|
16
16
|
'European Parliament',
|
|
@@ -58,48 +58,52 @@ function buildBreakingKeywords(feedData) {
|
|
|
58
58
|
function buildBreakingDescription(date, feedData) {
|
|
59
59
|
if (!feedData)
|
|
60
60
|
return `European Parliament breaking developments for ${date}.`;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return
|
|
72
|
-
const highlight = feedData.adoptedTexts[0]?.title ?? feedData.events[0]?.title ?? '';
|
|
73
|
-
const base = `EP breaking: ${counts.join(', ')}`;
|
|
74
|
-
if (highlight) {
|
|
75
|
-
const full = `${base}. Highlights: ${highlight}`;
|
|
76
|
-
return full.length > 200 ? full.slice(0, 197) + '...' : full;
|
|
61
|
+
// Priority 1: Use the title of the most significant adopted text
|
|
62
|
+
const topAdopted = feedData.adoptedTexts.find((t) => t.title && t.title.length > MIN_MEANINGFUL_TITLE_LENGTH);
|
|
63
|
+
if (topAdopted) {
|
|
64
|
+
const desc = `European Parliament adopts ${topAdopted.title}`;
|
|
65
|
+
return desc.length > 200 ? desc.slice(0, 197) + '...' : desc;
|
|
66
|
+
}
|
|
67
|
+
// Priority 2: Use the most significant event title
|
|
68
|
+
const topEvent = feedData.events.find((e) => e.title && e.title.length > MIN_MEANINGFUL_TITLE_LENGTH);
|
|
69
|
+
if (topEvent) {
|
|
70
|
+
const desc = `EP parliamentary event: ${topEvent.title}`;
|
|
71
|
+
return desc.length > 200 ? desc.slice(0, 197) + '...' : desc;
|
|
77
72
|
}
|
|
78
|
-
|
|
73
|
+
// Priority 3: Use the most significant procedure
|
|
74
|
+
const topProc = feedData.procedures.find((p) => p.title && p.title.length > MIN_MEANINGFUL_TITLE_LENGTH);
|
|
75
|
+
if (topProc) {
|
|
76
|
+
const desc = `EP legislative procedure: ${topProc.title}`;
|
|
77
|
+
return desc.length > 200 ? desc.slice(0, 197) + '...' : desc;
|
|
78
|
+
}
|
|
79
|
+
return `European Parliament breaking developments for ${date}.`;
|
|
79
80
|
}
|
|
80
81
|
/**
|
|
81
|
-
* Build a content-aware title suffix from feed
|
|
82
|
+
* Build a content-aware title suffix from the most significant feed item.
|
|
83
|
+
* Uses actual legislation/event titles, not data counts.
|
|
82
84
|
*
|
|
83
85
|
* @param feedData - Breaking news feed data (may be undefined)
|
|
84
|
-
* @returns Short suffix
|
|
86
|
+
* @returns Short analytical suffix, or empty string
|
|
85
87
|
*/
|
|
86
88
|
function buildBreakingTitleSuffix(feedData) {
|
|
87
89
|
if (!feedData)
|
|
88
90
|
return '';
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
91
|
+
// Priority 1: Name the most significant adopted text
|
|
92
|
+
const topAdopted = feedData.adoptedTexts.find((t) => t.title && t.title.length > MIN_MEANINGFUL_TITLE_LENGTH);
|
|
93
|
+
if (topAdopted) {
|
|
94
|
+
return truncateTitle(topAdopted.title);
|
|
95
|
+
}
|
|
96
|
+
// Priority 2: Name the most significant event
|
|
97
|
+
const topEvent = feedData.events.find((e) => e.title && e.title.length > MIN_MEANINGFUL_TITLE_LENGTH);
|
|
98
|
+
if (topEvent) {
|
|
99
|
+
return truncateTitle(topEvent.title);
|
|
100
|
+
}
|
|
101
|
+
// Priority 3: Name the most significant procedure
|
|
102
|
+
const topProc = feedData.procedures.find((p) => p.title && p.title.length > MIN_MEANINGFUL_TITLE_LENGTH);
|
|
103
|
+
if (topProc) {
|
|
104
|
+
return truncateTitle(topProc.title);
|
|
105
|
+
}
|
|
106
|
+
return '';
|
|
103
107
|
}
|
|
104
108
|
/**
|
|
105
109
|
* Extract a substantive summary from an analysis file if available.
|
|
@@ -11,7 +11,7 @@ import { buildSwotSection } from '../swot-content.js';
|
|
|
11
11
|
import { buildDashboardSection } from '../dashboard-content.js';
|
|
12
12
|
import { buildIntelligenceMindmapSection } from '../mindmap-content.js';
|
|
13
13
|
import { loadAnalysisContext, buildAnalysisInsightsSection } from './article-strategy.js';
|
|
14
|
-
import {
|
|
14
|
+
import { truncateTitle, MIN_MEANINGFUL_TITLE_LENGTH } from '../../utils/metadata-utils.js';
|
|
15
15
|
/** European Parliament home-page URL used as source reference */
|
|
16
16
|
const EP_SOURCE_URL = 'https://www.europarl.europa.eu';
|
|
17
17
|
/** European Parliament display name for source titles and article lede */
|
|
@@ -63,48 +63,50 @@ function buildCommitteeKeywords(committeeDataList, feedData) {
|
|
|
63
63
|
* @returns SEO-friendly description (≤ 200 chars)
|
|
64
64
|
*/
|
|
65
65
|
function buildCommitteeDescription(committeeDataList, feedData) {
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
66
|
+
// Priority 1: Use the title of the most significant adopted text
|
|
67
|
+
const topAdopted = feedData?.adoptedTexts?.find((t) => t.title && t.title.length > MIN_MEANINGFUL_TITLE_LENGTH);
|
|
68
|
+
if (topAdopted) {
|
|
69
|
+
const abbrs = committeeDataList
|
|
70
|
+
.filter((c) => c.chair !== PLACEHOLDER_CHAIR)
|
|
71
|
+
.map((c) => c.abbreviation)
|
|
72
|
+
.join(', ');
|
|
73
|
+
const desc = abbrs
|
|
74
|
+
? `EP committees ${abbrs}: ${topAdopted.title}`
|
|
75
|
+
: `EP committee report: ${topAdopted.title}`;
|
|
76
|
+
return desc.length > 200 ? desc.slice(0, 197) + '...' : desc;
|
|
77
|
+
}
|
|
78
|
+
// Priority 2: Name the active committees
|
|
76
79
|
const abbrs = committeeDataList
|
|
77
80
|
.filter((c) => c.chair !== PLACEHOLDER_CHAIR)
|
|
78
81
|
.map((c) => c.abbreviation)
|
|
79
82
|
.join(', ');
|
|
80
|
-
if (abbrs)
|
|
81
|
-
|
|
82
|
-
if (parts.length === 0) {
|
|
83
|
-
return 'Analysis of recent legislative output, effectiveness metrics, and key committee activities';
|
|
83
|
+
if (abbrs) {
|
|
84
|
+
return `European Parliament committee activity report covering ${abbrs}`;
|
|
84
85
|
}
|
|
85
|
-
|
|
86
|
-
return desc.length > 200 ? desc.slice(0, 197) + '...' : desc;
|
|
86
|
+
return 'Analysis of recent legislative output, effectiveness metrics, and key committee activities';
|
|
87
87
|
}
|
|
88
88
|
/**
|
|
89
|
-
* Build a content-aware title suffix from
|
|
89
|
+
* Build a content-aware title suffix from the most significant
|
|
90
|
+
* committee item. Uses actual legislation titles, not data counts.
|
|
90
91
|
*
|
|
91
92
|
* @param committeeDataList - Fetched committee data
|
|
92
93
|
* @param feedData - EP feed data (may be undefined)
|
|
93
|
-
* @returns Short suffix
|
|
94
|
+
* @returns Short analytical suffix, or empty string
|
|
94
95
|
*/
|
|
95
96
|
function buildCommitteeTitleSuffix(committeeDataList, feedData) {
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
97
|
+
// Priority 1: Name the most significant adopted text
|
|
98
|
+
const topAdopted = feedData?.adoptedTexts?.find((t) => t.title && t.title.length > MIN_MEANINGFUL_TITLE_LENGTH);
|
|
99
|
+
if (topAdopted) {
|
|
100
|
+
return truncateTitle(topAdopted.title);
|
|
101
|
+
}
|
|
102
|
+
// Priority 2: List active committee abbreviations
|
|
103
|
+
const activeAbbrs = committeeDataList
|
|
104
|
+
.filter((c) => c.chair !== PLACEHOLDER_CHAIR)
|
|
105
|
+
.map((c) => c.abbreviation);
|
|
106
|
+
if (activeAbbrs.length > 0) {
|
|
107
|
+
return activeAbbrs.slice(0, 5).join(', ');
|
|
106
108
|
}
|
|
107
|
-
return
|
|
109
|
+
return '';
|
|
108
110
|
}
|
|
109
111
|
// Keyword lists are pre-normalized to lowercase so that each call to
|
|
110
112
|
// categorizeAdoptedText only needs to lowercase the title once.
|
|
@@ -10,7 +10,7 @@ import { buildSwotSection } from '../swot-content.js';
|
|
|
10
10
|
import { buildDashboardSection } from '../dashboard-content.js';
|
|
11
11
|
import { buildIntelligenceMindmapSection } from '../mindmap-content.js';
|
|
12
12
|
import { loadAnalysisContext, buildAnalysisInsightsSection } from './article-strategy.js';
|
|
13
|
-
import { pl } from '../../utils/metadata-utils.js';
|
|
13
|
+
import { pl, truncateTitle, MIN_MEANINGFUL_TITLE_LENGTH } from '../../utils/metadata-utils.js';
|
|
14
14
|
import { isPlaceholderText } from '../../constants/analysis-constants.js';
|
|
15
15
|
/** Base keywords shared by all Motions articles */
|
|
16
16
|
const MOTIONS_BASE_KEYWORDS = [
|
|
@@ -58,52 +58,52 @@ function buildMotionsKeywords(data) {
|
|
|
58
58
|
}
|
|
59
59
|
/**
|
|
60
60
|
* Build a content-aware description from motions data.
|
|
61
|
-
*
|
|
61
|
+
* Prioritises the most significant adopted text or voting record title
|
|
62
|
+
* to produce a description that reflects political substance rather
|
|
63
|
+
* than mechanical data counts.
|
|
62
64
|
*
|
|
63
65
|
* @param data - Motions article data payload
|
|
64
66
|
* @returns SEO-friendly description (≤ 200 chars)
|
|
65
67
|
*/
|
|
66
68
|
function buildMotionsDescription(data) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (data.questions.length > 0)
|
|
73
|
-
parts.push(pl(data.questions.length, 'parliamentary question', 'parliamentary questions'));
|
|
74
|
-
const adoptedCount = data.feedData?.adoptedTexts?.length ?? 0;
|
|
75
|
-
if (adoptedCount > 0)
|
|
76
|
-
parts.push(pl(adoptedCount, 'adopted text', 'adopted texts'));
|
|
77
|
-
if (parts.length === 0) {
|
|
78
|
-
return `European Parliament plenary votes and resolutions from ${data.dateFromStr} to ${data.date}.`;
|
|
69
|
+
// Priority 1: Use the title of the most significant adopted text
|
|
70
|
+
const topAdopted = data.feedData?.adoptedTexts?.find((t) => t.title && !isPlaceholderText(t.title));
|
|
71
|
+
if (topAdopted) {
|
|
72
|
+
const desc = `European Parliament adopts ${topAdopted.title}`;
|
|
73
|
+
return desc.length > 200 ? desc.slice(0, 197) + '...' : desc;
|
|
79
74
|
}
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
83
|
-
const
|
|
84
|
-
return
|
|
75
|
+
// Priority 2: Use the title of the key voting record
|
|
76
|
+
const topVote = data.votingRecords.find((v) => v.title);
|
|
77
|
+
if (topVote) {
|
|
78
|
+
const desc = `EP plenary vote: ${topVote.title}`;
|
|
79
|
+
return desc.length > 200 ? desc.slice(0, 197) + '...' : desc;
|
|
85
80
|
}
|
|
86
|
-
return
|
|
81
|
+
return `European Parliament plenary votes and resolutions from ${data.dateFromStr} to ${data.date}.`;
|
|
87
82
|
}
|
|
88
83
|
/**
|
|
89
|
-
* Build a content-aware title suffix from
|
|
84
|
+
* Build a content-aware title suffix from the most significant
|
|
85
|
+
* motions item. Produces an analytical phrase describing the
|
|
86
|
+
* primary political content, not data counts.
|
|
90
87
|
*
|
|
91
88
|
* @param data - Motions article data payload
|
|
92
|
-
* @returns Short suffix for the title, or empty string
|
|
89
|
+
* @returns Short analytical suffix for the title, or empty string
|
|
93
90
|
*/
|
|
94
91
|
function buildMotionsTitleSuffix(data) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
92
|
+
// Priority 1: Name the most significant adopted text
|
|
93
|
+
const topAdopted = data.feedData?.adoptedTexts?.find((t) => t.title && !isPlaceholderText(t.title) && t.title.length > MIN_MEANINGFUL_TITLE_LENGTH);
|
|
94
|
+
if (topAdopted) {
|
|
95
|
+
return truncateTitle(topAdopted.title);
|
|
98
96
|
}
|
|
99
|
-
|
|
100
|
-
|
|
97
|
+
// Priority 2: Name the key voting record
|
|
98
|
+
const topVote = data.votingRecords.find((v) => v.title && v.title.length > MIN_MEANINGFUL_TITLE_LENGTH);
|
|
99
|
+
if (topVote) {
|
|
100
|
+
return truncateTitle(topVote.title);
|
|
101
101
|
}
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
102
|
+
// Priority 3 (last resort): If we only have anomalies, mention those
|
|
103
|
+
if (data.anomalies.length > 0) {
|
|
104
|
+
return `${pl(data.anomalies.length, 'Voting Anomaly', 'Voting Anomalies')} Detected`;
|
|
105
105
|
}
|
|
106
|
-
return
|
|
106
|
+
return '';
|
|
107
107
|
}
|
|
108
108
|
/** Number of days to look back when fetching motions data */
|
|
109
109
|
const MOTIONS_LOOKBACK_DAYS = 30;
|
|
@@ -11,7 +11,7 @@ import { buildSwotSection } from '../swot-content.js';
|
|
|
11
11
|
import { buildDashboardSection } from '../dashboard-content.js';
|
|
12
12
|
import { buildIntelligenceMindmapSection } from '../mindmap-content.js';
|
|
13
13
|
import { loadAnalysisContext, buildAnalysisInsightsSection } from './article-strategy.js';
|
|
14
|
-
import {
|
|
14
|
+
import { truncateTitle, MIN_MEANINGFUL_TITLE_LENGTH } from '../../utils/metadata-utils.js';
|
|
15
15
|
/** Base keywords shared by all Propositions articles */
|
|
16
16
|
const PROPOSITIONS_BASE_KEYWORDS = [
|
|
17
17
|
'European Parliament',
|
|
@@ -58,47 +58,39 @@ function buildPropositionsKeywords(data) {
|
|
|
58
58
|
* @returns SEO-friendly description (≤ 200 chars)
|
|
59
59
|
*/
|
|
60
60
|
function buildPropositionsDescription(data) {
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const proposalCount = proposalMatches ? proposalMatches.length : 0;
|
|
67
|
-
if (proposalCount > 0)
|
|
68
|
-
parts.push(`${proposalCount} active proposals`);
|
|
69
|
-
if (procCount > 0)
|
|
70
|
-
parts.push(`${procCount} procedures tracked`);
|
|
71
|
-
if (adoptedCount > 0)
|
|
72
|
-
parts.push(`${adoptedCount} recently adopted texts`);
|
|
73
|
-
if (data.pipelineData) {
|
|
74
|
-
const healthPct = Math.round(data.pipelineData.healthScore * 100);
|
|
75
|
-
parts.push(`pipeline health ${healthPct}%`);
|
|
61
|
+
// Priority 1: Use the title of the most significant adopted text
|
|
62
|
+
const topAdopted = data.feedData?.adoptedTexts?.find((t) => t.title && t.title.length > MIN_MEANINGFUL_TITLE_LENGTH);
|
|
63
|
+
if (topAdopted) {
|
|
64
|
+
const desc = `European Parliament legislative tracker: ${topAdopted.title}`;
|
|
65
|
+
return desc.length > 200 ? desc.slice(0, 197) + '...' : desc;
|
|
76
66
|
}
|
|
77
|
-
|
|
78
|
-
|
|
67
|
+
// Priority 2: Use the title of the most significant procedure
|
|
68
|
+
const topProc = data.feedData?.procedures?.find((p) => p.title && p.title.length > MIN_MEANINGFUL_TITLE_LENGTH);
|
|
69
|
+
if (topProc) {
|
|
70
|
+
const desc = `EP legislative procedure: ${topProc.title}`;
|
|
71
|
+
return desc.length > 200 ? desc.slice(0, 197) + '...' : desc;
|
|
79
72
|
}
|
|
80
|
-
|
|
81
|
-
return desc.length > 200 ? desc.slice(0, 197) + '...' : desc;
|
|
73
|
+
return 'Recent legislative proposals, procedure tracking, and pipeline status in the European Parliament';
|
|
82
74
|
}
|
|
83
75
|
/**
|
|
84
|
-
* Build a content-aware title suffix from
|
|
76
|
+
* Build a content-aware title suffix from the most significant
|
|
77
|
+
* propositions item. Uses actual procedure/text titles, not counts.
|
|
85
78
|
*
|
|
86
79
|
* @param data - Propositions article data payload
|
|
87
|
-
* @returns Short suffix for the title, or empty string
|
|
80
|
+
* @returns Short analytical suffix for the title, or empty string
|
|
88
81
|
*/
|
|
89
82
|
function buildPropositionsTitleSuffix(data) {
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
parts.push(`Pipeline ${healthPct}%`);
|
|
83
|
+
// Priority 1: Name the most significant adopted text
|
|
84
|
+
const topAdopted = data.feedData?.adoptedTexts?.find((t) => t.title && t.title.length > MIN_MEANINGFUL_TITLE_LENGTH);
|
|
85
|
+
if (topAdopted) {
|
|
86
|
+
return truncateTitle(topAdopted.title);
|
|
87
|
+
}
|
|
88
|
+
// Priority 2: Name the most significant procedure
|
|
89
|
+
const topProc = data.feedData?.procedures?.find((p) => p.title && p.title.length > MIN_MEANINGFUL_TITLE_LENGTH);
|
|
90
|
+
if (topProc) {
|
|
91
|
+
return truncateTitle(topProc.title);
|
|
100
92
|
}
|
|
101
|
-
return
|
|
93
|
+
return '';
|
|
102
94
|
}
|
|
103
95
|
/**
|
|
104
96
|
* Build procedures and adopted-texts HTML separately from EP feed data when
|
|
@@ -26,6 +26,40 @@ const STATS_FALLBACK = '{"stats": null}';
|
|
|
26
26
|
const PROCEDURE_EVENT_FALLBACK = '{"event": null}';
|
|
27
27
|
/** Fallback payload for server health status */
|
|
28
28
|
const SERVER_HEALTH_FALLBACK = '{"server": null, "feeds": []}';
|
|
29
|
+
/**
|
|
30
|
+
* Classify an error message into a diagnostic error category, aligned with
|
|
31
|
+
* EP MCP Server v1.2.1 standardized error categories.
|
|
32
|
+
*
|
|
33
|
+
* Priority:
|
|
34
|
+
* 1. Gateway 5xx → SERVER_ERROR (not TIMEOUT, even for 504 "Gateway Timeout")
|
|
35
|
+
* 2. 429 / rate-limit → RATE_LIMIT
|
|
36
|
+
* 3. 404 → NOT_FOUND
|
|
37
|
+
* 4. Client-side timeout → TIMEOUT
|
|
38
|
+
* 5. Everything else → UNKNOWN
|
|
39
|
+
*
|
|
40
|
+
* @param message - Raw error message
|
|
41
|
+
* @returns Diagnostic error category string
|
|
42
|
+
*/
|
|
43
|
+
function classifyToolError(message) {
|
|
44
|
+
const lowerMsg = message.toLowerCase();
|
|
45
|
+
if (lowerMsg.includes('gateway timeout') ||
|
|
46
|
+
lowerMsg.includes('gateway error 500') ||
|
|
47
|
+
lowerMsg.includes('gateway error 502') ||
|
|
48
|
+
lowerMsg.includes('gateway error 503') ||
|
|
49
|
+
lowerMsg.includes('gateway error 504')) {
|
|
50
|
+
return 'SERVER_ERROR';
|
|
51
|
+
}
|
|
52
|
+
if (lowerMsg.includes('429') ||
|
|
53
|
+
lowerMsg.includes('rate limit') ||
|
|
54
|
+
lowerMsg.includes('too many requests')) {
|
|
55
|
+
return 'RATE_LIMIT';
|
|
56
|
+
}
|
|
57
|
+
if (lowerMsg.includes('404'))
|
|
58
|
+
return 'NOT_FOUND';
|
|
59
|
+
if (lowerMsg.includes('timeout'))
|
|
60
|
+
return 'TIMEOUT';
|
|
61
|
+
return 'UNKNOWN';
|
|
62
|
+
}
|
|
29
63
|
/**
|
|
30
64
|
* MCP Client for European Parliament data access.
|
|
31
65
|
* Extends {@link MCPConnection} with EP-specific tool wrapper methods.
|
|
@@ -63,22 +97,7 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
63
97
|
}
|
|
64
98
|
catch (error) {
|
|
65
99
|
const message = error instanceof Error ? error.message : String(error);
|
|
66
|
-
const
|
|
67
|
-
// Classify the error for better diagnostics.
|
|
68
|
-
// Check gateway 5xx first — a "504 Gateway Timeout" should be SERVER_ERROR,
|
|
69
|
-
// not TIMEOUT (which is reserved for client-side request timeouts).
|
|
70
|
-
const isGatewayServerError = lowerMsg.includes('gateway timeout') ||
|
|
71
|
-
lowerMsg.includes('gateway error 500') ||
|
|
72
|
-
lowerMsg.includes('gateway error 502') ||
|
|
73
|
-
lowerMsg.includes('gateway error 503') ||
|
|
74
|
-
lowerMsg.includes('gateway error 504');
|
|
75
|
-
const errorType = isGatewayServerError
|
|
76
|
-
? 'SERVER_ERROR'
|
|
77
|
-
: lowerMsg.includes('404')
|
|
78
|
-
? 'NOT_FOUND'
|
|
79
|
-
: lowerMsg.includes('timeout')
|
|
80
|
-
? 'TIMEOUT'
|
|
81
|
-
: 'UNKNOWN';
|
|
100
|
+
const errorType = classifyToolError(message);
|
|
82
101
|
this._failedTools.set(toolName, `${errorType}: ${message}`);
|
|
83
102
|
console.warn(`⚠️ ${toolName} failed [${errorType}]:`, message);
|
|
84
103
|
return { content: [{ type: 'text', text: fallbackText }] };
|
|
@@ -161,6 +161,21 @@ export function generateArticleHTML(options) {
|
|
|
161
161
|
url: SITE_BASE_URL,
|
|
162
162
|
},
|
|
163
163
|
keywords: keywords.join(', '),
|
|
164
|
+
about: {
|
|
165
|
+
'@type': 'GovernmentOrganization',
|
|
166
|
+
name: 'European Parliament',
|
|
167
|
+
url: 'https://www.europarl.europa.eu',
|
|
168
|
+
},
|
|
169
|
+
isBasedOn: sources.length > 0
|
|
170
|
+
? sources
|
|
171
|
+
.filter((s) => typeof s.url === 'string' && /^https?:\/\//i.test(s.url))
|
|
172
|
+
.slice(0, 5)
|
|
173
|
+
.map((s) => ({
|
|
174
|
+
'@type': 'Dataset',
|
|
175
|
+
name: s.title,
|
|
176
|
+
url: s.url,
|
|
177
|
+
}))
|
|
178
|
+
: undefined,
|
|
164
179
|
mainEntityOfPage: {
|
|
165
180
|
'@type': 'WebPage',
|
|
166
181
|
'@id': `${SITE_BASE_URL}/news/${date}-${slug}-${lang}.html`,
|
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
/** Maximum length for the enriched description */
|
|
4
4
|
const MAX_DESCRIPTION_LENGTH = 200;
|
|
5
|
+
/**
|
|
6
|
+
* Minimum position (as fraction of MAX_DESCRIPTION_LENGTH) for a
|
|
7
|
+
* sentence-boundary truncation point. If the last sentence break
|
|
8
|
+
* is before this threshold, we fall back to hard truncation with `...`.
|
|
9
|
+
* This ensures the truncated description retains at least half its
|
|
10
|
+
* intended content.
|
|
11
|
+
*/
|
|
12
|
+
const MIN_SENTENCE_TRUNCATION_RATIO = 0.5;
|
|
5
13
|
/** Maximum number of keywords to emit */
|
|
6
14
|
const MAX_KEYWORDS = 15;
|
|
7
15
|
/** Minimum heading length to include as keyword */
|
|
@@ -174,8 +182,26 @@ function extractContentKeywords(content, baseKeywords) {
|
|
|
174
182
|
return [...new Set(keywords)].slice(0, MAX_KEYWORDS);
|
|
175
183
|
}
|
|
176
184
|
/**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
185
|
+
* Patterns that indicate a heading is a generic section label (not
|
|
186
|
+
* analytical content suitable for a title suffix).
|
|
187
|
+
*/
|
|
188
|
+
const GENERIC_HEADING_PATTERN = /^(introduction|overview|analysis|conclusion|summary|background|context|key\s+findings|methodology|data\s+sources|voting\s+records|parliamentary\s+questions|about|feed\s+health|analysis\s+pipeline|analysis\s+&\s+transparency|stakeholder|dashboard|pipeline\s+snapshot|political\s+intelligence|further\s+reading|related|appendix|table\s+of\s+contents|deep\s+analysis)/iu;
|
|
189
|
+
/**
|
|
190
|
+
* Patterns indicating a heading contains analytical/political content
|
|
191
|
+
* (e.g., specific legislation names, political dynamics, policy topics).
|
|
192
|
+
*/
|
|
193
|
+
const ANALYTICAL_HEADING_PATTERN = /(?:directive|regulation|resolution|reform|crisis|alliance|coalition|division|bloc|breakthrough|deadlock|amendment|trilogue|committee|parliament|council|commission|veto|mandate|sovereignty|trade|climate|digital|security|defense|defence|budget|migration|energy|sanctions|treaty|accession|withdrawal|election|referendum|impeach|censure|confidence|no.confidence)/iu;
|
|
194
|
+
/**
|
|
195
|
+
* Build a content-aware title by extracting the most politically
|
|
196
|
+
* significant heading or analytical finding from the article body.
|
|
197
|
+
*
|
|
198
|
+
* **Priority order** (per ai-driven-analysis-guide Rule 9):
|
|
199
|
+
* 1. Analytical headings containing political/legislative substance
|
|
200
|
+
* 2. Non-generic section headings with meaningful length
|
|
201
|
+
* 3. Data statistics as a last resort only
|
|
202
|
+
*
|
|
203
|
+
* This ensures titles reflect AI-analysed political intelligence
|
|
204
|
+
* rather than mechanical data counts like "5 Votes, 2 Anomalies".
|
|
179
205
|
*
|
|
180
206
|
* @param content - Article HTML body
|
|
181
207
|
* @param baseTitle - Localized base title from the strategy
|
|
@@ -186,26 +212,35 @@ function buildContentTitle(content, baseTitle) {
|
|
|
186
212
|
if (baseTitle.includes('—'))
|
|
187
213
|
return baseTitle;
|
|
188
214
|
const headings = extractHeadings(content);
|
|
215
|
+
// Priority 1: Find a heading with real political/legislative substance
|
|
216
|
+
const analyticalHeading = headings.find((h) => h.length > 12 &&
|
|
217
|
+
h.length <= 80 &&
|
|
218
|
+
ANALYTICAL_HEADING_PATTERN.test(h) &&
|
|
219
|
+
!GENERIC_HEADING_PATTERN.test(h));
|
|
220
|
+
if (analyticalHeading) {
|
|
221
|
+
return `${baseTitle} — ${analyticalHeading}`;
|
|
222
|
+
}
|
|
223
|
+
// Priority 2: Find any non-generic heading with meaningful length
|
|
224
|
+
const topHeading = headings.find((h) => h.length > 12 && h.length <= 80 && !GENERIC_HEADING_PATTERN.test(h));
|
|
225
|
+
if (topHeading) {
|
|
226
|
+
return `${baseTitle} — ${topHeading}`;
|
|
227
|
+
}
|
|
228
|
+
// Priority 3 (last resort): Use a key statistic — but only when no
|
|
229
|
+
// analytical heading is available
|
|
189
230
|
const stats = extractStatistics(content);
|
|
190
|
-
// Build a suffix from the first meaningful statistic
|
|
191
231
|
const topStat = stats[0];
|
|
192
|
-
// Build a suffix from the first heading that isn't a generic section label
|
|
193
|
-
const topHeading = headings.find((h) => h.length > 10 &&
|
|
194
|
-
!/^(introduction|overview|analysis|conclusion|summary|background|context)/iu.test(h));
|
|
195
|
-
if (topStat && topHeading) {
|
|
196
|
-
return `${baseTitle} — ${topStat}, ${topHeading}`;
|
|
197
|
-
}
|
|
198
232
|
if (topStat) {
|
|
199
233
|
return `${baseTitle} — ${topStat}`;
|
|
200
234
|
}
|
|
201
|
-
if (topHeading) {
|
|
202
|
-
return `${baseTitle} — ${topHeading}`;
|
|
203
|
-
}
|
|
204
235
|
return baseTitle;
|
|
205
236
|
}
|
|
206
237
|
/**
|
|
207
|
-
* Build a content-aware description by extracting the lede
|
|
208
|
-
* from the article body.
|
|
238
|
+
* Build a content-aware description by extracting the AI-written lede
|
|
239
|
+
* paragraph from the article body. The lede should contain the
|
|
240
|
+
* political significance of the article content — not data counts.
|
|
241
|
+
*
|
|
242
|
+
* Falls back to the strategy-provided subtitle only when no
|
|
243
|
+
* substantive lede paragraph is found.
|
|
209
244
|
*
|
|
210
245
|
* @param content - Article HTML body
|
|
211
246
|
* @param baseSubtitle - Subtitle from the strategy as fallback
|
|
@@ -214,9 +249,17 @@ function buildContentTitle(content, baseTitle) {
|
|
|
214
249
|
function buildContentDescription(content, baseSubtitle) {
|
|
215
250
|
const lede = extractLede(content);
|
|
216
251
|
if (lede.length > 30) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
252
|
+
// Truncate at sentence boundary when possible for clean SEO descriptions
|
|
253
|
+
if (lede.length > MAX_DESCRIPTION_LENGTH) {
|
|
254
|
+
const truncated = lede.slice(0, MAX_DESCRIPTION_LENGTH - 3);
|
|
255
|
+
// Find the last sentence boundary (period, exclamation, or question mark followed by space)
|
|
256
|
+
const lastSentence = Math.max(truncated.lastIndexOf('. '), truncated.lastIndexOf('! '), truncated.lastIndexOf('? '));
|
|
257
|
+
if (lastSentence > MAX_DESCRIPTION_LENGTH * MIN_SENTENCE_TRUNCATION_RATIO) {
|
|
258
|
+
return truncated.slice(0, lastSentence + 1);
|
|
259
|
+
}
|
|
260
|
+
return truncated + '...';
|
|
261
|
+
}
|
|
262
|
+
return lede;
|
|
220
263
|
}
|
|
221
264
|
return baseSubtitle;
|
|
222
265
|
}
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
* @description Shared helpers for article metadata generation.
|
|
4
|
-
*/
|
|
1
|
+
/** Minimum title length to be considered meaningful (not placeholder) */
|
|
2
|
+
export declare const MIN_MEANINGFUL_TITLE_LENGTH = 10;
|
|
5
3
|
/**
|
|
6
4
|
* Return singular or plural form based on count.
|
|
7
5
|
*
|
|
@@ -11,4 +9,13 @@
|
|
|
11
9
|
* @returns `"N singular"` or `"N plural"`
|
|
12
10
|
*/
|
|
13
11
|
export declare function pl(n: number, singular: string, plural: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Truncate a title string to the suffix length limit with ellipsis.
|
|
14
|
+
* Used by all strategy title suffix builders for consistent truncation.
|
|
15
|
+
*
|
|
16
|
+
* @param title - Title string to truncate
|
|
17
|
+
* @param maxLength - Maximum length (default: {@link MAX_SUFFIX_LENGTH})
|
|
18
|
+
* @returns Truncated title with `...` suffix if over limit, else unchanged
|
|
19
|
+
*/
|
|
20
|
+
export declare function truncateTitle(title: string, maxLength?: number): string;
|
|
14
21
|
//# sourceMappingURL=metadata-utils.d.ts.map
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
* @module Utils/MetadataUtils
|
|
5
5
|
* @description Shared helpers for article metadata generation.
|
|
6
6
|
*/
|
|
7
|
+
/** Maximum length for a title suffix before truncation */
|
|
8
|
+
const MAX_SUFFIX_LENGTH = 60;
|
|
9
|
+
/** Minimum title length to be considered meaningful (not placeholder) */
|
|
10
|
+
export const MIN_MEANINGFUL_TITLE_LENGTH = 10;
|
|
7
11
|
/**
|
|
8
12
|
* Return singular or plural form based on count.
|
|
9
13
|
*
|
|
@@ -15,4 +19,17 @@
|
|
|
15
19
|
export function pl(n, singular, plural) {
|
|
16
20
|
return `${n} ${n === 1 ? singular : plural}`;
|
|
17
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Truncate a title string to the suffix length limit with ellipsis.
|
|
24
|
+
* Used by all strategy title suffix builders for consistent truncation.
|
|
25
|
+
*
|
|
26
|
+
* @param title - Title string to truncate
|
|
27
|
+
* @param maxLength - Maximum length (default: {@link MAX_SUFFIX_LENGTH})
|
|
28
|
+
* @returns Truncated title with `...` suffix if over limit, else unchanged
|
|
29
|
+
*/
|
|
30
|
+
export function truncateTitle(title, maxLength = MAX_SUFFIX_LENGTH) {
|
|
31
|
+
if (title.length <= maxLength)
|
|
32
|
+
return title;
|
|
33
|
+
return title.slice(0, maxLength - 3) + '...';
|
|
34
|
+
}
|
|
18
35
|
//# sourceMappingURL=metadata-utils.js.map
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
*
|
|
18
18
|
* | Composite | Decision |
|
|
19
19
|
* |-----------|----------|
|
|
20
|
-
* | 0.0 – 3.
|
|
21
|
-
* |
|
|
22
|
-
* | ≥
|
|
20
|
+
* | 0.0 – 3.4 | skip |
|
|
21
|
+
* | 3.5 – 5.4 | hold |
|
|
22
|
+
* | ≥ 5.5 | publish |
|
|
23
23
|
*
|
|
24
24
|
* @see analysis/templates/significance-scoring.md
|
|
25
25
|
*/
|
|
@@ -35,9 +35,9 @@ export declare const WEIGHT_URGENCY = 0.15;
|
|
|
35
35
|
/** Weight applied to Institutional / Cross-Group Relevance dimension */
|
|
36
36
|
export declare const WEIGHT_INSTITUTIONAL = 0.15;
|
|
37
37
|
/** Composite score at or above which the decision is "publish" */
|
|
38
|
-
export declare const THRESHOLD_PUBLISH =
|
|
38
|
+
export declare const THRESHOLD_PUBLISH = 5.5;
|
|
39
39
|
/** Composite score at or above which the decision is "hold" (below publish) */
|
|
40
|
-
export declare const THRESHOLD_HOLD =
|
|
40
|
+
export declare const THRESHOLD_HOLD = 3.5;
|
|
41
41
|
/**
|
|
42
42
|
* Clamp a numeric value to the 0–10 scoring range.
|
|
43
43
|
*
|
|
@@ -49,9 +49,9 @@ const SCORE_MIN = 0;
|
|
|
49
49
|
/** Maximum score ceiling (dimension and composite) */
|
|
50
50
|
const SCORE_MAX = 10;
|
|
51
51
|
/** Composite score at or above which the decision is "publish" */
|
|
52
|
-
export const THRESHOLD_PUBLISH =
|
|
52
|
+
export const THRESHOLD_PUBLISH = 5.5;
|
|
53
53
|
/** Composite score at or above which the decision is "hold" (below publish) */
|
|
54
|
-
export const THRESHOLD_HOLD =
|
|
54
|
+
export const THRESHOLD_HOLD = 3.5;
|
|
55
55
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
56
56
|
/**
|
|
57
57
|
* Clamp a numeric value to the 0–10 scoring range.
|