euparliamentmonitor 0.8.53 → 0.8.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +14 -8
  2. package/package.json +4 -2
  3. package/scripts/aggregator/analysis-aggregator.d.ts +7 -20
  4. package/scripts/aggregator/analysis-aggregator.js +39 -14
  5. package/scripts/aggregator/article-generator.d.ts +6 -0
  6. package/scripts/aggregator/article-generator.js +62 -0
  7. package/scripts/aggregator/article-html.js +52 -8
  8. package/scripts/aggregator/article-meta.d.ts +121 -0
  9. package/scripts/aggregator/article-meta.js +320 -0
  10. package/scripts/aggregator/artifact-order.js +1 -1
  11. package/scripts/aggregator/forward-statements-registry.js +52 -3
  12. package/scripts/aggregator/key-takeaways.d.ts +72 -0
  13. package/scripts/aggregator/key-takeaways.js +213 -0
  14. package/scripts/aggregator/lead-extractor.d.ts +30 -0
  15. package/scripts/aggregator/lead-extractor.js +202 -0
  16. package/scripts/aggregator/markdown-renderer.js +20 -0
  17. package/scripts/aggregator/pipeline-transit-model.js +589 -0
  18. package/scripts/aggregator/reader-guide-constants.d.ts +35 -0
  19. package/scripts/aggregator/reader-guide-constants.js +23 -0
  20. package/scripts/aggregator/reader-intelligence-guide.d.ts +46 -0
  21. package/scripts/aggregator/reader-intelligence-guide.js +426 -0
  22. package/scripts/check-election-tier.js +105 -0
  23. package/scripts/config/article-horizons.d.ts +14 -2
  24. package/scripts/config/article-horizons.js +22 -13
  25. package/scripts/constants/config.d.ts +10 -3
  26. package/scripts/constants/config.js +35 -2
  27. package/scripts/constants/language-ui.d.ts +20 -0
  28. package/scripts/constants/language-ui.js +188 -14
  29. package/scripts/constants/languages.d.ts +1 -1
  30. package/scripts/constants/languages.js +1 -1
  31. package/scripts/generators/news-indexes.js +111 -6
  32. package/scripts/generators/political-intelligence/html.js +68 -11
  33. package/scripts/generators/seo-copy.d.ts +44 -0
  34. package/scripts/generators/seo-copy.js +398 -0
  35. package/scripts/generators/sitemap/html.js +75 -4
  36. package/scripts/lint-prompts.js +59 -0
  37. package/scripts/mcp/ep-mcp-client.d.ts +5 -5
  38. package/scripts/mcp/ep-mcp-client.js +7 -7
  39. package/scripts/templates/icons.d.ts +6 -1
  40. package/scripts/templates/icons.js +32 -1
  41. package/scripts/templates/section-builders.d.ts +7 -0
  42. package/scripts/templates/section-builders.js +78 -41
  43. package/scripts/templates/sync-template-frontmatter.js +385 -0
  44. package/scripts/validate-analysis-completeness.js +58 -0
package/README.md CHANGED
@@ -55,7 +55,7 @@ This repository is the open-source platform behind **[euparliamentmonitor.com](h
55
55
  | 🧠 **Political Intelligence** | Structured analytic techniques (ACH, SWOT/TOWS, PESTLE, scenario forecasting, devil's-advocate), Admiralty source grading, Words of Estimative Probability (WEP) bands, ICD-203 standards, and a 6-dimension political-threat framework — applied daily to live EP data. |
56
56
  | 🔍 **Radical Transparency** | Every article links back to the analysis run it was rendered from. Every artifact links to its methodology. Every methodology is published. No black box. |
57
57
  | 🗳️ **Democratic Accountability** | Public-data only. GDPR-clean. No personal profiling. Multi-language so a Finnish farmer, a Greek student, and a Polish journalist all get the same intelligence in their own language. |
58
- | 🤖 **AI-Generated News** | 8 unified gh-aw agentic workflows + 1 translation workflow run autonomously, produce structured analysis, render deterministic HTML, and open publication-ready pull requests for human review. |
58
+ | 🤖 **AI-Generated News** | 14 unified gh-aw agentic workflows + 1 translation workflow run autonomously, produce structured analysis, render deterministic HTML, and open publication-ready pull requests for human review. |
59
59
 
60
60
  ### Documentation & Reports
61
61
  [![API Docs](https://img.shields.io/badge/API-Documentation-blue?logo=javascript)](https://euparliamentmonitor.com/docs/api/)
@@ -124,7 +124,7 @@ The published site is the audience-facing companion to this npm/TypeScript packa
124
124
  - [Agent Catalog](.github/agents/README.md) — custom Copilot agents (analysis producers / consumers / gh-aw infrastructure)
125
125
  - [Skills Library](.github/skills/README.md) — shared skills (security, compliance, intelligence, gh-aw)
126
126
  - [Prompt Library](.github/prompts/README.md) — 10-file bounded-context prompt set (`00`→`09`) + `npm run lint:prompts` drift-guard
127
- - [Workflows](.github/workflows/README.md) + [WORKFLOWS.md](WORKFLOWS.md) — 9 `news-*.md` agentic workflows (8 unified `news-<type>.md` + `news-translate.md`) + CI workflows
127
+ - [Workflows](.github/workflows/README.md) + [WORKFLOWS.md](WORKFLOWS.md) — 15 `news-*.md` agentic workflows (14 unified `news-<type>.md` covering 14 article types — including the long-horizon `quarter-ahead`/`quarter-in-review`/`year-ahead`/`year-in-review`/`term-outlook`/`election-cycle` set added in 2026-Q2 — plus `news-translate.md`) + CI workflows
128
128
  - [Analysis Chain](analysis/README.md) — 5-stage pipeline (Data → Analysis → Completeness Gate → Article → Single PR), methodologies, 39 templates, quality thresholds
129
129
 
130
130
  **🔒 ISMS Compliance:**
@@ -136,11 +136,11 @@ 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.2.19 for accessing real EU Parliament data via the Model Context Protocol.
139
+ v1.2.20 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)
143
- - **Agentic Workflows**: 9 unified gh-aw markdown workflows — 8 article types (`news-<type>.md`, Stages A → B → C → D → E in one session) + `news-translate.md` (14-language flush translation) — compiled with
143
+ - **Agentic Workflows**: 15 unified gh-aw markdown workflows — 14 article types (`news-<type>.md`, Stages A → B → C → D → E in one session) + `news-translate.md` (14-language flush translation) — compiled with
144
144
  `gh-aw v0.69.3` (pin in `.github/workflows/compile-agentic-workflows.yml`) to `.lock.yml` for automated news generation with AI-driven political
145
145
  intelligence analysis. See [`.github/workflows/README.md`](.github/workflows/README.md).
146
146
  - **Analysis-Artifact-Driven Article Pipeline**: Agents author the full
@@ -338,21 +338,27 @@ flowchart LR
338
338
 
339
339
  ## 📰 Live News Streams
340
340
 
341
- **Eight unified gh-aw article workflows** plus a **14-language translation helper** run on precision schedules, autonomously generating *Economist-style* political intelligence and opening publication-ready pull requests. Every workflow follows the same Stage A → E contract documented in [Article-Generation.md](Article-Generation.md).
341
+ **Fourteen unified gh-aw article workflows** plus a **14-language translation helper** run on precision schedules, autonomously generating *Economist-style* political intelligence and opening publication-ready pull requests. Every workflow follows the same Stage A → E contract documented in [Article-Generation.md](Article-Generation.md). The single source of truth for every horizon's data window, cadence, mandatory artifacts, stage budgets, scenario depth and electoral overlay is the [`ARTICLE_HORIZONS` registry in `src/config/article-horizons.ts`](src/config/article-horizons.ts).
342
342
 
343
343
  | Workflow | Article Type | Focus |
344
344
  |----------|--------------|-------|
345
345
  | 🚨 **[news-breaking.md](.github/workflows/news-breaking.md)** | `breaking` | Rapid-response coverage of significant EP developments. |
346
346
  | 📋 **[news-week-ahead.md](.github/workflows/news-week-ahead.md)** | `week-ahead` | Forward calendar, committee agenda, urgency-file outlook. |
347
347
  | 🔭 **[news-month-ahead.md](.github/workflows/news-month-ahead.md)** | `month-ahead` | 30-day strategic horizon and risk forecast. |
348
+ | 🌐 **[news-quarter-ahead.md](.github/workflows/news-quarter-ahead.md)** | `quarter-ahead` | 90-day legislative pipeline forecast + presidency-trio overlay. |
349
+ | 🛰️ **[news-year-ahead.md](.github/workflows/news-year-ahead.md)** | `year-ahead` | 12-month strategic outlook with seat-projection sensitivity. |
350
+ | 🗓️ **[news-term-outlook.md](.github/workflows/news-term-outlook.md)** | `term-outlook` | Full EP-term outlook anchored to the next-EP-election week. |
351
+ | 🗳️ **[news-election-cycle.md](.github/workflows/news-election-cycle.md)** | `election-cycle` | EP-election span (±6 mo) — mandate scorecard + seat projection + Spitzenkandidaten arithmetic. |
348
352
  | 📊 **[news-week-in-review.md](.github/workflows/news-week-in-review.md)** | `week-in-review` | Past-week retrospective intelligence (D-8 → D-36 reporting window). |
349
353
  | 📈 **[news-month-in-review.md](.github/workflows/news-month-in-review.md)** | `month-in-review` | Monthly retrospective with cross-run continuity analysis. |
354
+ | 📚 **[news-quarter-in-review.md](.github/workflows/news-quarter-in-review.md)** | `quarter-in-review` | Quarterly retrospective with pipeline transit + presidency-trio overlay. |
355
+ | 📜 **[news-year-in-review.md](.github/workflows/news-year-in-review.md)** | `year-in-review` | Annual retrospective with mandate-fulfilment + term-arc + historical parallels. |
350
356
  | 🏛️ **[news-committee-reports.md](.github/workflows/news-committee-reports.md)** | `committee-reports` | Committee activity, rapporteur work, legislative-production analysis. |
351
357
  | ⚖️ **[news-motions.md](.github/workflows/news-motions.md)** | `motions` | Plenary motions, resolutions, urgency files, political signals. |
352
358
  | 📜 **[news-propositions.md](.github/workflows/news-propositions.md)** | `propositions` | Legislative proposals and pipeline analysis. |
353
359
  | 🌍 **[news-translate.md](.github/workflows/news-translate.md)** | translation helper | 14-language flush translation (manual dispatch only). |
354
360
 
355
- Each unified workflow runs Stages A–E **in a single 45-minute session** and produces exactly one PR containing both the analysis artifacts and the rendered HTML. The earlier split-pair `news-<type>-analysis.md` + `news-<type>-article.md` layout was retired in the April-2026 aggregator migration. See **[.github/workflows/README.md](.github/workflows/README.md)** for compile / lock-file / safe-output mechanics, and **[WORKFLOWS.md](WORKFLOWS.md)** for the full CI/CD catalog.
361
+ Each unified workflow runs Stages A–E **in a single 60-minute session** (`engine.mcp.session-timeout: 65m`) and produces exactly one PR containing both the analysis artifacts and the rendered HTML. The earlier split-pair `news-<type>-analysis.md` + `news-<type>-article.md` layout was retired in the April-2026 aggregator migration. See **[.github/workflows/README.md](.github/workflows/README.md)** for compile / lock-file / safe-output mechanics, **[WORKFLOWS.md](WORKFLOWS.md)** for the full CI/CD catalog, and **[Article-Generation.md § Forward-looking horizons & election cycle](Article-Generation.md)** for the new long-horizon and electoral pipeline.
356
362
 
357
363
  > 📚 **Prompt Library (`.github/prompts/`)** — 10 bounded-context prompt files (`00-scope-and-ground-rules.md` → `09-troubleshooting.md`) shared across every workflow. The `npm run lint:prompts` drift-guard fails CI on banned patterns (`checkpoint pr`, `keep-alive`, `heartbeat`, `progressive safe output`, `push_repo_memory`).
358
364
 
@@ -426,7 +432,7 @@ import type { ArticleCategory, LanguageCode } from 'euparliamentmonitor/types';
426
432
 
427
433
  ## 🔌 Data Sources
428
434
 
429
- **Primary — European Parliament MCP Server** ([Hack23/European-Parliament-MCP-Server](https://github.com/Hack23/European-Parliament-MCP-Server) v1.2.19+, fully operational):
435
+ **Primary — European Parliament MCP Server** ([Hack23/European-Parliament-MCP-Server](https://github.com/Hack23/European-Parliament-MCP-Server) v1.2.20+, fully operational):
430
436
 
431
437
  - 🗳️ Plenary sessions, voting records, roll-call votes
432
438
  - 📜 Adopted texts, motions, resolutions, urgency files
@@ -560,7 +566,7 @@ Six-phase roadmap from current agentic news generation to AGI-enhanced transform
560
566
  timeline
561
567
  title EU Parliament Monitor — AI Evolution Roadmap
562
568
  section Phase 1 (2026)
563
- Agentic News : 8 unified workflows
569
+ Agentic News : 14 unified workflows
564
570
  : 14-language generation
565
571
  : Deterministic aggregator
566
572
  : 51-artifact analysis catalog
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euparliamentmonitor",
3
- "version": "0.8.53",
3
+ "version": "0.8.55",
4
4
  "type": "module",
5
5
  "description": "European Parliament Intelligence Platform - Monitor political activity with systematic transparency",
6
6
  "main": "scripts/index.js",
@@ -55,6 +55,8 @@
55
55
  "build:check-tests": "tsc --project tsconfig.test.json --noEmit",
56
56
  "copy-vendor": "node scripts/copy-vendor.js",
57
57
  "validate-analysis": "node scripts/validate-analysis-completeness.js",
58
+ "sync:templates": "node scripts/templates/sync-template-frontmatter.js",
59
+ "sync:templates:check": "node scripts/templates/sync-template-frontmatter.js --check",
58
60
  "prior-run-diff": "node scripts/aggregator/prior-run-diff.js",
59
61
  "generate-article": "node scripts/aggregator/article-generator.js",
60
62
  "generate-article:all": "node scripts/aggregator/article-generator.js --all",
@@ -169,7 +171,7 @@
169
171
  "node": ">=25"
170
172
  },
171
173
  "dependencies": {
172
- "european-parliament-mcp-server": "1.2.19",
174
+ "european-parliament-mcp-server": "1.2.20",
173
175
  "markdown-it": "^14.1.1",
174
176
  "markdown-it-anchor": "^9.2.0",
175
177
  "markdown-it-attrs": "^4.3.1",
@@ -1,5 +1,8 @@
1
1
  import { type ArtifactSection } from './artifact-order.js';
2
2
  import { type Manifest, type ManifestFiles } from './manifest/index.js';
3
+ import type { TocSection, IncludedArtifact } from './reader-guide-constants.js';
4
+ export type { TocSection, IncludedArtifact } from './reader-guide-constants.js';
5
+ export { READER_GUIDE_SECTION_ID, READER_GUIDE_SECTION_IDS, READER_GUIDE_SECTION_TITLE, } from './reader-guide-constants.js';
3
6
  /** Result of {@link aggregateAnalysisRun}. */
4
7
  export interface AggregatedRun {
5
8
  /** Final Markdown document (provenance + sections + appendices). */
@@ -23,26 +26,6 @@ export interface AggregatedRun {
23
26
  */
24
27
  readonly sectionToc: readonly TocSection[];
25
28
  }
26
- /** One entry in the article-level table of contents (H2 level). */
27
- export interface TocSection {
28
- /** Fragment identifier — matches the `id="…"` on the rendered H2. */
29
- readonly id: string;
30
- /** Display title shown in the sidebar nav. */
31
- readonly title: string;
32
- }
33
- /** Metadata for one artifact included in the aggregate. */
34
- export interface IncludedArtifact {
35
- /** Path relative to the run dir. */
36
- readonly runRelPath: string;
37
- /** Path relative to the repo root. */
38
- readonly repoRelPath: string;
39
- /** Id of the section this artifact belongs to. */
40
- readonly sectionId: string;
41
- }
42
- /** Id of the generated reader guide section. */
43
- export declare const READER_GUIDE_SECTION_ID = "reader-intelligence-guide";
44
- /** Display title of the generated reader guide section. */
45
- export declare const READER_GUIDE_SECTION_TITLE = "Reader Intelligence Guide";
46
29
  /** Options for {@link aggregateAnalysisRun}. */
47
30
  export interface AggregateOptions {
48
31
  /** Absolute path to the analysis run directory. */
@@ -146,6 +129,10 @@ export declare function renderAnalysisIndex(included: readonly IncludedArtifact[
146
129
  * artifact sections. It gives readers a Riksdagsmonitor-style navigation layer
147
130
  * without requiring agents to hand-author another artifact.
148
131
  *
132
+ * Section membership is checked against `READER_GUIDE_SECTION_IDS` (the
133
+ * canonical list shared with the HTML renderer in `reader-intelligence-guide.ts`)
134
+ * to prevent drift between the two renderers.
135
+ *
149
136
  * @param sections - Emitted section TOC entries, in document order
150
137
  * @param included - Included artifacts, used to name each section's source
151
138
  * @returns Markdown block containing the guide table
@@ -14,11 +14,10 @@ import path from 'path';
14
14
  import { ARTIFACT_SECTIONS, MANIFEST_SECTION_ID, MANIFEST_SECTION_TITLE, SUPPLEMENTARY_SECTION_ID, SUPPLEMENTARY_SECTION_TITLE, TRADECRAFT_SECTION_ID, TRADECRAFT_SECTION_TITLE, } from './artifact-order.js';
15
15
  import { cleanArtifact, githubBlobUrl } from './clean-artifact.js';
16
16
  import { treeUrl } from './infra/github-urls.js';
17
+ import { buildKeyTakeaways, KEY_TAKEAWAYS_SECTION_ID, KEY_TAKEAWAYS_SECTION_TITLE, } from './key-takeaways.js';
17
18
  import { flattenManifestFiles as _flattenManifestFiles, latestGateResult as _latestGateResult, resolveArticleType as _resolveArticleType, resolveRunId as _resolveRunId, } from './manifest/index.js';
18
- /** Id of the generated reader guide section. */
19
- export const READER_GUIDE_SECTION_ID = 'reader-intelligence-guide';
20
- /** Display title of the generated reader guide section. */
21
- export const READER_GUIDE_SECTION_TITLE = 'Reader Intelligence Guide';
19
+ import { READER_GUIDE_SECTION_ID, READER_GUIDE_SECTION_IDS, READER_GUIDE_SECTION_TITLE, } from './reader-guide-constants.js';
20
+ export { READER_GUIDE_SECTION_ID, READER_GUIDE_SECTION_IDS, READER_GUIDE_SECTION_TITLE, } from './reader-guide-constants.js';
22
21
  /**
23
22
  * Normalise `manifest.files` into a flat list of `runRelPath` strings.
24
23
  *
@@ -258,8 +257,13 @@ export function renderAnalysisIndex(included, manifestRelPath) {
258
257
  '',
259
258
  ].join('\n');
260
259
  }
261
- /** Reader-guide copy for high-value intelligence sections. */
262
- const READER_GUIDE_VALUES = {
260
+ /**
261
+ * English-only reader-guide copy for the Markdown guide embedded in the
262
+ * aggregated source document. Section membership is gated by
263
+ * `READER_GUIDE_SECTION_IDS` (imported from `reader-guide-constants.ts`)
264
+ * so both renderers stay in sync automatically.
265
+ */
266
+ const READER_GUIDE_EN = {
263
267
  'section-executive-brief': {
264
268
  need: 'BLUF and editorial decisions',
265
269
  value: 'fast answer to what happened, why it matters, who is accountable, and the next dated trigger',
@@ -298,6 +302,10 @@ const READER_GUIDE_VALUES = {
298
302
  * artifact sections. It gives readers a Riksdagsmonitor-style navigation layer
299
303
  * without requiring agents to hand-author another artifact.
300
304
  *
305
+ * Section membership is checked against `READER_GUIDE_SECTION_IDS` (the
306
+ * canonical list shared with the HTML renderer in `reader-intelligence-guide.ts`)
307
+ * to prevent drift between the two renderers.
308
+ *
301
309
  * @param sections - Emitted section TOC entries, in document order
302
310
  * @param included - Included artifacts, used to name each section's source
303
311
  * @returns Markdown block containing the guide table
@@ -305,7 +313,10 @@ const READER_GUIDE_VALUES = {
305
313
  export function renderReaderIntelligenceGuide(sections, included) {
306
314
  const rows = sections
307
315
  .map((section) => {
308
- const copy = Object.getOwnPropertyDescriptor(READER_GUIDE_VALUES, section.id)?.value;
316
+ // Guard: only include sections whose IDs are in the canonical list
317
+ if (!READER_GUIDE_SECTION_IDS.includes(section.id))
318
+ return '';
319
+ const copy = Object.getOwnPropertyDescriptor(READER_GUIDE_EN, section.id)?.value;
309
320
  if (!copy)
310
321
  return '';
311
322
  const source = included.find((artifact) => artifact.sectionId === section.id)?.runRelPath;
@@ -538,16 +549,29 @@ export function aggregateAnalysisRun(options) {
538
549
  const tradecraft = renderTradecraftAppendix(tradecraftFiles);
539
550
  const analysisIndex = renderAnalysisIndex(includedArtifacts, manifestRelPath);
540
551
  const readerGuide = renderReaderIntelligenceGuide(emittedSections, includedArtifacts);
552
+ // Deterministic 3–7 bullet "Key takeaways" block, harvested from the
553
+ // synthesis-summary / intelligence-assessment artifacts. Placed
554
+ // immediately after the Executive Brief so the reader gets the BLUF
555
+ // followed by a digest of the strongest findings before being handed
556
+ // off to the Reader Intelligence Guide and the deeper sections.
557
+ const keyTakeaways = buildKeyTakeaways({ runDir });
541
558
  // TOC ordering reflects the rendered document:
542
559
  // Executive Brief (already first in emittedSections via appendSection) →
543
- // Reader Intelligence Guide (inserted at position 1, after Exec Brief) →
544
- // remaining sections → audit appendices.
560
+ // Key Takeaways (inserted right after the brief when present) →
561
+ // Reader Intelligence Guide → remaining sections → audit appendices.
562
+ let postBriefIdx = emittedSections.length > 0 &&
563
+ emittedSections[0]?.id === namespacedSectionId(execBriefSection?.id ?? '')
564
+ ? 1
565
+ : 0;
566
+ if (keyTakeaways) {
567
+ emittedSections.splice(postBriefIdx, 0, {
568
+ id: KEY_TAKEAWAYS_SECTION_ID,
569
+ title: KEY_TAKEAWAYS_SECTION_TITLE,
570
+ });
571
+ postBriefIdx += 1;
572
+ }
545
573
  if (readerGuide) {
546
- const insertIdx = emittedSections.length > 0 &&
547
- emittedSections[0]?.id === namespacedSectionId(execBriefSection?.id ?? '')
548
- ? 1
549
- : 0;
550
- emittedSections.splice(insertIdx, 0, {
574
+ emittedSections.splice(postBriefIdx, 0, {
551
575
  id: READER_GUIDE_SECTION_ID,
552
576
  title: READER_GUIDE_SECTION_TITLE,
553
577
  });
@@ -559,6 +583,7 @@ export function aggregateAnalysisRun(options) {
559
583
  '',
560
584
  ...execBriefMarkdown,
561
585
  '',
586
+ ...(keyTakeaways ? [keyTakeaways, ''] : []),
562
587
  readerGuide,
563
588
  '',
564
589
  ...sectionMarkdown,
@@ -45,6 +45,12 @@ export interface GenerateResult {
45
45
  * the artifacts that produced it (riksdagsmonitor pattern).
46
46
  */
47
47
  readonly runArticleMdRelPath: string;
48
+ /**
49
+ * Repo-relative path of the `article-meta.json` sidecar written next to
50
+ * `article.md` — structured data consumed by HTML SEO, news indexes,
51
+ * and RSS rendering. Always emitted, deterministic.
52
+ */
53
+ readonly runArticleMetaRelPath: string;
48
54
  /** Filenames written under `outDir`, relative to `outDir`. */
49
55
  readonly writtenFiles: readonly string[];
50
56
  /** Metadata from {@link aggregateAnalysisRun}. */
@@ -20,9 +20,12 @@ import fs from 'fs';
20
20
  import path from 'path';
21
21
  import { pathToFileURL } from 'url';
22
22
  import { aggregateAnalysisRun, resolveArticleTypeFromManifest, } from './analysis-aggregator.js';
23
+ import { resolveRunId as _resolveRunId } from './manifest/index.js';
23
24
  import { resolveArticleMetadata, extractStrongProseLine, } from './article-metadata.js';
25
+ import { buildArticleMeta, serializeArticleMeta } from './article-meta.js';
24
26
  import { renderMarkdown } from './markdown-renderer.js';
25
27
  import { wrapArticleHtml, getArticleFilename } from './article-html.js';
28
+ import { buildReaderIntelligenceGuideHtml, stripInlineReaderGuide, } from './reader-intelligence-guide.js';
26
29
  import { ALL_LANGUAGES } from '../constants/language-core.js';
27
30
  import { blobUrl } from './infra/github-urls.js';
28
31
  import { buildArticleSlug as _buildArticleSlug, sanitizeRunSuffix as _sanitizeRunSuffix, } from './slug/index.js';
@@ -335,6 +338,22 @@ function writeLanguageVariant(lang, slug, aggregated, englishHtml, chromeOptions
335
338
  metaSource = fs.readFileSync(langMdAbs, 'utf8');
336
339
  bodyHtml = renderMarkdown(metaSource).html;
337
340
  }
341
+ // Strip any AI-authored inline Reader Intelligence Guide and inject the
342
+ // renderer-owned, language-aware version so exactly one guide appears.
343
+ bodyHtml = stripInlineReaderGuide(bodyHtml);
344
+ // The article chrome (wrapArticleHtml) renders its own <h1> in the hero
345
+ // header. Strip the in-body <h1> emitted from the Markdown `# Title` to
346
+ // avoid a duplicate H1 and broken heading hierarchy (H2 preceding H1).
347
+ bodyHtml = bodyHtml.replace(/<h1[^>]*>[\s\S]*?<\/h1>\s*/, '');
348
+ const guideHtml = buildReaderIntelligenceGuideHtml(lang, aggregated.sectionToc, aggregated.includedArtifacts);
349
+ if (guideHtml) {
350
+ // Prepend the guide to the body so it always appears at the top of
351
+ // the rendered content, immediately after the chrome header. The
352
+ // article chrome in wrapArticleHtml wraps the body in an <article>
353
+ // with its own <header>/<h1>, so prepending here is deterministic
354
+ // and avoids fragile in-body heading searches.
355
+ bodyHtml = guideHtml + '\n' + bodyHtml;
356
+ }
338
357
  // When a per-language translated source exists, prefer a summary derived
339
358
  // from it so the `<meta description>` matches the visible prose. The
340
359
  // editorial title still comes from the English resolver (per-language
@@ -455,6 +474,25 @@ export function generateArticle(opts, runSuffix, articleCountOverride) {
455
474
  .relative(opts.repoRoot, runArticleMdAbs)
456
475
  .split(path.sep)
457
476
  .join('/');
477
+ // Emit `article-meta.json` next to `article.md` — a deterministic
478
+ // structured-data sidecar consumed by HTML SEO, news indexes, and RSS.
479
+ // Same artifact bytes in → same JSON bytes out (asserted by the
480
+ // determinism test).
481
+ const runArticleMetaAbs = path.join(opts.runDir, 'article-meta.json');
482
+ const articleMeta = buildArticleMeta({
483
+ runDir: opts.runDir,
484
+ repoRoot: opts.repoRoot,
485
+ date: aggregated.date,
486
+ articleType: aggregated.articleType,
487
+ runId: readManifestRunId(opts.runDir, path.basename(opts.runDir)),
488
+ gateResult: aggregated.gateResult,
489
+ slug,
490
+ });
491
+ fs.writeFileSync(runArticleMetaAbs, serializeArticleMeta(articleMeta), 'utf8');
492
+ const runArticleMetaRelPath = path
493
+ .relative(opts.repoRoot, runArticleMetaAbs)
494
+ .split(path.sep)
495
+ .join('/');
458
496
  // Also write source Markdown under <outDir>/<slug>.en.md for search
459
497
  // indexing and backwards compatibility with existing news-index scripts.
460
498
  ensureDir(opts.outDir);
@@ -479,6 +517,7 @@ export function generateArticle(opts, runSuffix, articleCountOverride) {
479
517
  return {
480
518
  sourceMarkdownRelPath: runArticleMdRelPath,
481
519
  runArticleMdRelPath,
520
+ runArticleMetaRelPath,
482
521
  writtenFiles: written,
483
522
  aggregated,
484
523
  };
@@ -536,6 +575,29 @@ export function generateAllArticles(opts) {
536
575
  }
537
576
  return results;
538
577
  }
578
+ /**
579
+ * Read the run identifier from `manifest.json`, falling back to the
580
+ * directory basename when the manifest is missing or unparsable. Wraps
581
+ * the canonical resolver from `aggregator/manifest/index.ts` so callers
582
+ * outside the aggregator core (here, the article-meta sidecar emitter)
583
+ * stay decoupled from the internal manifest schema.
584
+ *
585
+ * @param runDir - Absolute run directory path
586
+ * @param defaultRunId - Fall-back run id (typically the directory basename)
587
+ * @returns Resolved run id, never empty
588
+ */
589
+ function readManifestRunId(runDir, defaultRunId) {
590
+ const manifestPath = path.join(runDir, 'manifest.json');
591
+ if (!fs.existsSync(manifestPath))
592
+ return defaultRunId;
593
+ try {
594
+ const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
595
+ return _resolveRunId(parsed, defaultRunId);
596
+ }
597
+ catch {
598
+ return defaultRunId;
599
+ }
600
+ }
539
601
  /**
540
602
  * Read the raw manifest.json from a run directory and return the subset
541
603
  * of fields consumed by {@link resolveArticleMetadata}. Returns an empty
@@ -23,6 +23,12 @@ import { buildHeadFreshnessTags } from '../constants/build-info-meta.js';
23
23
  import { ALL_LANGUAGES, LANGUAGE_NAMES, LANGUAGE_FLAGS, PAGE_TITLES, SKIP_LINK_TEXTS, TOC_ARIA_LABELS, UPDATE_AVAILABLE_LABELS, UPDATE_REFRESH_CTA_LABELS, UPDATE_DISMISS_LABELS, getLocalizedString, getTextDirection, } from '../constants/languages.js';
24
24
  import { escapeHTML } from '../utils/file-utils.js';
25
25
  import { buildSiteFooter, buildSiteHeader, buildPageBanner, } from '../templates/section-builders.js';
26
+ import { READER_GUIDE_SECTION_ID } from './reader-guide-constants.js';
27
+ import { READER_GUIDE_TITLE_LABELS } from './reader-intelligence-guide.js';
28
+ /** Publisher organization name used in JSON-LD, meta tags. */
29
+ const PUBLISHER_NAME = 'Hack23 AB';
30
+ /** Site name used across meta tags and structured data. */
31
+ const SITE_NAME = 'EU Parliament Monitor';
26
32
  /**
27
33
  * Build the canonical filename for an article in a given language. English
28
34
  * uses the bare stem (`2026-01-15-breaking-en.html`); other languages share
@@ -85,7 +91,13 @@ export function buildArticleToc(entries, lang) {
85
91
  return '';
86
92
  const label = escapeHTML(getLocalizedString(TOC_ARIA_LABELS, lang));
87
93
  const items = entries
88
- .map((e) => ` <li><a href="#${escapeHTML(e.id)}">${escapeHTML(e.title)}</a></li>`)
94
+ .map((e) => {
95
+ // Translate the Reader Intelligence Guide title into the target language
96
+ const displayTitle = e.id === READER_GUIDE_SECTION_ID
97
+ ? getLocalizedString(READER_GUIDE_TITLE_LABELS, lang)
98
+ : e.title;
99
+ return ` <li><a href="#${escapeHTML(e.id)}">${escapeHTML(displayTitle)}</a></li>`;
100
+ })
89
101
  .join('\n');
90
102
  return [
91
103
  ` <aside class="article-toc-container" aria-label="${label}">`,
@@ -111,7 +123,7 @@ export function buildArticleToc(entries, lang) {
111
123
  export function wrapArticleHtml(options) {
112
124
  const safeLang = ALL_LANGUAGES.includes(options.lang) ? options.lang : 'en';
113
125
  const dir = getTextDirection(safeLang);
114
- const siteTitle = getLocalizedString(PAGE_TITLES, safeLang).split(' - ')[0] ?? 'EU Parliament Monitor';
126
+ const siteTitle = getLocalizedString(PAGE_TITLES, safeLang).split(' - ')[0] ?? SITE_NAME;
115
127
  const skipLinkText = getLocalizedString(SKIP_LINK_TEXTS, safeLang);
116
128
  const canonicalUrl = `${BASE_URL}/news/${getArticleFilename(options.articleSlug, safeLang)}`;
117
129
  const indexHref = safeLang === 'en' ? '../index.html' : `../index-${safeLang}.html`;
@@ -127,14 +139,21 @@ export function wrapArticleHtml(options) {
127
139
  headline: options.title,
128
140
  description: options.description,
129
141
  datePublished: options.date,
142
+ dateModified: options.date,
130
143
  inLanguage: safeLang,
131
144
  url: canonicalUrl,
132
- author: { '@type': 'Organization', name: 'Hack23 AB', url: 'https://hack23.com' },
133
- publisher: { '@type': 'Organization', name: 'Hack23 AB', url: 'https://hack23.com' },
145
+ image: `${BASE_URL}/images/og-image.jpg`,
146
+ author: { '@type': 'Organization', name: PUBLISHER_NAME, url: 'https://hack23.com' },
147
+ publisher: {
148
+ '@type': 'Organization',
149
+ name: PUBLISHER_NAME,
150
+ url: 'https://hack23.com',
151
+ logo: { '@type': 'ImageObject', url: `${BASE_URL}/images/apple-touch-icon.png` },
152
+ },
134
153
  articleSection: options.articleType,
135
154
  isPartOf: {
136
155
  '@type': 'WebSite',
137
- name: 'EU Parliament Monitor',
156
+ name: SITE_NAME,
138
157
  url: BASE_URL,
139
158
  },
140
159
  ...(options.isBasedOn && options.isBasedOn.length > 0
@@ -143,7 +162,32 @@ export function wrapArticleHtml(options) {
143
162
  }
144
163
  : {}),
145
164
  };
146
- const jsonLdString = JSON.stringify(jsonLd).replace(/</g, '\\u003c');
165
+ const breadcrumbLd = {
166
+ '@context': 'https://schema.org',
167
+ '@type': 'BreadcrumbList',
168
+ itemListElement: [
169
+ {
170
+ '@type': 'ListItem',
171
+ position: 1,
172
+ name: SITE_NAME,
173
+ item: BASE_URL,
174
+ },
175
+ {
176
+ '@type': 'ListItem',
177
+ position: 2,
178
+ name: options.articleType.replace(/-/g, ' '),
179
+ item: `${BASE_URL}/news/`,
180
+ },
181
+ {
182
+ '@type': 'ListItem',
183
+ position: 3,
184
+ name: options.title,
185
+ item: canonicalUrl,
186
+ },
187
+ ],
188
+ };
189
+ const structuredData = [jsonLd, breadcrumbLd];
190
+ const jsonLdString = JSON.stringify(structuredData).replace(/</g, '\\u003c');
147
191
  const pageTitle = `${options.title} — ${siteTitle}`;
148
192
  const header = buildSiteHeader({
149
193
  lang: safeLang,
@@ -163,8 +207,8 @@ export function wrapArticleHtml(options) {
163
207
  <title>${escapeHTML(pageTitle)}</title>
164
208
  <meta name="description" content="${escapeHTML(options.description)}">
165
209
  <meta name="robots" content="index, follow, max-image-preview:large">
166
- <meta name="author" content="Hack23 AB">
167
- <meta name="publisher" content="Hack23 AB">
210
+ <meta name="author" content="${PUBLISHER_NAME}">
211
+ <meta name="publisher" content="${PUBLISHER_NAME}">
168
212
  <meta name="date" content="${options.date}">
169
213
  <meta name="article:published_time" content="${options.date}">
170
214
  <link rel="canonical" href="${canonicalUrl}">
@@ -0,0 +1,121 @@
1
+ import { buildKeyTakeaways as _buildKeyTakeaways } from './key-takeaways.js';
2
+ /** Shape of `article-meta.json`. */
3
+ export interface ArticleMeta {
4
+ /** ISO date of the run (`YYYY-MM-DD`). */
5
+ readonly date: string;
6
+ /** Article type slug (e.g. `breaking`). */
7
+ readonly articleType: string;
8
+ /** Stable run identifier from the manifest. */
9
+ readonly runId: string;
10
+ /** Latest non-PENDING gate result. */
11
+ readonly gateResult: string;
12
+ /** Article slug used by the news pages (`<date>-<type>[-<suffix>]`). */
13
+ readonly slug: string;
14
+ /** Run-relative path of the canonical `article.md`. */
15
+ readonly articlePath: string;
16
+ /** One-sentence executive lead — the strongest finding, distilled. */
17
+ readonly topFinding: string;
18
+ /** 3–7 deterministic key takeaways harvested from synthesis-summary. */
19
+ readonly keyTakeaways: readonly string[];
20
+ /** Top political risks (artifact-driven, may be empty). */
21
+ readonly topRisks: readonly string[];
22
+ /** Key dated triggers / "what to watch" items. */
23
+ readonly keyDates: readonly string[];
24
+ /** Key actors / political groups identified by the artifacts. */
25
+ readonly keyActors: readonly string[];
26
+ /** Optional IMF / WorldBank macro hook surfaced as a sidebar callout. */
27
+ readonly macroContext: string;
28
+ /**
29
+ * Run-relative paths of every artifact whose content fed into this meta
30
+ * record — emitted so the HTML SEO layer can build `isBasedOn` arrays
31
+ * without re-walking the run directory.
32
+ */
33
+ readonly sources: readonly string[];
34
+ }
35
+ /** Options for {@link buildArticleMeta}. */
36
+ export interface BuildArticleMetaOptions {
37
+ /** Absolute path to the analysis run directory. */
38
+ readonly runDir: string;
39
+ /** Absolute path to the repository root (used for repo-relative paths). */
40
+ readonly repoRoot: string;
41
+ /** ISO date of the run (`YYYY-MM-DD`). */
42
+ readonly date: string;
43
+ /** Article type slug. */
44
+ readonly articleType: string;
45
+ /** Stable run identifier from the manifest. */
46
+ readonly runId: string;
47
+ /** Latest non-PENDING gate result. */
48
+ readonly gateResult: string;
49
+ /** Article slug used by the news pages. */
50
+ readonly slug: string;
51
+ }
52
+ /**
53
+ * Mine top political risks from `risk-scoring/risk-matrix.md` (or its
54
+ * historic variants under the same directory). Falls back to the first
55
+ * bullets in `risk-scoring/quantitative-swot.md` when the matrix is
56
+ * absent. Returns at most {@link MAX_LIST_ENTRIES} bullets.
57
+ *
58
+ * @param runDir - Absolute path to the analysis run directory
59
+ * @returns Ordered list of risk bullet bodies
60
+ */
61
+ export declare function extractTopRisks(runDir: string): string[];
62
+ /**
63
+ * Mine forward-looking dated items from
64
+ * `intelligence/parliamentary-calendar-projection.md` and
65
+ * `extended/forward-indicators.md`. Returns at most
66
+ * {@link MAX_LIST_ENTRIES} bullets, de-duplicated across the two sources.
67
+ *
68
+ * @param runDir - Absolute path to the analysis run directory
69
+ * @returns Ordered list of dated trigger bullet bodies
70
+ */
71
+ export declare function extractKeyDates(runDir: string): string[];
72
+ /**
73
+ * Mine key actors / political groups from
74
+ * `classification/actor-mapping.md` and `intelligence/stakeholder-map.md`.
75
+ * Falls through to coalition-dynamics when the canonical actor map is
76
+ * missing. Returns at most {@link MAX_LIST_ENTRIES} bullets.
77
+ *
78
+ * @param runDir - Absolute path to the analysis run directory
79
+ * @returns Ordered list of actor bullet bodies
80
+ */
81
+ export declare function extractKeyActors(runDir: string): string[];
82
+ /**
83
+ * Resolve a one-line IMF / WorldBank macro context callout from
84
+ * `intelligence/economic-context.md`. Returns the trimmed lead sentence
85
+ * of the artifact, or `''` when the artifact is missing.
86
+ *
87
+ * @param runDir - Absolute path to the analysis run directory
88
+ * @returns IMF-backed macro hook, or `''`
89
+ */
90
+ export declare function extractMacroContext(runDir: string): string;
91
+ /**
92
+ * Resolve the deterministic 3–7 key-takeaway bullets used in both the
93
+ * Markdown article body and `article-meta.json`.
94
+ *
95
+ * @param runDir - Absolute path to the analysis run directory
96
+ * @returns Ordered list of takeaway bodies (3–7 entries)
97
+ */
98
+ export declare function extractKeyTakeaways(runDir: string): string[];
99
+ /**
100
+ * Build the deterministic `ArticleMeta` record for one run. Pure function
101
+ * of the on-disk artifacts plus the resolved manifest fields.
102
+ *
103
+ * @param options - Run-level metadata + absolute run directory
104
+ * @returns Frozen, JSON-serialisable {@link ArticleMeta}
105
+ */
106
+ export declare function buildArticleMeta(options: BuildArticleMetaOptions): ArticleMeta;
107
+ /**
108
+ * Serialise an {@link ArticleMeta} as a stable JSON string with a trailing
109
+ * newline. Keys are emitted in declaration order (insertion-order, matching
110
+ * the interface layout). Determinism guarantee: same input → same bytes.
111
+ *
112
+ * @param meta - Article meta record
113
+ * @returns JSON string ready to be written next to `article.md`
114
+ */
115
+ export declare function serializeArticleMeta(meta: ArticleMeta): string;
116
+ /**
117
+ * Convenience wrapper that re-exports {@link _buildKeyTakeaways} so the
118
+ * aggregator can import the rendered Markdown block from a single module.
119
+ */
120
+ export { _buildKeyTakeaways as buildKeyTakeawaysMarkdown };
121
+ //# sourceMappingURL=article-meta.d.ts.map