euparliamentmonitor 0.8.21 → 0.8.22

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