euparliamentmonitor 0.9.1 → 0.9.2
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/README.md +2 -2
- package/package.json +4 -4
- package/scripts/aggregator/article-generator.d.ts +18 -0
- package/scripts/aggregator/article-generator.js +65 -6
- package/scripts/aggregator/article-html.js +23 -6
- package/scripts/aggregator/article-metadata.d.ts +75 -0
- package/scripts/aggregator/article-metadata.js +482 -32
- package/scripts/config/article-horizons.js +17 -2
- package/scripts/generators/news-indexes.js +3 -1
- package/scripts/mcp/ep-mcp-client.d.ts +6 -6
- package/scripts/mcp/ep-mcp-client.js +8 -8
- package/scripts/templates/section-builders.d.ts +2 -3
- package/scripts/templates/section-builders.js +15 -12
- package/scripts/types/mcp.d.ts +1 -1
package/README.md
CHANGED
|
@@ -136,7 +136,7 @@ The published site is the audience-facing companion to this npm/TypeScript packa
|
|
|
136
136
|
|
|
137
137
|
**MCP Server Integration**: The project uses the
|
|
138
138
|
[European-Parliament-MCP-Server](https://github.com/Hack23/European-Parliament-MCP-Server)
|
|
139
|
-
v1.3.
|
|
139
|
+
v1.3.2 for accessing real EU Parliament data via the Model Context Protocol.
|
|
140
140
|
|
|
141
141
|
- **MCP Server Status**: ✅ Fully operational — 60+ EP data tools available
|
|
142
142
|
(feeds, direct lookups, analytical tools, intelligence correlation)
|
|
@@ -432,7 +432,7 @@ import type { ArticleCategory, LanguageCode } from 'euparliamentmonitor/types';
|
|
|
432
432
|
|
|
433
433
|
## 🔌 Data Sources
|
|
434
434
|
|
|
435
|
-
**Primary — European Parliament MCP Server** ([Hack23/European-Parliament-MCP-Server](https://github.com/Hack23/European-Parliament-MCP-Server) v1.3.
|
|
435
|
+
**Primary — European Parliament MCP Server** ([Hack23/European-Parliament-MCP-Server](https://github.com/Hack23/European-Parliament-MCP-Server) v1.3.2+, fully operational):
|
|
436
436
|
|
|
437
437
|
- 🗳️ Plenary sessions, voting records, roll-call votes
|
|
438
438
|
- 📜 Adopted texts, motions, resolutions, urgency files
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "euparliamentmonitor",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "European Parliament Intelligence Platform - Monitor political activity with systematic transparency",
|
|
6
6
|
"main": "scripts/index.js",
|
|
@@ -146,7 +146,7 @@
|
|
|
146
146
|
"@playwright/test": "1.59.1",
|
|
147
147
|
"@types/d3": "7.4.3",
|
|
148
148
|
"@types/markdown-it": "^14.1.2",
|
|
149
|
-
"@types/node": "25.6.
|
|
149
|
+
"@types/node": "25.6.2",
|
|
150
150
|
"@types/papaparse": "5.5.2",
|
|
151
151
|
"@typescript-eslint/eslint-plugin": "8.59.2",
|
|
152
152
|
"@typescript-eslint/parser": "8.59.2",
|
|
@@ -165,7 +165,7 @@
|
|
|
165
165
|
"husky": "9.1.7",
|
|
166
166
|
"jscpd": "4.0.9",
|
|
167
167
|
"knip": "^6.7.0",
|
|
168
|
-
"lint-staged": "17.0.
|
|
168
|
+
"lint-staged": "17.0.3",
|
|
169
169
|
"mermaid": "11.14.0",
|
|
170
170
|
"papaparse": "5.5.3",
|
|
171
171
|
"prettier": "3.8.3",
|
|
@@ -179,7 +179,7 @@
|
|
|
179
179
|
"node": ">=26"
|
|
180
180
|
},
|
|
181
181
|
"dependencies": {
|
|
182
|
-
"european-parliament-mcp-server": "1.3.
|
|
182
|
+
"european-parliament-mcp-server": "1.3.2",
|
|
183
183
|
"markdown-it": "^14.1.1",
|
|
184
184
|
"markdown-it-anchor": "^9.2.0",
|
|
185
185
|
"markdown-it-attrs": "^4.3.1",
|
|
@@ -100,6 +100,24 @@ export declare function sanitizeRunSuffix(runId: string): string;
|
|
|
100
100
|
* @returns Plain-text description, truncated to ≤300 characters
|
|
101
101
|
*/
|
|
102
102
|
export declare function extractDefaultDescription(markdown: string): string;
|
|
103
|
+
/**
|
|
104
|
+
* Insert the regenerated Reader Intelligence Guide HTML immediately after
|
|
105
|
+
* the Executive Brief section so the rendered article body matches the
|
|
106
|
+
* documented order (Executive Brief → Reader Intelligence Guide → Key
|
|
107
|
+
* Takeaways → deep sections). The Executive Brief section ends where the
|
|
108
|
+
* next H2 begins; we splice at that boundary. When the brief is absent
|
|
109
|
+
* (sparse runs) we fall back to prepending so the guide still appears
|
|
110
|
+
* at the top of the body.
|
|
111
|
+
*
|
|
112
|
+
* Implementation uses `indexOf` rather than a regex so the splice point
|
|
113
|
+
* is deterministic and immune to polynomial-regex backtracking on
|
|
114
|
+
* pathological input.
|
|
115
|
+
*
|
|
116
|
+
* @param bodyHtml - Rendered article body
|
|
117
|
+
* @param guideHtml - Reader Intelligence Guide HTML fragment
|
|
118
|
+
* @returns Body HTML with the guide spliced after the Executive Brief
|
|
119
|
+
*/
|
|
120
|
+
export declare function insertReaderGuideAfterExecutiveBrief(bodyHtml: string, guideHtml: string): string;
|
|
103
121
|
/**
|
|
104
122
|
* Run the full aggregate → render → write pipeline for one run.
|
|
105
123
|
*
|
|
@@ -349,12 +349,17 @@ function writeLanguageVariant(lang, slug, aggregated, englishHtml, chromeOptions
|
|
|
349
349
|
bodyHtml = bodyHtml.replace(/<h1[^>]*>[\s\S]*?<\/h1>\s*/, '');
|
|
350
350
|
const guideHtml = buildReaderIntelligenceGuideHtml(lang, aggregated.sectionToc, aggregated.includedArtifacts);
|
|
351
351
|
if (guideHtml) {
|
|
352
|
-
//
|
|
353
|
-
// the rendered
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
|
|
352
|
+
// Insert the guide IMMEDIATELY AFTER the Executive Brief section so
|
|
353
|
+
// the rendered HTML body order matches the documented article
|
|
354
|
+
// skeleton (Article-Generation.md §"Article skeleton"):
|
|
355
|
+
// 1. Executive Brief
|
|
356
|
+
// 2. Reader Intelligence Guide
|
|
357
|
+
// 3. Key Takeaways
|
|
358
|
+
// 4. … deep sections
|
|
359
|
+
// We splice at the start of the next H2 after the Executive Brief
|
|
360
|
+
// anchor; when the brief is missing (sparse runs) we fall back to
|
|
361
|
+
// prepending so the guide still appears at the top of the body.
|
|
362
|
+
bodyHtml = insertReaderGuideAfterExecutiveBrief(bodyHtml, guideHtml);
|
|
358
363
|
}
|
|
359
364
|
// Localize Tradecraft References, Analysis Index, and other appendix
|
|
360
365
|
// section headings and content into the target language.
|
|
@@ -385,6 +390,60 @@ function writeLanguageVariant(lang, slug, aggregated, englishHtml, chromeOptions
|
|
|
385
390
|
fs.writeFileSync(path.join(opts.outDir, filename), html, 'utf8');
|
|
386
391
|
return filename;
|
|
387
392
|
}
|
|
393
|
+
/**
|
|
394
|
+
* Insert the regenerated Reader Intelligence Guide HTML immediately after
|
|
395
|
+
* the Executive Brief section so the rendered article body matches the
|
|
396
|
+
* documented order (Executive Brief → Reader Intelligence Guide → Key
|
|
397
|
+
* Takeaways → deep sections). The Executive Brief section ends where the
|
|
398
|
+
* next H2 begins; we splice at that boundary. When the brief is absent
|
|
399
|
+
* (sparse runs) we fall back to prepending so the guide still appears
|
|
400
|
+
* at the top of the body.
|
|
401
|
+
*
|
|
402
|
+
* Implementation uses `indexOf` rather than a regex so the splice point
|
|
403
|
+
* is deterministic and immune to polynomial-regex backtracking on
|
|
404
|
+
* pathological input.
|
|
405
|
+
*
|
|
406
|
+
* @param bodyHtml - Rendered article body
|
|
407
|
+
* @param guideHtml - Reader Intelligence Guide HTML fragment
|
|
408
|
+
* @returns Body HTML with the guide spliced after the Executive Brief
|
|
409
|
+
*/
|
|
410
|
+
export function insertReaderGuideAfterExecutiveBrief(bodyHtml, guideHtml) {
|
|
411
|
+
const execBriefAnchor = 'id="section-executive-brief"';
|
|
412
|
+
const briefIdx = bodyHtml.indexOf(execBriefAnchor);
|
|
413
|
+
if (briefIdx === -1) {
|
|
414
|
+
return guideHtml + '\n' + bodyHtml;
|
|
415
|
+
}
|
|
416
|
+
// Skip the Executive Brief opening tag itself, then walk forward to the
|
|
417
|
+
// next H2 — that's where the next section starts and where we want to
|
|
418
|
+
// splice the guide. `<h2 ` matches a tag with attributes; `<h2>` matches
|
|
419
|
+
// a bare tag (defensive).
|
|
420
|
+
const afterBrief = briefIdx + execBriefAnchor.length;
|
|
421
|
+
const nextH2Tagged = bodyHtml.indexOf('<h2 ', afterBrief);
|
|
422
|
+
const nextH2Bare = bodyHtml.indexOf('<h2>', afterBrief);
|
|
423
|
+
const nextH2 = pickEarliestIndex(nextH2Tagged, nextH2Bare);
|
|
424
|
+
if (nextH2 === -1) {
|
|
425
|
+
// Executive Brief is the only section — append the guide at the end.
|
|
426
|
+
return bodyHtml + '\n' + guideHtml;
|
|
427
|
+
}
|
|
428
|
+
return bodyHtml.slice(0, nextH2) + guideHtml + '\n' + bodyHtml.slice(nextH2);
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Return the smaller of two `indexOf` results, treating `-1` as "not
|
|
432
|
+
* found" so the caller gets `-1` only when both probes failed. Extracted
|
|
433
|
+
* to keep {@link insertReaderGuideAfterExecutiveBrief} under the
|
|
434
|
+
* useless-assignment lint.
|
|
435
|
+
*
|
|
436
|
+
* @param a - First `indexOf` result
|
|
437
|
+
* @param b - Second `indexOf` result
|
|
438
|
+
* @returns Smaller non-negative index, or `-1` when both are `-1`
|
|
439
|
+
*/
|
|
440
|
+
function pickEarliestIndex(a, b) {
|
|
441
|
+
if (a === -1)
|
|
442
|
+
return b;
|
|
443
|
+
if (b === -1)
|
|
444
|
+
return a;
|
|
445
|
+
return Math.min(a, b);
|
|
446
|
+
}
|
|
388
447
|
/**
|
|
389
448
|
* Safely look up one language entry in a {@link ResolvedMetadata} map.
|
|
390
449
|
* The runtime shape is always complete (one entry per language), but the
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { BASE_URL, BUILD_SHORT, MERMAID_VERSION } from '../constants/config.js';
|
|
22
22
|
import { buildHeadFreshnessTags } from '../constants/build-info-meta.js';
|
|
23
|
-
import { ALL_LANGUAGES, LANGUAGE_NAMES, LANGUAGE_FLAGS, PAGE_TITLES, SKIP_LINK_TEXTS, TOC_ARIA_LABELS, ARTICLE_TYPE_LABELS, VIEW_SOURCE_MARKDOWN_LABELS, ARTICLE_TYPE_ICONS, TRADECRAFT_HEADING_LABELS, TRADECRAFT_INTRO_LABELS, TRADECRAFT_METHODOLOGIES_LABELS, TRADECRAFT_TEMPLATES_LABELS, ANALYSIS_INDEX_HEADING_LABELS, ANALYSIS_INDEX_INTRO_LABELS, ANALYSIS_INDEX_COL_SECTION_LABELS, ANALYSIS_INDEX_COL_ARTIFACT_LABELS, ANALYSIS_INDEX_COL_PATH_LABELS, KEY_TAKEAWAYS_HEADING_LABELS, SUPPLEMENTARY_HEADING_LABELS, SECTION_TITLE_LABELS, getLocalizedString, getTextDirection, } from '../constants/languages.js';
|
|
23
|
+
import { ALL_LANGUAGES, LANGUAGE_NAMES, LANGUAGE_FLAGS, PAGE_TITLES, SKIP_LINK_TEXTS, TOC_ARIA_LABELS, ARTICLE_TYPE_LABELS, BACK_TO_NEWS_LABELS, ARTICLE_NAV_LABELS, VIEW_SOURCE_MARKDOWN_LABELS, ARTICLE_TYPE_ICONS, FOOTER_SITEMAP_LABELS, FOOTER_POLITICAL_INTELLIGENCE_LABELS, TRADECRAFT_HEADING_LABELS, TRADECRAFT_INTRO_LABELS, TRADECRAFT_METHODOLOGIES_LABELS, TRADECRAFT_TEMPLATES_LABELS, ANALYSIS_INDEX_HEADING_LABELS, ANALYSIS_INDEX_INTRO_LABELS, ANALYSIS_INDEX_COL_SECTION_LABELS, ANALYSIS_INDEX_COL_ARTIFACT_LABELS, ANALYSIS_INDEX_COL_PATH_LABELS, KEY_TAKEAWAYS_HEADING_LABELS, SUPPLEMENTARY_HEADING_LABELS, SECTION_TITLE_LABELS, getLocalizedString, getTextDirection, } from '../constants/languages.js';
|
|
24
24
|
import { ArticleCategory } from '../types/index.js';
|
|
25
25
|
import { escapeHTML } from '../utils/file-utils.js';
|
|
26
26
|
import { buildSiteFooter, buildSiteHeader, buildPageBanner, } from '../templates/section-builders.js';
|
|
@@ -28,6 +28,8 @@ import { READER_GUIDE_SECTION_ID } from './reader-guide-constants.js';
|
|
|
28
28
|
import { READER_GUIDE_TITLE_LABELS } from './reader-intelligence-guide.js';
|
|
29
29
|
import { TRADECRAFT_SECTION_ID, MANIFEST_SECTION_ID, SUPPLEMENTARY_SECTION_ID, } from './artifact-order.js';
|
|
30
30
|
import { KEY_TAKEAWAYS_SECTION_ID } from './key-takeaways.js';
|
|
31
|
+
import { getPoliticalIntelligenceFilename } from '../generators/political-intelligence.js';
|
|
32
|
+
import { getSitemapFilename } from '../generators/sitemap/index.js';
|
|
31
33
|
/**
|
|
32
34
|
* Resolve a localized article type label with icon. Falls back to the
|
|
33
35
|
* humanised slug when a translation isn't available.
|
|
@@ -155,10 +157,10 @@ export function buildArticleToc(entries, lang) {
|
|
|
155
157
|
})
|
|
156
158
|
.join('\n');
|
|
157
159
|
return [
|
|
158
|
-
` <aside class="article-toc-container" aria-
|
|
160
|
+
` <aside class="article-toc-container" aria-labelledby="article-toc-heading">`,
|
|
159
161
|
` <details class="article-toc-details" open>`,
|
|
160
|
-
` <summary class="article-toc-summary"><span class="guide-icon" aria-hidden="true">📑</span> ${label}</summary>`,
|
|
161
|
-
` <nav class="article-toc">`,
|
|
162
|
+
` <summary class="article-toc-summary" id="article-toc-heading"><span class="guide-icon" aria-hidden="true">📑</span> ${label}</summary>`,
|
|
163
|
+
` <nav class="article-toc" aria-labelledby="article-toc-heading">`,
|
|
162
164
|
` <ol class="article-toc-list">`,
|
|
163
165
|
items,
|
|
164
166
|
` </ol>`,
|
|
@@ -310,10 +312,17 @@ export function wrapArticleHtml(options) {
|
|
|
310
312
|
const hreflangLinks = buildArticleHreflangLinks(options.articleSlug);
|
|
311
313
|
const langSwitcher = buildLanguageSwitcher(options.articleSlug, safeLang);
|
|
312
314
|
const sourceMdLabel = getLocalizedString(VIEW_SOURCE_MARKDOWN_LABELS, safeLang);
|
|
315
|
+
const articleNavLabel = getLocalizedString(ARTICLE_NAV_LABELS, safeLang);
|
|
316
|
+
const backToNewsLabel = getLocalizedString(BACK_TO_NEWS_LABELS, safeLang);
|
|
317
|
+
const politicalIntelligenceLabel = getLocalizedString(FOOTER_POLITICAL_INTELLIGENCE_LABELS, safeLang);
|
|
318
|
+
const sitemapLabel = getLocalizedString(FOOTER_SITEMAP_LABELS, safeLang);
|
|
319
|
+
const politicalIntelligenceHref = `../${getPoliticalIntelligenceFilename(safeLang)}`;
|
|
320
|
+
const sitemapHref = `../${getSitemapFilename(safeLang)}`;
|
|
313
321
|
const sourceMdLink = options.sourceMarkdownRelPath
|
|
314
322
|
? `<p class="article-source-md"><a href="${BASE_URL}/${options.sourceMarkdownRelPath}" rel="alternate" type="text/markdown"><svg class="icon icon-inline" width="16" height="16" viewBox="0 0 24 24" role="img" aria-hidden="true" focusable="false"><path d="M9 5H7a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-2M12 3h6a2 2 0 0 1 2 2v6M10 14 20 4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg> ${escapeHTML(sourceMdLabel)}</a></p>`
|
|
315
323
|
: '';
|
|
316
324
|
const tocHtml = buildArticleToc(options.toc ?? [], safeLang);
|
|
325
|
+
const articleMainClass = tocHtml.length > 0 ? 'article-main--with-toc' : 'article-main--no-toc';
|
|
317
326
|
const jsonLd = {
|
|
318
327
|
'@context': 'https://schema.org',
|
|
319
328
|
'@type': 'NewsArticle',
|
|
@@ -413,7 +422,9 @@ ${hreflangLinks}
|
|
|
413
422
|
<link rel="icon" type="image/png" sizes="16x16" href="../images/favicon-16x16.png">
|
|
414
423
|
<link rel="apple-touch-icon" sizes="180x180" href="../images/apple-touch-icon.png">
|
|
415
424
|
<link rel="manifest" href="../site.webmanifest">
|
|
416
|
-
<meta name="
|
|
425
|
+
<meta name="color-scheme" content="light dark">
|
|
426
|
+
<meta name="theme-color" content="#003399" media="(prefers-color-scheme: light)">
|
|
427
|
+
<meta name="theme-color" content="#0a1a38" media="(prefers-color-scheme: dark)">
|
|
417
428
|
<link rel="stylesheet" href="../styles.css?v=${BUILD_SHORT}">
|
|
418
429
|
${buildHeadFreshnessTags('../')}
|
|
419
430
|
<script type="application/ld+json">${jsonLdString}</script>
|
|
@@ -422,12 +433,18 @@ ${buildHeadFreshnessTags('../')}
|
|
|
422
433
|
</head>
|
|
423
434
|
<body>
|
|
424
435
|
<a href="#main" class="skip-link">${escapeHTML(skipLinkText)}</a>
|
|
436
|
+
<div class="reading-progress" aria-hidden="true"></div>
|
|
425
437
|
|
|
426
438
|
${header}
|
|
427
439
|
|
|
428
440
|
${buildPageBanner('../')}
|
|
429
441
|
|
|
430
|
-
<main id="main" class="site-main article-main">
|
|
442
|
+
<main id="main" class="site-main article-main ${articleMainClass}">
|
|
443
|
+
<nav class="article-top-nav" aria-label="${escapeHTML(articleNavLabel)}">
|
|
444
|
+
<a class="article-top-nav__link article-top-nav__link--primary" href="${indexHref}">${escapeHTML(backToNewsLabel)}</a>
|
|
445
|
+
<a class="article-top-nav__link" href="${politicalIntelligenceHref}">🧠 ${escapeHTML(politicalIntelligenceLabel)}</a>
|
|
446
|
+
<a class="article-top-nav__link" href="${sitemapHref}">🗺️ ${escapeHTML(sitemapLabel)}</a>
|
|
447
|
+
</nav>
|
|
431
448
|
${tocHtml} <article class="article-body" lang="${safeLang}">
|
|
432
449
|
<header class="article-hero">
|
|
433
450
|
<p class="article-kicker">${escapeHTML(getLocalizedArticleType(options.articleType, safeLang))}</p>
|
|
@@ -59,6 +59,31 @@ export interface ResolveMetadataOptions {
|
|
|
59
59
|
* @returns `true` when the line is not prose and should be skipped
|
|
60
60
|
*/
|
|
61
61
|
export declare function shouldSkipDescriptionLine(line: string): boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Strip inline Markdown decorations so we can use the remaining text as
|
|
64
|
+
* plain-text meta-tag content. Removes link syntax, emphasis, inline code
|
|
65
|
+
* backticks, and HTML-entity fragments that the Markdown source sometimes
|
|
66
|
+
* smuggles in. Keeps the visible text readable.
|
|
67
|
+
*
|
|
68
|
+
* @param raw - Trimmed Markdown line
|
|
69
|
+
* @returns Plain-text variant
|
|
70
|
+
*/
|
|
71
|
+
/**
|
|
72
|
+
* Strip a leading all-caps prose label (e.g. `SITUATION:`, `KEY MOTION:`,
|
|
73
|
+
* `BLUF:`, `BOTTOM LINE:`, `TIER-1:`) from a prose line. These labels
|
|
74
|
+
* are common in BLUF-style editorial writing — they survive
|
|
75
|
+
* {@link stripInlineMarkdown} (which strips the `**bold**` wrapper but
|
|
76
|
+
* keeps the literal text) and would otherwise leak into the SEO
|
|
77
|
+
* description as a confusing all-caps shout.
|
|
78
|
+
*
|
|
79
|
+
* Matches up to 4 hyphenated all-caps tokens, optionally followed by a
|
|
80
|
+
* digit suffix (`TIER-1`), terminating at a colon. Returns the original
|
|
81
|
+
* line when no opener is present.
|
|
82
|
+
*
|
|
83
|
+
* @param line - Plain prose line (post-{@link stripInlineMarkdown})
|
|
84
|
+
* @returns Line with the all-caps opener removed
|
|
85
|
+
*/
|
|
86
|
+
export declare function stripLeadingProseLabel(line: string): string;
|
|
62
87
|
/**
|
|
63
88
|
* Strip inline Markdown decorations so we can use the remaining text as
|
|
64
89
|
* plain-text meta-tag content. Removes link syntax, emphasis, inline code
|
|
@@ -105,6 +130,56 @@ export declare function extractFirstH1(markdown: string): string;
|
|
|
105
130
|
* @returns Prose description, or empty string when nothing qualifies
|
|
106
131
|
*/
|
|
107
132
|
export declare function extractStrongProseLine(markdown: string): string;
|
|
133
|
+
/**
|
|
134
|
+
* Walk the body of an editorial artefact and, when it contains a `## …`
|
|
135
|
+
* heading whose text matches one of {@link EDITORIAL_LEDE_HEADINGS},
|
|
136
|
+
* return the first prose paragraph that follows that heading. This is
|
|
137
|
+
* the journalist's lede ("60-Second Read", "TL;DR", "BLUF — …", …) and
|
|
138
|
+
* is exactly the sentence that should power `<meta description>` and
|
|
139
|
+
* the OG/Twitter description fields.
|
|
140
|
+
*
|
|
141
|
+
* Returns the empty string when no lede heading is found or no qualifying
|
|
142
|
+
* prose follows it. Inline Markdown is stripped and the result is
|
|
143
|
+
* truncated to fit `<meta description>`.
|
|
144
|
+
*
|
|
145
|
+
* @param markdown - Editorial artefact source
|
|
146
|
+
* @returns Lede paragraph, or empty string when none matched
|
|
147
|
+
*/
|
|
148
|
+
export declare function extractLedeAfterHeading(markdown: string): string;
|
|
149
|
+
/**
|
|
150
|
+
* Return `true` when an artefact-H1 begins with one of the
|
|
151
|
+
* {@link ARTIFACT_CATEGORY_PREFIXES} followed by a separator. Such H1s
|
|
152
|
+
* carry the artefact's structural label rather than a journalist's
|
|
153
|
+
* headline (e.g. `# Synthesis Summary — Week in Review (3 Apr – 1 May
|
|
154
|
+
* 2026)`) and must not leak into the article `<title>`.
|
|
155
|
+
*
|
|
156
|
+
* @param heading - Plain-text H1 (after `stripInlineMarkdown`)
|
|
157
|
+
* @returns `true` when the heading is an artefact-category label
|
|
158
|
+
*/
|
|
159
|
+
export declare function isArtifactCategoryHeading(heading: string): boolean;
|
|
160
|
+
/**
|
|
161
|
+
* Strip a leading or trailing artifact-category label from a heading and
|
|
162
|
+
* return the editorial-topic core. When neither end carries a category
|
|
163
|
+
* label, the heading is returned unchanged. When the category label is
|
|
164
|
+
* the **entire** heading (e.g. `# Executive Brief`) the result is the
|
|
165
|
+
* empty string.
|
|
166
|
+
*
|
|
167
|
+
* Examples:
|
|
168
|
+
* - `Executive Brief — EU Parliament Motions` → `EU Parliament Motions`
|
|
169
|
+
* - `EU Parliament Propositions — Executive Brief` → `EU Parliament Propositions`
|
|
170
|
+
* - `EP10 Term Outlook — Executive Brief` → `EP10 Term Outlook`
|
|
171
|
+
* - `Key Legislative Developments — Deep Analysis (2026-05-08)` → `Key Legislative Developments`
|
|
172
|
+
* - `Synthesis Summary — EP Motions & Adopted Texts` → `EP Motions & Adopted Texts`
|
|
173
|
+
*
|
|
174
|
+
* Trailing parenthesised metadata (`(2026-05-08)`, `(May 2026)`) is also
|
|
175
|
+
* stripped because it functions as a date stamp rather than editorial
|
|
176
|
+
* copy. The returned core is trimmed of whitespace and trailing
|
|
177
|
+
* punctuation.
|
|
178
|
+
*
|
|
179
|
+
* @param heading - Raw heading text (post-{@link stripInlineMarkdown})
|
|
180
|
+
* @returns Editorial-topic core, or empty string when only the category survived
|
|
181
|
+
*/
|
|
182
|
+
export declare function stripArtifactCategoryAffix(heading: string): string;
|
|
108
183
|
/**
|
|
109
184
|
* Humanise an `article-type` slug the same way the aggregator does (see
|
|
110
185
|
* `src/aggregator/analysis-aggregator.ts:humanize`). Kept in sync by value
|
|
@@ -52,6 +52,14 @@ const DESCRIPTION_MAX_LENGTH = 300;
|
|
|
52
52
|
const TITLE_MAX_LENGTH = 140;
|
|
53
53
|
/** Ordered list of artefact filenames that typically carry the editorial H1. */
|
|
54
54
|
const EDITORIAL_ARTEFACT_CANDIDATES = [
|
|
55
|
+
// `executive-brief.md` is the canonical Riksdagsmonitor-aligned editorial
|
|
56
|
+
// artefact (see `analysis/methodologies/ai-driven-analysis-guide.md`).
|
|
57
|
+
// It always carries the journalist's BLUF and a `## 60-Second Read`
|
|
58
|
+
// paragraph that is the lede — preferring it over `synthesis-summary.md`
|
|
59
|
+
// keeps Stage-B internal vocabulary ("Purpose: This artifact provides …")
|
|
60
|
+
// out of the SEO-critical `<title>` and `<meta description>` surfaces.
|
|
61
|
+
'executive-brief.md',
|
|
62
|
+
'extended/executive-brief.md',
|
|
55
63
|
'intelligence/synthesis-summary.md',
|
|
56
64
|
'intelligence/executive-summary.md',
|
|
57
65
|
'intelligence/intelligence-briefing.md',
|
|
@@ -68,6 +76,107 @@ const EDITORIAL_ARTEFACT_CANDIDATES = [
|
|
|
68
76
|
'motions-analysis.md',
|
|
69
77
|
'propositions-analysis.md',
|
|
70
78
|
];
|
|
79
|
+
/**
|
|
80
|
+
* Headings inside an editorial artefact that carry the journalist's lede
|
|
81
|
+
* paragraph (a one-paragraph summary of "what happened, why it matters").
|
|
82
|
+
* When the resolver sees one of these as a `## …` heading inside the
|
|
83
|
+
* editorial artefact, it prefers the first prose paragraph that follows
|
|
84
|
+
* it as the description (and as a title fallback) over a generic line
|
|
85
|
+
* walk. Names are matched case-insensitively against the heading text
|
|
86
|
+
* (after stripping inline Markdown).
|
|
87
|
+
*/
|
|
88
|
+
const EDITORIAL_LEDE_HEADINGS = [
|
|
89
|
+
'60-second read',
|
|
90
|
+
'60 second read',
|
|
91
|
+
'sixty-second read',
|
|
92
|
+
'lede',
|
|
93
|
+
'lead',
|
|
94
|
+
'tl;dr',
|
|
95
|
+
'tldr',
|
|
96
|
+
'synopsis',
|
|
97
|
+
'in brief',
|
|
98
|
+
'at a glance',
|
|
99
|
+
'bottom line',
|
|
100
|
+
'bluf',
|
|
101
|
+
'bluf — bottom line up front',
|
|
102
|
+
'bottom line up front',
|
|
103
|
+
'executive summary',
|
|
104
|
+
'executive briefing',
|
|
105
|
+
'master narrative',
|
|
106
|
+
'overview',
|
|
107
|
+
'headline judgement',
|
|
108
|
+
'headline judgment',
|
|
109
|
+
'key findings',
|
|
110
|
+
'key judgements',
|
|
111
|
+
'key judgments',
|
|
112
|
+
'situation summary',
|
|
113
|
+
'situation report',
|
|
114
|
+
'situation update',
|
|
115
|
+
];
|
|
116
|
+
/**
|
|
117
|
+
* Artifact-category prefixes that appear inside editorial-artefact H1s as
|
|
118
|
+
* a structural label rather than an editorial headline (e.g. `# Synthesis
|
|
119
|
+
* Summary — Week in Review (3 Apr – 1 May 2026)`). When a candidate H1
|
|
120
|
+
* starts with one of these prefixes followed by a separator (em/en dash,
|
|
121
|
+
* hyphen, or colon), the resolver treats it as **generic** so it does
|
|
122
|
+
* not leak into the article `<title>`. Compared lower-case, with leading
|
|
123
|
+
* punctuation stripped.
|
|
124
|
+
*/
|
|
125
|
+
const ARTIFACT_CATEGORY_PREFIXES = [
|
|
126
|
+
'actor mapping',
|
|
127
|
+
'analytical quality',
|
|
128
|
+
'breaking news analysis',
|
|
129
|
+
'coalition dynamics',
|
|
130
|
+
'commission wp alignment',
|
|
131
|
+
'committee activity report',
|
|
132
|
+
'cross run continuity',
|
|
133
|
+
'deep analysis',
|
|
134
|
+
'economic context',
|
|
135
|
+
'executive brief',
|
|
136
|
+
'executive briefing',
|
|
137
|
+
'executive summary',
|
|
138
|
+
'forward indicators',
|
|
139
|
+
'historical baseline',
|
|
140
|
+
'impact matrix',
|
|
141
|
+
'intelligence assessment',
|
|
142
|
+
'intelligence briefing',
|
|
143
|
+
'intelligence synthesis summary',
|
|
144
|
+
'legislative output analysis',
|
|
145
|
+
'legislative pipeline analysis',
|
|
146
|
+
'legislative pipeline forecast',
|
|
147
|
+
'mandate fulfilment scorecard',
|
|
148
|
+
'master intelligence synthesis',
|
|
149
|
+
'mcp reliability audit',
|
|
150
|
+
'methodology reflection',
|
|
151
|
+
'monthly outlook',
|
|
152
|
+
'motions analysis',
|
|
153
|
+
'parliamentary calendar projection',
|
|
154
|
+
'pestle analysis',
|
|
155
|
+
'political intelligence brief',
|
|
156
|
+
'political risk',
|
|
157
|
+
'political threat landscape',
|
|
158
|
+
'presidency trio context',
|
|
159
|
+
'propositions analysis',
|
|
160
|
+
'quantitative swot',
|
|
161
|
+
'risk assessment',
|
|
162
|
+
'risk matrix',
|
|
163
|
+
'risk scoring',
|
|
164
|
+
'scenario forecast',
|
|
165
|
+
'seat projection',
|
|
166
|
+
'significance classification',
|
|
167
|
+
'situation report',
|
|
168
|
+
'situation summary',
|
|
169
|
+
'stakeholder analysis',
|
|
170
|
+
'stakeholder impact',
|
|
171
|
+
'stakeholder map',
|
|
172
|
+
'swot analysis',
|
|
173
|
+
'synthesis summary',
|
|
174
|
+
'threat assessment',
|
|
175
|
+
'threat model',
|
|
176
|
+
'voting patterns',
|
|
177
|
+
'weekly outlook',
|
|
178
|
+
'wildcards blackswans',
|
|
179
|
+
];
|
|
71
180
|
/**
|
|
72
181
|
* Emoji-banner prefixes that Stage-B agents use to decorate metadata rows
|
|
73
182
|
* (e.g. `📋 Analysis Owner:`). Any line starting with one of these is
|
|
@@ -96,27 +205,47 @@ const EMOJI_BANNER_CHARS = [
|
|
|
96
205
|
* by optional space and a colon.
|
|
97
206
|
*/
|
|
98
207
|
const METADATA_LINE_PREFIXES = [
|
|
208
|
+
'Admiralty Grade',
|
|
99
209
|
'Analysis Date',
|
|
100
210
|
'Analysis Owner',
|
|
101
211
|
'Article Type',
|
|
212
|
+
'Article Window',
|
|
102
213
|
'Assessment Date',
|
|
214
|
+
'Briefing',
|
|
215
|
+
'Briefing Date',
|
|
103
216
|
'Classification',
|
|
104
217
|
'Classification Date',
|
|
105
218
|
'Confidence',
|
|
219
|
+
'Confidence in Evidence',
|
|
106
220
|
'Data Sources',
|
|
221
|
+
'Date',
|
|
107
222
|
'Document Type',
|
|
223
|
+
'Filing Date',
|
|
108
224
|
'Generated',
|
|
225
|
+
'Horizon',
|
|
226
|
+
'IMF Status',
|
|
109
227
|
'Last Updated',
|
|
110
228
|
'Parliamentary Status',
|
|
111
229
|
'Parliamentary Term',
|
|
112
230
|
'Period',
|
|
231
|
+
'Prepared',
|
|
232
|
+
'Purpose',
|
|
233
|
+
'Region',
|
|
234
|
+
'Reporting',
|
|
235
|
+
'Reporting Period',
|
|
236
|
+
'Reporting Window',
|
|
113
237
|
'Run',
|
|
114
238
|
'Run ID',
|
|
115
239
|
'Series',
|
|
116
240
|
'Series Run',
|
|
241
|
+
'Source',
|
|
242
|
+
'Sources',
|
|
117
243
|
'SPDX-FileCopyrightText',
|
|
118
244
|
'SPDX-License-Identifier',
|
|
245
|
+
'Topic',
|
|
119
246
|
'Type',
|
|
247
|
+
'WEP Band',
|
|
248
|
+
'WEP Grade',
|
|
120
249
|
'Window',
|
|
121
250
|
];
|
|
122
251
|
/**
|
|
@@ -170,6 +299,53 @@ export function shouldSkipDescriptionLine(line) {
|
|
|
170
299
|
return true;
|
|
171
300
|
return false;
|
|
172
301
|
}
|
|
302
|
+
/**
|
|
303
|
+
* Strip inline Markdown decorations so we can use the remaining text as
|
|
304
|
+
* plain-text meta-tag content. Removes link syntax, emphasis, inline code
|
|
305
|
+
* backticks, and HTML-entity fragments that the Markdown source sometimes
|
|
306
|
+
* smuggles in. Keeps the visible text readable.
|
|
307
|
+
*
|
|
308
|
+
* @param raw - Trimmed Markdown line
|
|
309
|
+
* @returns Plain-text variant
|
|
310
|
+
*/
|
|
311
|
+
/**
|
|
312
|
+
* Strip a leading all-caps prose label (e.g. `SITUATION:`, `KEY MOTION:`,
|
|
313
|
+
* `BLUF:`, `BOTTOM LINE:`, `TIER-1:`) from a prose line. These labels
|
|
314
|
+
* are common in BLUF-style editorial writing — they survive
|
|
315
|
+
* {@link stripInlineMarkdown} (which strips the `**bold**` wrapper but
|
|
316
|
+
* keeps the literal text) and would otherwise leak into the SEO
|
|
317
|
+
* description as a confusing all-caps shout.
|
|
318
|
+
*
|
|
319
|
+
* Matches up to 4 hyphenated all-caps tokens, optionally followed by a
|
|
320
|
+
* digit suffix (`TIER-1`), terminating at a colon. Returns the original
|
|
321
|
+
* line when no opener is present.
|
|
322
|
+
*
|
|
323
|
+
* @param line - Plain prose line (post-{@link stripInlineMarkdown})
|
|
324
|
+
* @returns Line with the all-caps opener removed
|
|
325
|
+
*/
|
|
326
|
+
export function stripLeadingProseLabel(line) {
|
|
327
|
+
// Use a single-pass regex with no nested quantifiers and no overlapping
|
|
328
|
+
// character classes — keeps `security/detect-unsafe-regex` happy. The
|
|
329
|
+
// pattern matches a label of 2-80 contiguous chars from a closed set
|
|
330
|
+
// (uppercase letters, digits, hyphen, single internal spaces),
|
|
331
|
+
// terminated by `:` and at least one whitespace before the prose body.
|
|
332
|
+
const colonIdx = line.indexOf(': ');
|
|
333
|
+
if (colonIdx < 2 || colonIdx > 80)
|
|
334
|
+
return line;
|
|
335
|
+
const label = line.slice(0, colonIdx);
|
|
336
|
+
const rest = line.slice(colonIdx + 2).trim();
|
|
337
|
+
if (rest.length < 20)
|
|
338
|
+
return line;
|
|
339
|
+
// Validate the label: ALL chars must be uppercase A-Z, digit 0-9, space,
|
|
340
|
+
// or hyphen; the first char must be a letter.
|
|
341
|
+
if (!/^[A-Z][A-Z0-9 -]{1,79}$/.test(label))
|
|
342
|
+
return line;
|
|
343
|
+
// Reject single-word labels shorter than 3 chars (`OK:` would be a
|
|
344
|
+
// false positive against legitimate sentence openers).
|
|
345
|
+
if (label.length < 3)
|
|
346
|
+
return line;
|
|
347
|
+
return rest;
|
|
348
|
+
}
|
|
173
349
|
/**
|
|
174
350
|
* Strip inline Markdown decorations so we can use the remaining text as
|
|
175
351
|
* plain-text meta-tag content. Removes link syntax, emphasis, inline code
|
|
@@ -266,13 +442,204 @@ export function extractStrongProseLine(markdown) {
|
|
|
266
442
|
const line = raw.trim();
|
|
267
443
|
if (shouldSkipDescriptionLine(line))
|
|
268
444
|
continue;
|
|
269
|
-
const plain = stripInlineMarkdown(line);
|
|
445
|
+
const plain = stripLeadingProseLabel(stripInlineMarkdown(line));
|
|
270
446
|
if (plain.length < 40)
|
|
271
447
|
continue;
|
|
272
448
|
return truncateDescription(plain);
|
|
273
449
|
}
|
|
274
450
|
return '';
|
|
275
451
|
}
|
|
452
|
+
/**
|
|
453
|
+
* Walk the body of an editorial artefact and, when it contains a `## …`
|
|
454
|
+
* heading whose text matches one of {@link EDITORIAL_LEDE_HEADINGS},
|
|
455
|
+
* return the first prose paragraph that follows that heading. This is
|
|
456
|
+
* the journalist's lede ("60-Second Read", "TL;DR", "BLUF — …", …) and
|
|
457
|
+
* is exactly the sentence that should power `<meta description>` and
|
|
458
|
+
* the OG/Twitter description fields.
|
|
459
|
+
*
|
|
460
|
+
* Returns the empty string when no lede heading is found or no qualifying
|
|
461
|
+
* prose follows it. Inline Markdown is stripped and the result is
|
|
462
|
+
* truncated to fit `<meta description>`.
|
|
463
|
+
*
|
|
464
|
+
* @param markdown - Editorial artefact source
|
|
465
|
+
* @returns Lede paragraph, or empty string when none matched
|
|
466
|
+
*/
|
|
467
|
+
export function extractLedeAfterHeading(markdown) {
|
|
468
|
+
const lines = markdown.split('\n');
|
|
469
|
+
let inLede = false;
|
|
470
|
+
for (let i = 0; i < lines.length; i++) {
|
|
471
|
+
const raw = lines[i] ?? '';
|
|
472
|
+
const line = raw.trim();
|
|
473
|
+
// Detect the start of a lede section — accept any H2/H3 whose plain
|
|
474
|
+
// text (after stripping leading hashes, inline decorations, and any
|
|
475
|
+
// leading emoji/punctuation) matches one of the canonical headings.
|
|
476
|
+
if (/^#{2,3}\s+/.test(line)) {
|
|
477
|
+
const headingText = normaliseHeadingText(line.replace(/^#{2,3}\s+/, ''));
|
|
478
|
+
inLede = EDITORIAL_LEDE_HEADINGS.some((h) => headingText === h || headingText.startsWith(`${h} `) || headingText.startsWith(`${h}:`));
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
if (!inLede)
|
|
482
|
+
continue;
|
|
483
|
+
// Inside the lede section: skip non-prose lines, then return the first
|
|
484
|
+
// qualifying paragraph. Strip a leading all-caps prose label
|
|
485
|
+
// (`SITUATION:`, `KEY MOTION:`, `BLUF:`, …) so SEO descriptions read
|
|
486
|
+
// as natural sentences rather than BLUF shouts.
|
|
487
|
+
if (shouldSkipDescriptionLine(line))
|
|
488
|
+
continue;
|
|
489
|
+
const plain = stripLeadingProseLabel(stripInlineMarkdown(line));
|
|
490
|
+
if (plain.length < 40)
|
|
491
|
+
continue;
|
|
492
|
+
return truncateDescription(plain);
|
|
493
|
+
}
|
|
494
|
+
return '';
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Normalise a Markdown heading's text for comparison against the
|
|
498
|
+
* editorial-lede heading whitelist. Strips inline Markdown decorations
|
|
499
|
+
* (`*`, `_`, `` ` ``, `#`), then strips any leading non-alphanumeric
|
|
500
|
+
* characters (emoji, punctuation, spaces) so a heading like
|
|
501
|
+
* `🎯 Headline Judgement` compares equal to `headline judgement`.
|
|
502
|
+
*
|
|
503
|
+
* @param raw - Raw heading text (no leading hashes)
|
|
504
|
+
* @returns Lower-cased, decoration-stripped heading text
|
|
505
|
+
*/
|
|
506
|
+
function normaliseHeadingText(raw) {
|
|
507
|
+
return stripInlineMarkdown(raw)
|
|
508
|
+
.replace(/[*_`#]+/g, '')
|
|
509
|
+
.replace(/^[^A-Za-z0-9]+/, '')
|
|
510
|
+
.trim()
|
|
511
|
+
.toLowerCase();
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Return `true` when an artefact-H1 begins with one of the
|
|
515
|
+
* {@link ARTIFACT_CATEGORY_PREFIXES} followed by a separator. Such H1s
|
|
516
|
+
* carry the artefact's structural label rather than a journalist's
|
|
517
|
+
* headline (e.g. `# Synthesis Summary — Week in Review (3 Apr – 1 May
|
|
518
|
+
* 2026)`) and must not leak into the article `<title>`.
|
|
519
|
+
*
|
|
520
|
+
* @param heading - Plain-text H1 (after `stripInlineMarkdown`)
|
|
521
|
+
* @returns `true` when the heading is an artefact-category label
|
|
522
|
+
*/
|
|
523
|
+
export function isArtifactCategoryHeading(heading) {
|
|
524
|
+
const normalized = normaliseCategoryHeading(heading);
|
|
525
|
+
if (normalized === '')
|
|
526
|
+
return false;
|
|
527
|
+
for (const prefix of ARTIFACT_CATEGORY_PREFIXES) {
|
|
528
|
+
if (normalized === prefix)
|
|
529
|
+
return true;
|
|
530
|
+
// Accept any of: "<prefix> — …", "<prefix> – …", "<prefix> - …",
|
|
531
|
+
// "<prefix>: …" — every separator commonly used in artefact H1s.
|
|
532
|
+
if (normalized.startsWith(`${prefix} —`) ||
|
|
533
|
+
normalized.startsWith(`${prefix} –`) ||
|
|
534
|
+
normalized.startsWith(`${prefix} -`) ||
|
|
535
|
+
normalized.startsWith(`${prefix}:`)) {
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
// Also accept "<topic> — <prefix>" / "<topic>: <prefix>" so suffix-form
|
|
539
|
+
// category labels (`# EU Parliament Propositions — Executive Brief`,
|
|
540
|
+
// `# Key Legislative Developments — Deep Analysis`) are flagged the
|
|
541
|
+
// same as prefix-form ones. The "topic" is rescued by the affix
|
|
542
|
+
// stripper before this rejection takes effect.
|
|
543
|
+
if (normalized.endsWith(` — ${prefix}`) ||
|
|
544
|
+
normalized.endsWith(` – ${prefix}`) ||
|
|
545
|
+
normalized.endsWith(` - ${prefix}`) ||
|
|
546
|
+
normalized.endsWith(`: ${prefix}`)) {
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Strip a leading or trailing artifact-category label from a heading and
|
|
554
|
+
* return the editorial-topic core. When neither end carries a category
|
|
555
|
+
* label, the heading is returned unchanged. When the category label is
|
|
556
|
+
* the **entire** heading (e.g. `# Executive Brief`) the result is the
|
|
557
|
+
* empty string.
|
|
558
|
+
*
|
|
559
|
+
* Examples:
|
|
560
|
+
* - `Executive Brief — EU Parliament Motions` → `EU Parliament Motions`
|
|
561
|
+
* - `EU Parliament Propositions — Executive Brief` → `EU Parliament Propositions`
|
|
562
|
+
* - `EP10 Term Outlook — Executive Brief` → `EP10 Term Outlook`
|
|
563
|
+
* - `Key Legislative Developments — Deep Analysis (2026-05-08)` → `Key Legislative Developments`
|
|
564
|
+
* - `Synthesis Summary — EP Motions & Adopted Texts` → `EP Motions & Adopted Texts`
|
|
565
|
+
*
|
|
566
|
+
* Trailing parenthesised metadata (`(2026-05-08)`, `(May 2026)`) is also
|
|
567
|
+
* stripped because it functions as a date stamp rather than editorial
|
|
568
|
+
* copy. The returned core is trimmed of whitespace and trailing
|
|
569
|
+
* punctuation.
|
|
570
|
+
*
|
|
571
|
+
* @param heading - Raw heading text (post-{@link stripInlineMarkdown})
|
|
572
|
+
* @returns Editorial-topic core, or empty string when only the category survived
|
|
573
|
+
*/
|
|
574
|
+
export function stripArtifactCategoryAffix(heading) {
|
|
575
|
+
const trimmed = heading.trim();
|
|
576
|
+
if (trimmed === '')
|
|
577
|
+
return '';
|
|
578
|
+
const sortedPrefixes = [...ARTIFACT_CATEGORY_PREFIXES].sort((a, b) => b.length - a.length);
|
|
579
|
+
const normalized = normaliseCategoryHeading(trimmed);
|
|
580
|
+
const skip = trimmed.length - normalized.length;
|
|
581
|
+
const visible = trimmed.slice(skip < 0 ? 0 : skip);
|
|
582
|
+
// Pre-strip trailing parenthesised metadata (`(2026-05-08)`,
|
|
583
|
+
// `(May 2026)`) so the suffix matcher works on `… — deep analysis`
|
|
584
|
+
// rather than `… — deep analysis (2026-05-08)`.
|
|
585
|
+
const visibleClean = visible.replace(/\s*\([^)]{1,80}\)\s*$/u, '').trim();
|
|
586
|
+
const normalizedClean = normaliseCategoryHeading(visibleClean);
|
|
587
|
+
for (const prefix of sortedPrefixes) {
|
|
588
|
+
// Prefix-form: `Executive Brief — <topic>`
|
|
589
|
+
for (const sep of [' — ', ' – ', ' - ', ': ']) {
|
|
590
|
+
const candidate = `${prefix}${sep}`;
|
|
591
|
+
if (normalizedClean.startsWith(candidate)) {
|
|
592
|
+
const core = visibleClean.slice(candidate.length).trim();
|
|
593
|
+
return cleanupAffixCore(core);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
// Suffix-form: `<topic> — Executive Brief`
|
|
597
|
+
for (const sep of [' — ', ' – ', ' - ', ': ']) {
|
|
598
|
+
const candidate = `${sep}${prefix}`;
|
|
599
|
+
if (normalizedClean.endsWith(candidate)) {
|
|
600
|
+
const core = visibleClean.slice(0, visibleClean.length - candidate.length).trim();
|
|
601
|
+
return cleanupAffixCore(core);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
// Whole-heading match: `Executive Brief`
|
|
605
|
+
if (normalizedClean === prefix)
|
|
606
|
+
return '';
|
|
607
|
+
}
|
|
608
|
+
// No category label detected — return the heading unchanged.
|
|
609
|
+
return trimmed;
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Tidy the editorial-topic core returned by
|
|
613
|
+
* {@link stripArtifactCategoryAffix}: drop trailing parenthesised
|
|
614
|
+
* metadata (`(2026-05-08)`, `(May 2026)`) and trailing punctuation. When
|
|
615
|
+
* stripping leaves the string too short to be meaningful (<5 chars),
|
|
616
|
+
* return the empty string so callers fall through to lower tiers.
|
|
617
|
+
*
|
|
618
|
+
* @param core - Heading with the category label already stripped
|
|
619
|
+
* @returns Cleaned editorial-topic core, or empty string when too short
|
|
620
|
+
*/
|
|
621
|
+
function cleanupAffixCore(core) {
|
|
622
|
+
const withoutTrailingParens = core.replace(/\s*\([^)]{1,80}\)\s*$/u, '').trim();
|
|
623
|
+
const withoutTrailingPunct = withoutTrailingParens.replace(/[—–:;,.\s-]+$/u, '').trim();
|
|
624
|
+
if (withoutTrailingPunct.length < 5)
|
|
625
|
+
return '';
|
|
626
|
+
return withoutTrailingPunct;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Lower-case, decoration-stripped form used by the artifact-category
|
|
630
|
+
* matchers. Strips inline Markdown, leading non-alphanumeric runs (emoji,
|
|
631
|
+
* decoration), and collapses whitespace to a single space.
|
|
632
|
+
*
|
|
633
|
+
* @param raw - Raw heading text
|
|
634
|
+
* @returns Lower-case normalised form
|
|
635
|
+
*/
|
|
636
|
+
function normaliseCategoryHeading(raw) {
|
|
637
|
+
return stripInlineMarkdown(raw)
|
|
638
|
+
.trim()
|
|
639
|
+
.toLowerCase()
|
|
640
|
+
.replace(/^[^a-z0-9]+/, '')
|
|
641
|
+
.replace(/\s+/g, ' ');
|
|
642
|
+
}
|
|
276
643
|
/**
|
|
277
644
|
* Humanise an `article-type` slug the same way the aggregator does (see
|
|
278
645
|
* `src/aggregator/analysis-aggregator.ts:humanize`). Kept in sync by value
|
|
@@ -304,6 +671,12 @@ export function isGenericHeading(heading, articleType, date) {
|
|
|
304
671
|
const normalized = heading.trim().replace(/\s+/g, ' ');
|
|
305
672
|
if (normalized === '')
|
|
306
673
|
return true;
|
|
674
|
+
// Artefact-category H1s (e.g. `Synthesis Summary — …`, `Executive Brief
|
|
675
|
+
// — …`) are structural labels, not journalist headlines. Treat them as
|
|
676
|
+
// generic so the resolver falls through to the localized template tier
|
|
677
|
+
// and the SEO `<title>` stays clean.
|
|
678
|
+
if (isArtifactCategoryHeading(normalized))
|
|
679
|
+
return true;
|
|
307
680
|
const human = humanizeSlug(articleType);
|
|
308
681
|
const patterns = [
|
|
309
682
|
`${human} — ${date}`,
|
|
@@ -353,38 +726,102 @@ function escapeRegex(input) {
|
|
|
353
726
|
export function extractArtifactHighlight(runDir, articleType, date) {
|
|
354
727
|
if (!runDir || !fs.existsSync(runDir))
|
|
355
728
|
return null;
|
|
356
|
-
// Direct candidate lookup — cheap and deterministic.
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
if (isGenericHeading(headline, articleType, date))
|
|
366
|
-
continue;
|
|
367
|
-
const summary = extractStrongProseLine(body);
|
|
368
|
-
return { headline: truncateTitle(headline), summary };
|
|
369
|
-
}
|
|
729
|
+
// Direct candidate lookup — cheap and deterministic. We collect the
|
|
730
|
+
// first artefact whose body yields a usable lede summary even when its
|
|
731
|
+
// H1 is a structural artefact-category label, so the description tier
|
|
732
|
+
// benefits from the editorial 60-Second Read paragraph in
|
|
733
|
+
// `executive-brief.md` even though its H1 (`Executive Brief — …`) is
|
|
734
|
+
// generic.
|
|
735
|
+
const direct = scanCandidatesForHighlight(runDir, EDITORIAL_ARTEFACT_CANDIDATES, articleType, date);
|
|
736
|
+
if (direct.headline)
|
|
737
|
+
return { headline: direct.headline, summary: direct.summary };
|
|
370
738
|
// Fallback: walk the top-level `.md` files in the run dir once, looking
|
|
371
739
|
// for any that starts with `#` and has a non-generic headline.
|
|
372
|
-
const topLevel = safeReaddir(runDir).filter((f) => f.endsWith('.md'));
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
const summary = extractStrongProseLine(body);
|
|
384
|
-
return { headline: truncateTitle(headline), summary };
|
|
740
|
+
const topLevel = safeReaddir(runDir).filter((f) => f.endsWith('.md') && f !== 'manifest.json');
|
|
741
|
+
const fallback = scanCandidatesForHighlight(runDir, topLevel, articleType, date);
|
|
742
|
+
if (fallback.headline)
|
|
743
|
+
return { headline: fallback.headline, summary: fallback.summary };
|
|
744
|
+
// No editorial headline was found, but we may have harvested a strong
|
|
745
|
+
// lede summary from one of the editorial artefacts. Returning a
|
|
746
|
+
// headline-less highlight lets `resolveEditorialContent` keep the
|
|
747
|
+
// editorial summary while falling back to the localized title template.
|
|
748
|
+
const summaryOnly = direct.summary || fallback.summary;
|
|
749
|
+
if (summaryOnly) {
|
|
750
|
+
return { headline: '', summary: summaryOnly };
|
|
385
751
|
}
|
|
386
752
|
return null;
|
|
387
753
|
}
|
|
754
|
+
/**
|
|
755
|
+
* Walk a list of candidate artefact paths and return the first
|
|
756
|
+
* non-generic headline + summary pair, plus the first usable lede
|
|
757
|
+
* summary seen along the way. Extracted from
|
|
758
|
+
* {@link extractArtifactHighlight} to keep its cognitive complexity
|
|
759
|
+
* within the SonarJS budget.
|
|
760
|
+
*
|
|
761
|
+
* @param runDir - Absolute run directory path
|
|
762
|
+
* @param candidates - Run-relative candidate filenames to probe
|
|
763
|
+
* @param articleType - Article-type slug (used by {@link isGenericHeading})
|
|
764
|
+
* @param date - ISO run date (used by {@link isGenericHeading})
|
|
765
|
+
* @returns `{headline, summary}` where either field may be empty
|
|
766
|
+
*/
|
|
767
|
+
function scanCandidatesForHighlight(runDir, candidates, articleType, date) {
|
|
768
|
+
let bestSummaryOnly = '';
|
|
769
|
+
for (const rel of candidates) {
|
|
770
|
+
const probe = probeCandidateForHighlight(runDir, rel, articleType, date);
|
|
771
|
+
// Both clean and stripped highlights win the loop — they come from
|
|
772
|
+
// the highest-priority artefact that yielded usable text. This
|
|
773
|
+
// preserves the priority order of {@link EDITORIAL_ARTEFACT_CANDIDATES}
|
|
774
|
+
// (executive-brief > synthesis-summary > …) so a stripped headline
|
|
775
|
+
// from `executive-brief.md` beats a clean H1 from a lower-priority
|
|
776
|
+
// artefact like `intelligence/synthesis-summary.md`.
|
|
777
|
+
if (probe.cleanHighlight)
|
|
778
|
+
return probe.cleanHighlight;
|
|
779
|
+
if (probe.strippedHeadline) {
|
|
780
|
+
return { headline: probe.strippedHeadline, summary: probe.summary ?? bestSummaryOnly };
|
|
781
|
+
}
|
|
782
|
+
if (!bestSummaryOnly && probe.summary) {
|
|
783
|
+
bestSummaryOnly = probe.summary;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return { headline: '', summary: bestSummaryOnly };
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Read a single candidate artefact and classify what it can contribute
|
|
790
|
+
* to the highlight resolver. Extracted from
|
|
791
|
+
* {@link scanCandidatesForHighlight} to keep its cognitive complexity
|
|
792
|
+
* within the SonarJS budget.
|
|
793
|
+
*
|
|
794
|
+
* @param runDir - Absolute run directory
|
|
795
|
+
* @param rel - Run-relative artefact path
|
|
796
|
+
* @param articleType - Article-type slug for {@link isGenericHeading}
|
|
797
|
+
* @param date - ISO run date for {@link isGenericHeading}
|
|
798
|
+
* @returns
|
|
799
|
+
* - `cleanHighlight` when the artefact has a non-generic H1 (caller may
|
|
800
|
+
* return it directly)
|
|
801
|
+
* - `strippedHeadline` when the H1 is generic but yields an editorial
|
|
802
|
+
* core after {@link stripArtifactCategoryAffix}
|
|
803
|
+
* - `summary` when the artefact carries a usable lede or strong prose
|
|
804
|
+
* line (independent of the headline outcome)
|
|
805
|
+
*/
|
|
806
|
+
function probeCandidateForHighlight(runDir, rel, articleType, date) {
|
|
807
|
+
const abs = path.join(runDir, rel);
|
|
808
|
+
if (!fs.existsSync(abs))
|
|
809
|
+
return {};
|
|
810
|
+
const body = readArtefactBody(abs);
|
|
811
|
+
const headline = extractFirstH1(body);
|
|
812
|
+
const lede = extractLedeAfterHeading(body);
|
|
813
|
+
const summary = lede || extractStrongProseLine(body);
|
|
814
|
+
if (headline && !isGenericHeading(headline, articleType, date)) {
|
|
815
|
+
return { cleanHighlight: { headline: truncateTitle(headline), summary } };
|
|
816
|
+
}
|
|
817
|
+
if (headline) {
|
|
818
|
+
const stripped = stripArtifactCategoryAffix(headline);
|
|
819
|
+
if (stripped && !isGenericHeading(stripped, articleType, date)) {
|
|
820
|
+
return { strippedHeadline: truncateTitle(stripped), summary };
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
return { summary };
|
|
824
|
+
}
|
|
388
825
|
/**
|
|
389
826
|
* Read an artefact file, skipping any SPDX HTML-comment header rows so the
|
|
390
827
|
* first-H1 / first-prose logic is never derailed by the REUSE preamble.
|
|
@@ -763,7 +1200,13 @@ function manifestOverrideFor(value, lang) {
|
|
|
763
1200
|
*/
|
|
764
1201
|
function resolveEditorialContent(opts) {
|
|
765
1202
|
const { articleType, date, markdown, runDir } = opts;
|
|
766
|
-
// Tier 2: first non-generic H1 in the first substantive artefact.
|
|
1203
|
+
// Tier 2: first non-generic H1 in the first substantive artefact. We
|
|
1204
|
+
// also remember any editorial summary harvested from a category-only
|
|
1205
|
+
// artefact (e.g. `executive-brief.md` whose H1 is the structural
|
|
1206
|
+
// `Executive Brief — …` label but whose `## 60-Second Read` paragraph
|
|
1207
|
+
// is the journalist's lede) so the description tier can still benefit
|
|
1208
|
+
// from real editorial copy when the headline tier falls through.
|
|
1209
|
+
let artefactSummary = '';
|
|
767
1210
|
if (runDir) {
|
|
768
1211
|
const highlight = extractArtifactHighlight(runDir, articleType, date);
|
|
769
1212
|
if (highlight?.headline) {
|
|
@@ -772,6 +1215,9 @@ function resolveEditorialContent(opts) {
|
|
|
772
1215
|
summary: highlight.summary,
|
|
773
1216
|
};
|
|
774
1217
|
}
|
|
1218
|
+
if (highlight?.summary) {
|
|
1219
|
+
artefactSummary = highlight.summary;
|
|
1220
|
+
}
|
|
775
1221
|
}
|
|
776
1222
|
// Tier 3: first non-generic H1 in the aggregated Markdown itself.
|
|
777
1223
|
const aggregatedH1 = extractFirstH1(markdown);
|
|
@@ -779,12 +1225,16 @@ function resolveEditorialContent(opts) {
|
|
|
779
1225
|
if (aggregatedH1 && !isGenericHeading(aggregatedH1, articleType, date)) {
|
|
780
1226
|
return {
|
|
781
1227
|
headline: truncateTitle(aggregatedH1),
|
|
782
|
-
summary: aggregatedSummary,
|
|
1228
|
+
summary: artefactSummary || aggregatedSummary,
|
|
783
1229
|
};
|
|
784
1230
|
}
|
|
785
1231
|
// Tier 4: first strong prose paragraph (title = same prose clipped).
|
|
786
|
-
|
|
787
|
-
|
|
1232
|
+
// Prefer the artefact-derived editorial summary when available so the
|
|
1233
|
+
// description carries the journalist's lede rather than the
|
|
1234
|
+
// aggregator-walk leftover.
|
|
1235
|
+
const summary = artefactSummary || aggregatedSummary;
|
|
1236
|
+
if (summary) {
|
|
1237
|
+
return { headline: truncateTitle(summary), summary };
|
|
788
1238
|
}
|
|
789
1239
|
return { headline: '', summary: '' };
|
|
790
1240
|
}
|
|
@@ -58,6 +58,7 @@ const A_COMPARATIVE_INTL = 'extended/comparative-international.md';
|
|
|
58
58
|
const A_EXEC_BRIEF = 'extended/executive-brief.md';
|
|
59
59
|
const A_DEVILS_ADVOCATE = 'extended/devils-advocate-analysis.md';
|
|
60
60
|
const A_INTEL_ASSESSMENT = 'extended/intelligence-assessment.md';
|
|
61
|
+
const A_MEDIA_FRAMING = 'extended/media-framing-analysis.md';
|
|
61
62
|
const A_DEEP_ANALYSIS_EXISTING = 'existing/deep-analysis.md';
|
|
62
63
|
/** Stage budgets shared by the four short/mid prospective horizons.
|
|
63
64
|
* Sum 35 (A=5, B=22, C=4, D=2, E=2). Per-family B1→B2,
|
|
@@ -86,7 +87,13 @@ const STANDARD_FEEDS = [
|
|
|
86
87
|
'get_external_documents',
|
|
87
88
|
];
|
|
88
89
|
/** Mandatory artifacts shared by every prospective horizon. Long-horizon
|
|
89
|
-
* variants additionally require `forward-projection.md`.
|
|
90
|
+
* variants additionally require `forward-projection.md`.
|
|
91
|
+
*
|
|
92
|
+
* `extended/media-framing-analysis.md` is mandatory for every horizon —
|
|
93
|
+
* see [`analytical-supplementary-methodology.md` §AS4](../../analysis/methodologies/analytical-supplementary-methodology.md)
|
|
94
|
+
* and [`per-artifact-methodologies.md` §media-framing-analysis](../../analysis/methodologies/per-artifact-methodologies.md).
|
|
95
|
+
* Agents produce it during Pass 2 (or late Pass 1) once the rest of the
|
|
96
|
+
* context is in place. */
|
|
90
97
|
const PROSPECTIVE_MANDATORY = [
|
|
91
98
|
A_SIGNIFICANCE,
|
|
92
99
|
A_ACTOR_MAP,
|
|
@@ -105,9 +112,15 @@ const PROSPECTIVE_MANDATORY = [
|
|
|
105
112
|
A_THREAT,
|
|
106
113
|
A_MCP_AUDIT,
|
|
107
114
|
A_INDEX,
|
|
115
|
+
A_MEDIA_FRAMING,
|
|
108
116
|
A_REFLECTION,
|
|
109
117
|
];
|
|
110
|
-
/** Mandatory artifacts shared by every retrospective horizon.
|
|
118
|
+
/** Mandatory artifacts shared by every retrospective horizon.
|
|
119
|
+
*
|
|
120
|
+
* `extended/media-framing-analysis.md` is mandatory across every horizon
|
|
121
|
+
* (see PROSPECTIVE_MANDATORY) — review runs build it from the same Pass-2
|
|
122
|
+
* read-back so framing analysis lands after the underlying voting,
|
|
123
|
+
* stakeholder and coalition artifacts are stable. */
|
|
111
124
|
const RETROSPECTIVE_MANDATORY = [
|
|
112
125
|
A_SIGNIFICANCE,
|
|
113
126
|
A_ACTOR_MAP,
|
|
@@ -125,6 +138,7 @@ const RETROSPECTIVE_MANDATORY = [
|
|
|
125
138
|
A_THREAT,
|
|
126
139
|
A_MCP_AUDIT,
|
|
127
140
|
A_INDEX,
|
|
141
|
+
A_MEDIA_FRAMING,
|
|
128
142
|
A_REFLECTION,
|
|
129
143
|
];
|
|
130
144
|
/** Mandatory artifacts unique to long-horizon prospective runs. */
|
|
@@ -345,6 +359,7 @@ export const ARTICLE_HORIZONS = {
|
|
|
345
359
|
A_THREAT,
|
|
346
360
|
A_MCP_AUDIT,
|
|
347
361
|
A_INDEX,
|
|
362
|
+
A_MEDIA_FRAMING,
|
|
348
363
|
A_REFLECTION,
|
|
349
364
|
],
|
|
350
365
|
optionalArtifacts: [A_EXEC_BRIEF],
|
|
@@ -292,7 +292,9 @@ export function generateIndexHTML(lang, articles, metaMap = new Map()) {
|
|
|
292
292
|
<link rel="icon" type="image/png" sizes="16x16" href="images/favicon-16x16.png">
|
|
293
293
|
<link rel="apple-touch-icon" sizes="180x180" href="images/apple-touch-icon.png">
|
|
294
294
|
<link rel="manifest" href="site.webmanifest">
|
|
295
|
-
<meta name="
|
|
295
|
+
<meta name="color-scheme" content="light dark">
|
|
296
|
+
<meta name="theme-color" content="#003399" media="(prefers-color-scheme: light)">
|
|
297
|
+
<meta name="theme-color" content="#0a1a38" media="(prefers-color-scheme: dark)">
|
|
296
298
|
<link rel="alternate" type="application/rss+xml" title="EU Parliament Monitor RSS" href="rss.xml">
|
|
297
299
|
<link rel="stylesheet" href="styles.css?v=${BUILD_SHORT}">
|
|
298
300
|
${buildHeadFreshnessTags('')}
|
|
@@ -7,7 +7,7 @@ import { MCPConnection } from './mcp-connection.js';
|
|
|
7
7
|
import type { MCPClientOptions, MCPToolResult, GetMEPsOptions, GetPlenarySessionsOptions, SearchDocumentsOptions, GetParliamentaryQuestionsOptions, GetCommitteeInfoOptions, MonitorLegislativePipelineOptions, AssessMEPInfluenceOptions, AnalyzeCoalitionDynamicsOptions, DetectVotingAnomaliesOptions, ComparePoliticalGroupsOptions, VotingRecordsOptions, VotingPatternsOptions, GenerateReportOptions, AnalyzeLegislativeEffectivenessOptions, AnalyzeCommitteeActivityOptions, TrackMEPAttendanceOptions, AnalyzeCountryDelegationOptions, GeneratePoliticalLandscapeOptions, GetCurrentMEPsOptions, GetSpeechesOptions, GetProceduresOptions, GetAdoptedTextsOptions, GetEventsOptions, GetMeetingActivitiesOptions, GetMeetingDecisionsOptions, GetMEPDeclarationsOptions, GetIncomingMEPsOptions, GetOutgoingMEPsOptions, GetHomonymMEPsOptions, GetLatestVotesOptions, GetPlenaryDocumentsOptions, GetCommitteeDocumentsOptions, GetPlenarySessionDocumentsOptions, GetPlenarySessionDocumentItemsOptions, GetControlledVocabulariesOptions, GetExternalDocumentsOptions, GetMeetingForeseenActivitiesOptions, GetProcedureEventsOptions, GetMeetingPlenarySessionDocumentsOptions, GetMeetingPlenarySessionDocumentItemsOptions, NetworkAnalysisOptions, SentimentTrackerOptions, EarlyWarningSystemOptions, ComparativeIntelligenceOptions, CorrelateIntelligenceOptions, GetAllGeneratedStatsOptions, GetMEPsFeedOptions, GetEventsFeedOptions, GetProceduresFeedOptions, GetAdoptedTextsFeedOptions, GetMEPDeclarationsFeedOptions, GetDocumentsFeedOptions, GetPlenaryDocumentsFeedOptions, GetCommitteeDocumentsFeedOptions, GetPlenarySessionDocumentsFeedOptions, GetExternalDocumentsFeedOptions, GetParliamentaryQuestionsFeedOptions, GetCorporateBodiesFeedOptions, GetControlledVocabulariesFeedOptions, GetProcedureEventByIdOptions, GetFreshProceduresOptions } from '../types/index.js';
|
|
8
8
|
/**
|
|
9
9
|
* Canonical list of tools exposed by the European Parliament MCP gateway
|
|
10
|
-
* (`european-parliament-mcp-server@1.3.
|
|
10
|
+
* (`european-parliament-mcp-server@1.3.2`). The news workflows, prompt
|
|
11
11
|
* library (`.github/prompts/07-mcp-reference.md`), and the integration test
|
|
12
12
|
* suite all reference this list so a regression that adds/removes a tool
|
|
13
13
|
* fails a single drift guard
|
|
@@ -22,7 +22,7 @@ export declare const EP_MCP_TOOLS: readonly string[];
|
|
|
22
22
|
* covering the two shapes historically emitted by the EP MCP server.
|
|
23
23
|
*
|
|
24
24
|
* 1. **Uniform envelope** (all feeds as of
|
|
25
|
-
* `european-parliament-mcp-server@1.3.
|
|
25
|
+
* `european-parliament-mcp-server@1.3.2`) —
|
|
26
26
|
* `{status:"unavailable", items:[], generatedAt:"..."}` established by
|
|
27
27
|
* Hack23/European-Parliament-MCP-Server#301 and extended to
|
|
28
28
|
* `get_events_feed`/`get_procedures_feed` by
|
|
@@ -206,9 +206,9 @@ export declare class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
206
206
|
*
|
|
207
207
|
* @remarks
|
|
208
208
|
* This repository is currently documented/configured against
|
|
209
|
-
* `european-parliament-mcp-server@1.3.
|
|
209
|
+
* `european-parliament-mcp-server@1.3.2`.
|
|
210
210
|
*
|
|
211
|
-
* **Upstream date-filter contract (v1.2.14+, active on the pinned v1.3.
|
|
211
|
+
* **Upstream date-filter contract (v1.2.14+, active on the pinned v1.3.2 server):** the upstream server
|
|
212
212
|
* applies a server-side post-filter on `dateFrom`/`dateTo` before serialisation, because the
|
|
213
213
|
* EP Open Data Portal `/meetings` endpoint silently ignores its `date-from`/`date-to` query
|
|
214
214
|
* parameters (Defect #5). Under this contract:
|
|
@@ -217,7 +217,7 @@ export declare class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
217
217
|
* - Per-window session counts are reproducible because the EP-side regression is masked by
|
|
218
218
|
* the upstream post-filter.
|
|
219
219
|
*
|
|
220
|
-
* No local post-filter is applied here. The repository is pinned to v1.3.
|
|
220
|
+
* No local post-filter is applied here. The repository is pinned to v1.3.2, so the
|
|
221
221
|
* date-filter guarantees above apply; consumers running against an older server image
|
|
222
222
|
* (pre-v1.2.14) must not assume them.
|
|
223
223
|
*/
|
|
@@ -529,7 +529,7 @@ export declare class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
529
529
|
* dataSource: EP_DOCEO_XML) — typically available within minutes of a
|
|
530
530
|
* plenary vote, unlike the regular EP API which has a multi-week delay.
|
|
531
531
|
*
|
|
532
|
-
* New in `european-parliament-mcp-server@1.3.
|
|
532
|
+
* New in `european-parliament-mcp-server@1.3.1`.
|
|
533
533
|
*
|
|
534
534
|
* @param options - Pagination options
|
|
535
535
|
* @returns Latest plenary vote records with NEAR_REALTIME freshness
|
|
@@ -11,7 +11,7 @@ import { recordPendingDocument, markDocumentResolved, getPendingDocumentsForRepr
|
|
|
11
11
|
import { EP_NEXT_ELECTION_START, EP_NEXT_ELECTION_END, EP_CURRENT_TERM, EP_NEXT_TERM, } from '../constants/config.js';
|
|
12
12
|
/**
|
|
13
13
|
* Canonical list of tools exposed by the European Parliament MCP gateway
|
|
14
|
-
* (`european-parliament-mcp-server@1.3.
|
|
14
|
+
* (`european-parliament-mcp-server@1.3.2`). The news workflows, prompt
|
|
15
15
|
* library (`.github/prompts/07-mcp-reference.md`), and the integration test
|
|
16
16
|
* suite all reference this list so a regression that adds/removes a tool
|
|
17
17
|
* fails a single drift guard
|
|
@@ -116,7 +116,7 @@ const CONTENT_NOT_YET_AVAILABLE_SUBSTRING = 'document indexed but content not ye
|
|
|
116
116
|
/**
|
|
117
117
|
* Classify an error message into a diagnostic error category.
|
|
118
118
|
*
|
|
119
|
-
* Maps EP MCP Server v1.3.
|
|
119
|
+
* Maps EP MCP Server v1.3.2 structured error codes and generic HTTP/network
|
|
120
120
|
* errors into one of six broad categories used for logging and retry decisions:
|
|
121
121
|
*
|
|
122
122
|
* Returned categories (priority order):
|
|
@@ -132,7 +132,7 @@ const CONTENT_NOT_YET_AVAILABLE_SUBSTRING = 'document indexed but content not ye
|
|
|
132
132
|
*/
|
|
133
133
|
function classifyToolError(message) {
|
|
134
134
|
const lowerMsg = message.toLowerCase();
|
|
135
|
-
// EP MCP Server v1.3.
|
|
135
|
+
// EP MCP Server v1.3.2 structured error codes (matched case-insensitively)
|
|
136
136
|
if (lowerMsg.includes('internal_error')) {
|
|
137
137
|
return 'INTERNAL_ERROR';
|
|
138
138
|
}
|
|
@@ -191,7 +191,7 @@ function _parseResultPayload(result) {
|
|
|
191
191
|
* covering the two shapes historically emitted by the EP MCP server.
|
|
192
192
|
*
|
|
193
193
|
* 1. **Uniform envelope** (all feeds as of
|
|
194
|
-
* `european-parliament-mcp-server@1.3.
|
|
194
|
+
* `european-parliament-mcp-server@1.3.2`) —
|
|
195
195
|
* `{status:"unavailable", items:[], generatedAt:"..."}` established by
|
|
196
196
|
* Hack23/European-Parliament-MCP-Server#301 and extended to
|
|
197
197
|
* `get_events_feed`/`get_procedures_feed` by
|
|
@@ -592,9 +592,9 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
592
592
|
*
|
|
593
593
|
* @remarks
|
|
594
594
|
* This repository is currently documented/configured against
|
|
595
|
-
* `european-parliament-mcp-server@1.3.
|
|
595
|
+
* `european-parliament-mcp-server@1.3.2`.
|
|
596
596
|
*
|
|
597
|
-
* **Upstream date-filter contract (v1.2.14+, active on the pinned v1.3.
|
|
597
|
+
* **Upstream date-filter contract (v1.2.14+, active on the pinned v1.3.2 server):** the upstream server
|
|
598
598
|
* applies a server-side post-filter on `dateFrom`/`dateTo` before serialisation, because the
|
|
599
599
|
* EP Open Data Portal `/meetings` endpoint silently ignores its `date-from`/`date-to` query
|
|
600
600
|
* parameters (Defect #5). Under this contract:
|
|
@@ -603,7 +603,7 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
603
603
|
* - Per-window session counts are reproducible because the EP-side regression is masked by
|
|
604
604
|
* the upstream post-filter.
|
|
605
605
|
*
|
|
606
|
-
* No local post-filter is applied here. The repository is pinned to v1.3.
|
|
606
|
+
* No local post-filter is applied here. The repository is pinned to v1.3.2, so the
|
|
607
607
|
* date-filter guarantees above apply; consumers running against an older server image
|
|
608
608
|
* (pre-v1.2.14) must not assume them.
|
|
609
609
|
*/
|
|
@@ -1111,7 +1111,7 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
1111
1111
|
* dataSource: EP_DOCEO_XML) — typically available within minutes of a
|
|
1112
1112
|
* plenary vote, unlike the regular EP API which has a multi-week delay.
|
|
1113
1113
|
*
|
|
1114
|
-
* New in `european-parliament-mcp-server@1.3.
|
|
1114
|
+
* New in `european-parliament-mcp-server@1.3.1`.
|
|
1115
1115
|
*
|
|
1116
1116
|
* @param options - Pagination options
|
|
1117
1117
|
* @returns Latest plenary vote records with NEAR_REALTIME freshness
|
|
@@ -122,9 +122,8 @@ export declare function buildSiteHeader(options: SiteHeaderOptions): string;
|
|
|
122
122
|
/**
|
|
123
123
|
* Build the full-width page banner shown below the sticky site header on every page.
|
|
124
124
|
*
|
|
125
|
-
* The banner image (`banner.webp` / `banner.jpg`) is 1200×400. CSS renders it
|
|
126
|
-
*
|
|
127
|
-
* always visible and the uninteresting top/bottom 10% may be cropped.
|
|
125
|
+
* The banner image (`banner.webp` / `banner.jpg`) is 1200×400. CSS renders it
|
|
126
|
+
* at its native 3:1 ratio so the full artwork remains visible on every viewport.
|
|
128
127
|
*
|
|
129
128
|
* @param pathPrefix - Asset path prefix: `''` for root pages, `'../'` for `news/` pages.
|
|
130
129
|
* @returns HTML string for the `.page-banner` element.
|
|
@@ -300,7 +300,7 @@ export function buildSiteHeader(options) {
|
|
|
300
300
|
<a href="${escapeHTML(homeHref)}" class="site-header__brand" aria-label="${safeTitle}">
|
|
301
301
|
<picture class="site-header__logo-picture">
|
|
302
302
|
<source srcset="${pathPrefix}images/banner.webp" type="image/webp">
|
|
303
|
-
<img class="site-header__logo site-header__logo--banner" src="${pathPrefix}images/banner.jpg" alt="${safeTitle}" width="
|
|
303
|
+
<img class="site-header__logo site-header__logo--banner" src="${pathPrefix}images/banner.jpg" alt="${safeTitle}" width="240" height="80" loading="eager">
|
|
304
304
|
</picture>
|
|
305
305
|
<span class="site-header__brand-text">
|
|
306
306
|
<span class="site-header__title">${safeTitle}</span>
|
|
@@ -308,10 +308,14 @@ export function buildSiteHeader(options) {
|
|
|
308
308
|
</span>
|
|
309
309
|
</a>
|
|
310
310
|
<div class="site-header__actions">
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
311
|
+
<div class="site-header__cta-group">
|
|
312
|
+
${piCta}${cta('site-header__cta--sponsor', 'https://github.com/sponsors/Hack23', 'heart', sponsorLabel)}
|
|
313
|
+
${cta('', 'https://www.hack23.com', 'sponsor', becomeSponsorLabel)}
|
|
314
|
+
${cta('site-header__cta--security', 'https://github.com/Hack23/euparliamentmonitor/blob/main/SECURITY.md', ICON_SECURITY, securityLabel)}
|
|
315
|
+
</div>
|
|
316
|
+
<div class="site-header__theme-toggle-slot">
|
|
317
|
+
${createThemeToggleButton(themeToggleLabel)}
|
|
318
|
+
</div>
|
|
315
319
|
</div>
|
|
316
320
|
<nav class="site-header__langs" role="navigation" aria-label="${langSelectionLabel}">
|
|
317
321
|
${languageSwitcherHtml}
|
|
@@ -322,9 +326,8 @@ export function buildSiteHeader(options) {
|
|
|
322
326
|
/**
|
|
323
327
|
* Build the full-width page banner shown below the sticky site header on every page.
|
|
324
328
|
*
|
|
325
|
-
* The banner image (`banner.webp` / `banner.jpg`) is 1200×400. CSS renders it
|
|
326
|
-
*
|
|
327
|
-
* always visible and the uninteresting top/bottom 10% may be cropped.
|
|
329
|
+
* The banner image (`banner.webp` / `banner.jpg`) is 1200×400. CSS renders it
|
|
330
|
+
* at its native 3:1 ratio so the full artwork remains visible on every viewport.
|
|
328
331
|
*
|
|
329
332
|
* @param pathPrefix - Asset path prefix: `''` for root pages, `'../'` for `news/` pages.
|
|
330
333
|
* @returns HTML string for the `.page-banner` element.
|
|
@@ -412,12 +415,12 @@ export function buildSiteFooter(options) {
|
|
|
412
415
|
return `<footer class="site-footer" role="contentinfo">
|
|
413
416
|
<div class="footer-content">
|
|
414
417
|
<div class="footer-section">
|
|
415
|
-
<h3>${aboutHeading}</h3>
|
|
418
|
+
<h3 class="footer-section__heading">${aboutHeading}</h3>
|
|
416
419
|
<p>${aboutText}</p>${articlesLine}
|
|
417
420
|
<p class="footer-company-summary">${companyTagline}</p>
|
|
418
421
|
</div>
|
|
419
422
|
<div class="footer-section">
|
|
420
|
-
<h3>${quickLinksHeading}</h3>
|
|
423
|
+
<h3 class="footer-section__heading">${quickLinksHeading}</h3>
|
|
421
424
|
<ul>
|
|
422
425
|
<li><a href="${homeHref}">${icon('home')}<span>${homeLabel}</span></a></li>
|
|
423
426
|
<li><a href="${homeHref}#main">${icon('news')}<span>${newsLabel}</span></a></li>
|
|
@@ -438,7 +441,7 @@ export function buildSiteFooter(options) {
|
|
|
438
441
|
</ul>
|
|
439
442
|
</div>
|
|
440
443
|
<div class="footer-section">
|
|
441
|
-
<h3>${builtByHeading}</h3>
|
|
444
|
+
<h3 class="footer-section__heading">${builtByHeading}</h3>
|
|
442
445
|
<div class="footer-badges" aria-label="${escapeHTML(getLocalizedString(FOOTER_TRUST_BADGES_ARIA_LABELS, lang))}">
|
|
443
446
|
<a href="https://www.npmjs.com/package/euparliamentmonitor" aria-label="npm package version"><img src="https://img.shields.io/npm/v/euparliamentmonitor.svg" alt="npm package version"></a>
|
|
444
447
|
<a href="https://scorecard.dev/viewer/?uri=github.com/Hack23/euparliamentmonitor" aria-label="OpenSSF Scorecard"><img src="https://api.securityscorecards.dev/projects/github.com/Hack23/euparliamentmonitor/badge" alt="OpenSSF Scorecard"></a>
|
|
@@ -468,7 +471,7 @@ export function buildSiteFooter(options) {
|
|
|
468
471
|
</ul>
|
|
469
472
|
</div>
|
|
470
473
|
<div class="footer-section">
|
|
471
|
-
<h3>${icon('lang')} ${languagesHeading}</h3>
|
|
474
|
+
<h3 class="footer-section__heading">${icon('lang')} ${languagesHeading}</h3>
|
|
472
475
|
<div class="language-grid">
|
|
473
476
|
${langGrid}
|
|
474
477
|
</div>
|
package/scripts/types/mcp.d.ts
CHANGED
|
@@ -287,7 +287,7 @@ export interface GetHomonymMEPsOptions {
|
|
|
287
287
|
limit?: number | undefined;
|
|
288
288
|
offset?: number | undefined;
|
|
289
289
|
}
|
|
290
|
-
/** Options for getLatestVotes (DOCEO-backed near-real-time vote enrichment, new in v1.3.
|
|
290
|
+
/** Options for getLatestVotes (DOCEO-backed near-real-time vote enrichment, new in v1.3.1) */
|
|
291
291
|
export interface GetLatestVotesOptions {
|
|
292
292
|
/** Maximum number of recent vote records to return (default: 50) */
|
|
293
293
|
limit?: number | undefined;
|