euparliamentmonitor 0.8.4
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/LICENSE +201 -0
- package/README.md +1005 -0
- package/SECURITY.md +151 -0
- package/package.json +131 -0
- package/scripts/constants/committee-indicator-map.d.ts +199 -0
- package/scripts/constants/committee-indicator-map.d.ts.map +1 -0
- package/scripts/constants/committee-indicator-map.js +1224 -0
- package/scripts/constants/committee-indicator-map.js.map +1 -0
- package/scripts/constants/config.d.ts +38 -0
- package/scripts/constants/config.d.ts.map +1 -0
- package/scripts/constants/config.js +66 -0
- package/scripts/constants/config.js.map +1 -0
- package/scripts/constants/language-articles.d.ts +84 -0
- package/scripts/constants/language-articles.d.ts.map +1 -0
- package/scripts/constants/language-articles.js +6771 -0
- package/scripts/constants/language-articles.js.map +1 -0
- package/scripts/constants/language-core.d.ts +38 -0
- package/scripts/constants/language-core.d.ts.map +1 -0
- package/scripts/constants/language-core.js +90 -0
- package/scripts/constants/language-core.js.map +1 -0
- package/scripts/constants/language-ui.d.ts +82 -0
- package/scripts/constants/language-ui.d.ts.map +1 -0
- package/scripts/constants/language-ui.js +889 -0
- package/scripts/constants/language-ui.js.map +1 -0
- package/scripts/constants/languages.d.ts +14 -0
- package/scripts/constants/languages.d.ts.map +1 -0
- package/scripts/constants/languages.js +15 -0
- package/scripts/constants/languages.js.map +1 -0
- package/scripts/generators/analysis-builders.d.ts +266 -0
- package/scripts/generators/analysis-builders.d.ts.map +1 -0
- package/scripts/generators/analysis-builders.js +2903 -0
- package/scripts/generators/analysis-builders.js.map +1 -0
- package/scripts/generators/breaking-content.d.ts +45 -0
- package/scripts/generators/breaking-content.d.ts.map +1 -0
- package/scripts/generators/breaking-content.js +530 -0
- package/scripts/generators/breaking-content.js.map +1 -0
- package/scripts/generators/committee-helpers.d.ts +54 -0
- package/scripts/generators/committee-helpers.d.ts.map +1 -0
- package/scripts/generators/committee-helpers.js +154 -0
- package/scripts/generators/committee-helpers.js.map +1 -0
- package/scripts/generators/dashboard-content.d.ts +95 -0
- package/scripts/generators/dashboard-content.d.ts.map +1 -0
- package/scripts/generators/dashboard-content.js +630 -0
- package/scripts/generators/dashboard-content.js.map +1 -0
- package/scripts/generators/deep-analysis-content.d.ts +23 -0
- package/scripts/generators/deep-analysis-content.d.ts.map +1 -0
- package/scripts/generators/deep-analysis-content.js +831 -0
- package/scripts/generators/deep-analysis-content.js.map +1 -0
- package/scripts/generators/mindmap-content.d.ts +55 -0
- package/scripts/generators/mindmap-content.d.ts.map +1 -0
- package/scripts/generators/mindmap-content.js +512 -0
- package/scripts/generators/mindmap-content.js.map +1 -0
- package/scripts/generators/motions-content.d.ts +50 -0
- package/scripts/generators/motions-content.d.ts.map +1 -0
- package/scripts/generators/motions-content.js +391 -0
- package/scripts/generators/motions-content.js.map +1 -0
- package/scripts/generators/news-enhanced.d.ts +14 -0
- package/scripts/generators/news-enhanced.d.ts.map +1 -0
- package/scripts/generators/news-enhanced.js +169 -0
- package/scripts/generators/news-enhanced.js.map +1 -0
- package/scripts/generators/news-indexes.d.ts +31 -0
- package/scripts/generators/news-indexes.d.ts.map +1 -0
- package/scripts/generators/news-indexes.js +410 -0
- package/scripts/generators/news-indexes.js.map +1 -0
- package/scripts/generators/pipeline/fetch-stage.d.ts +352 -0
- package/scripts/generators/pipeline/fetch-stage.d.ts.map +1 -0
- package/scripts/generators/pipeline/fetch-stage.js +1522 -0
- package/scripts/generators/pipeline/fetch-stage.js.map +1 -0
- package/scripts/generators/pipeline/generate-stage.d.ts +43 -0
- package/scripts/generators/pipeline/generate-stage.d.ts.map +1 -0
- package/scripts/generators/pipeline/generate-stage.js +204 -0
- package/scripts/generators/pipeline/generate-stage.js.map +1 -0
- package/scripts/generators/pipeline/output-stage.d.ts +48 -0
- package/scripts/generators/pipeline/output-stage.d.ts.map +1 -0
- package/scripts/generators/pipeline/output-stage.js +145 -0
- package/scripts/generators/pipeline/output-stage.js.map +1 -0
- package/scripts/generators/pipeline/transform-stage.d.ts +57 -0
- package/scripts/generators/pipeline/transform-stage.d.ts.map +1 -0
- package/scripts/generators/pipeline/transform-stage.js +111 -0
- package/scripts/generators/pipeline/transform-stage.js.map +1 -0
- package/scripts/generators/propositions-content.d.ts +29 -0
- package/scripts/generators/propositions-content.d.ts.map +1 -0
- package/scripts/generators/propositions-content.js +90 -0
- package/scripts/generators/propositions-content.js.map +1 -0
- package/scripts/generators/sankey-content.d.ts +45 -0
- package/scripts/generators/sankey-content.d.ts.map +1 -0
- package/scripts/generators/sankey-content.js +227 -0
- package/scripts/generators/sankey-content.js.map +1 -0
- package/scripts/generators/sitemap.d.ts +66 -0
- package/scripts/generators/sitemap.d.ts.map +1 -0
- package/scripts/generators/sitemap.js +562 -0
- package/scripts/generators/sitemap.js.map +1 -0
- package/scripts/generators/strategies/article-strategy.d.ts +146 -0
- package/scripts/generators/strategies/article-strategy.d.ts.map +1 -0
- package/scripts/generators/strategies/article-strategy.js +4 -0
- package/scripts/generators/strategies/article-strategy.js.map +1 -0
- package/scripts/generators/strategies/breaking-news-strategy.d.ts +64 -0
- package/scripts/generators/strategies/breaking-news-strategy.d.ts.map +1 -0
- package/scripts/generators/strategies/breaking-news-strategy.js +246 -0
- package/scripts/generators/strategies/breaking-news-strategy.js.map +1 -0
- package/scripts/generators/strategies/committee-reports-strategy.d.ts +93 -0
- package/scripts/generators/strategies/committee-reports-strategy.d.ts.map +1 -0
- package/scripts/generators/strategies/committee-reports-strategy.js +447 -0
- package/scripts/generators/strategies/committee-reports-strategy.js.map +1 -0
- package/scripts/generators/strategies/month-ahead-strategy.d.ts +60 -0
- package/scripts/generators/strategies/month-ahead-strategy.d.ts.map +1 -0
- package/scripts/generators/strategies/month-ahead-strategy.js +175 -0
- package/scripts/generators/strategies/month-ahead-strategy.js.map +1 -0
- package/scripts/generators/strategies/monthly-review-strategy.d.ts +66 -0
- package/scripts/generators/strategies/monthly-review-strategy.d.ts.map +1 -0
- package/scripts/generators/strategies/monthly-review-strategy.js +204 -0
- package/scripts/generators/strategies/monthly-review-strategy.js.map +1 -0
- package/scripts/generators/strategies/motions-strategy.d.ts +61 -0
- package/scripts/generators/strategies/motions-strategy.d.ts.map +1 -0
- package/scripts/generators/strategies/motions-strategy.js +215 -0
- package/scripts/generators/strategies/motions-strategy.js.map +1 -0
- package/scripts/generators/strategies/propositions-strategy.d.ts +60 -0
- package/scripts/generators/strategies/propositions-strategy.d.ts.map +1 -0
- package/scripts/generators/strategies/propositions-strategy.js +257 -0
- package/scripts/generators/strategies/propositions-strategy.js.map +1 -0
- package/scripts/generators/strategies/week-ahead-strategy.d.ts +57 -0
- package/scripts/generators/strategies/week-ahead-strategy.d.ts.map +1 -0
- package/scripts/generators/strategies/week-ahead-strategy.js +178 -0
- package/scripts/generators/strategies/week-ahead-strategy.js.map +1 -0
- package/scripts/generators/strategies/weekly-review-strategy.d.ts +63 -0
- package/scripts/generators/strategies/weekly-review-strategy.d.ts.map +1 -0
- package/scripts/generators/strategies/weekly-review-strategy.js +211 -0
- package/scripts/generators/strategies/weekly-review-strategy.js.map +1 -0
- package/scripts/generators/swot-content.d.ts +42 -0
- package/scripts/generators/swot-content.d.ts.map +1 -0
- package/scripts/generators/swot-content.js +366 -0
- package/scripts/generators/swot-content.js.map +1 -0
- package/scripts/generators/week-ahead-content.d.ts +103 -0
- package/scripts/generators/week-ahead-content.d.ts.map +1 -0
- package/scripts/generators/week-ahead-content.js +610 -0
- package/scripts/generators/week-ahead-content.js.map +1 -0
- package/scripts/index.d.ts +40 -0
- package/scripts/index.d.ts.map +1 -0
- package/scripts/index.js +53 -0
- package/scripts/index.js.map +1 -0
- package/scripts/mcp/ep-mcp-client.d.ts +471 -0
- package/scripts/mcp/ep-mcp-client.d.ts.map +1 -0
- package/scripts/mcp/ep-mcp-client.js +734 -0
- package/scripts/mcp/ep-mcp-client.js.map +1 -0
- package/scripts/mcp/mcp-connection.d.ts +264 -0
- package/scripts/mcp/mcp-connection.d.ts.map +1 -0
- package/scripts/mcp/mcp-connection.js +790 -0
- package/scripts/mcp/mcp-connection.js.map +1 -0
- package/scripts/mcp/mcp-health.d.ts +75 -0
- package/scripts/mcp/mcp-health.d.ts.map +1 -0
- package/scripts/mcp/mcp-health.js +78 -0
- package/scripts/mcp/mcp-health.js.map +1 -0
- package/scripts/mcp/mcp-retry.d.ts +94 -0
- package/scripts/mcp/mcp-retry.d.ts.map +1 -0
- package/scripts/mcp/mcp-retry.js +127 -0
- package/scripts/mcp/mcp-retry.js.map +1 -0
- package/scripts/mcp/wb-mcp-client.d.ts +38 -0
- package/scripts/mcp/wb-mcp-client.d.ts.map +1 -0
- package/scripts/mcp/wb-mcp-client.js +112 -0
- package/scripts/mcp/wb-mcp-client.js.map +1 -0
- package/scripts/templates/article-template.d.ts +9 -0
- package/scripts/templates/article-template.d.ts.map +1 -0
- package/scripts/templates/article-template.js +378 -0
- package/scripts/templates/article-template.js.map +1 -0
- package/scripts/templates/section-builders.d.ts +28 -0
- package/scripts/templates/section-builders.d.ts.map +1 -0
- package/scripts/templates/section-builders.js +142 -0
- package/scripts/templates/section-builders.js.map +1 -0
- package/scripts/types/analysis.d.ts +115 -0
- package/scripts/types/analysis.d.ts.map +1 -0
- package/scripts/types/analysis.js +4 -0
- package/scripts/types/analysis.js.map +1 -0
- package/scripts/types/common.d.ts +584 -0
- package/scripts/types/common.d.ts.map +1 -0
- package/scripts/types/common.js +96 -0
- package/scripts/types/common.js.map +1 -0
- package/scripts/types/generation.d.ts +104 -0
- package/scripts/types/generation.d.ts.map +1 -0
- package/scripts/types/generation.js +4 -0
- package/scripts/types/generation.js.map +1 -0
- package/scripts/types/index.d.ts +24 -0
- package/scripts/types/index.d.ts.map +1 -0
- package/scripts/types/index.js +16 -0
- package/scripts/types/index.js.map +1 -0
- package/scripts/types/intelligence.d.ts +129 -0
- package/scripts/types/intelligence.d.ts.map +1 -0
- package/scripts/types/intelligence.js +4 -0
- package/scripts/types/intelligence.js.map +1 -0
- package/scripts/types/mcp.d.ts +418 -0
- package/scripts/types/mcp.d.ts.map +1 -0
- package/scripts/types/mcp.js +4 -0
- package/scripts/types/mcp.js.map +1 -0
- package/scripts/types/parliament.d.ts +388 -0
- package/scripts/types/parliament.d.ts.map +1 -0
- package/scripts/types/parliament.js +4 -0
- package/scripts/types/parliament.js.map +1 -0
- package/scripts/types/quality.d.ts +114 -0
- package/scripts/types/quality.d.ts.map +1 -0
- package/scripts/types/quality.js +4 -0
- package/scripts/types/quality.js.map +1 -0
- package/scripts/types/stakeholder.d.ts +88 -0
- package/scripts/types/stakeholder.d.ts.map +1 -0
- package/scripts/types/stakeholder.js +16 -0
- package/scripts/types/stakeholder.js.map +1 -0
- package/scripts/types/visualization.d.ts +708 -0
- package/scripts/types/visualization.d.ts.map +1 -0
- package/scripts/types/visualization.js +4 -0
- package/scripts/types/visualization.js.map +1 -0
- package/scripts/types/world-bank.d.ts +85 -0
- package/scripts/types/world-bank.d.ts.map +1 -0
- package/scripts/types/world-bank.js +4 -0
- package/scripts/types/world-bank.js.map +1 -0
- package/scripts/utils/article-category.d.ts +18 -0
- package/scripts/utils/article-category.d.ts.map +1 -0
- package/scripts/utils/article-category.js +49 -0
- package/scripts/utils/article-category.js.map +1 -0
- package/scripts/utils/article-quality-scorer.d.ts +87 -0
- package/scripts/utils/article-quality-scorer.d.ts.map +1 -0
- package/scripts/utils/article-quality-scorer.js +1048 -0
- package/scripts/utils/article-quality-scorer.js.map +1 -0
- package/scripts/utils/content-metadata.d.ts +34 -0
- package/scripts/utils/content-metadata.d.ts.map +1 -0
- package/scripts/utils/content-metadata.js +249 -0
- package/scripts/utils/content-metadata.js.map +1 -0
- package/scripts/utils/content-validator.d.ts +94 -0
- package/scripts/utils/content-validator.d.ts.map +1 -0
- package/scripts/utils/content-validator.js +489 -0
- package/scripts/utils/content-validator.js.map +1 -0
- package/scripts/utils/copy-test-reports.d.ts +9 -0
- package/scripts/utils/copy-test-reports.d.ts.map +1 -0
- package/scripts/utils/copy-test-reports.js +508 -0
- package/scripts/utils/copy-test-reports.js.map +1 -0
- package/scripts/utils/file-utils.d.ts +144 -0
- package/scripts/utils/file-utils.d.ts.map +1 -0
- package/scripts/utils/file-utils.js +374 -0
- package/scripts/utils/file-utils.js.map +1 -0
- package/scripts/utils/fix-articles.d.ts +27 -0
- package/scripts/utils/fix-articles.d.ts.map +1 -0
- package/scripts/utils/fix-articles.js +510 -0
- package/scripts/utils/fix-articles.js.map +1 -0
- package/scripts/utils/generate-docs-index.d.ts +8 -0
- package/scripts/utils/generate-docs-index.d.ts.map +1 -0
- package/scripts/utils/generate-docs-index.js +275 -0
- package/scripts/utils/generate-docs-index.js.map +1 -0
- package/scripts/utils/html-sanitize.d.ts +18 -0
- package/scripts/utils/html-sanitize.d.ts.map +1 -0
- package/scripts/utils/html-sanitize.js +57 -0
- package/scripts/utils/html-sanitize.js.map +1 -0
- package/scripts/utils/intelligence-analysis.d.ts +173 -0
- package/scripts/utils/intelligence-analysis.d.ts.map +1 -0
- package/scripts/utils/intelligence-analysis.js +936 -0
- package/scripts/utils/intelligence-analysis.js.map +1 -0
- package/scripts/utils/intelligence-index.d.ts +126 -0
- package/scripts/utils/intelligence-index.d.ts.map +1 -0
- package/scripts/utils/intelligence-index.js +731 -0
- package/scripts/utils/intelligence-index.js.map +1 -0
- package/scripts/utils/metadata-utils.d.ts +14 -0
- package/scripts/utils/metadata-utils.d.ts.map +1 -0
- package/scripts/utils/metadata-utils.js +18 -0
- package/scripts/utils/metadata-utils.js.map +1 -0
- package/scripts/utils/news-metadata.d.ts +47 -0
- package/scripts/utils/news-metadata.d.ts.map +1 -0
- package/scripts/utils/news-metadata.js +259 -0
- package/scripts/utils/news-metadata.js.map +1 -0
- package/scripts/utils/validate-articles.d.ts +2 -0
- package/scripts/utils/validate-articles.d.ts.map +1 -0
- package/scripts/utils/validate-articles.js +284 -0
- package/scripts/utils/validate-articles.js.map +1 -0
- package/scripts/utils/validate-ep-api.d.ts +51 -0
- package/scripts/utils/validate-ep-api.d.ts.map +1 -0
- package/scripts/utils/validate-ep-api.js +160 -0
- package/scripts/utils/validate-ep-api.js.map +1 -0
- package/scripts/utils/world-bank-data.d.ts +84 -0
- package/scripts/utils/world-bank-data.d.ts.map +1 -0
- package/scripts/utils/world-bank-data.js +311 -0
- package/scripts/utils/world-bank-data.js.map +1 -0
|
@@ -0,0 +1,1048 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2024-2026 Hack23 AB
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { stripScriptBlocks } from './html-sanitize.js';
|
|
4
|
+
// ─── Scoring constants ────────────────────────────────────────────────────────
|
|
5
|
+
/** Weight applied to analysis depth score in overall calculation */
|
|
6
|
+
const WEIGHT_ANALYSIS_DEPTH = 0.25;
|
|
7
|
+
/** Weight applied to stakeholder balance score in overall calculation */
|
|
8
|
+
const WEIGHT_STAKEHOLDER = 0.2;
|
|
9
|
+
/** Weight applied to visualization quality score in overall calculation */
|
|
10
|
+
const WEIGHT_VISUALIZATION = 0.25;
|
|
11
|
+
/** Weight applied to word-count score in overall calculation */
|
|
12
|
+
const WEIGHT_WORD_COUNT = 0.15;
|
|
13
|
+
/** Weight applied to evidence-reference score in overall calculation */
|
|
14
|
+
const WEIGHT_EVIDENCE = 0.15;
|
|
15
|
+
/** Minimum word count to score 0 on the word-count dimension */
|
|
16
|
+
const WORD_COUNT_MIN = 0;
|
|
17
|
+
/** Word count that earns the maximum word-count dimension score */
|
|
18
|
+
const WORD_COUNT_MAX = 1500;
|
|
19
|
+
/** Evidence-reference count that earns the maximum evidence dimension score */
|
|
20
|
+
const EVIDENCE_MAX = 10;
|
|
21
|
+
/** Overall score threshold for passing the quality gate (Grade C floor) */
|
|
22
|
+
const QUALITY_GATE_THRESHOLD = 40;
|
|
23
|
+
/** Grade boundary — score >= this earns an A */
|
|
24
|
+
const GRADE_A_MIN = 80;
|
|
25
|
+
/** Grade boundary — score >= this earns a B */
|
|
26
|
+
const GRADE_B_MIN = 65;
|
|
27
|
+
/** Grade boundary — score >= this earns a C */
|
|
28
|
+
const GRADE_C_MIN = 40;
|
|
29
|
+
/** Grade boundary — score >= this earns a D */
|
|
30
|
+
const GRADE_D_MIN = 25;
|
|
31
|
+
// ─── Analysis-depth keyword sets ─────────────────────────────────────────────
|
|
32
|
+
/** Keywords indicating political context discussion */
|
|
33
|
+
const POLITICAL_CONTEXT_KEYWORDS = [
|
|
34
|
+
'political',
|
|
35
|
+
'coalition',
|
|
36
|
+
'majority',
|
|
37
|
+
'opposition',
|
|
38
|
+
'parliament',
|
|
39
|
+
];
|
|
40
|
+
/** Keywords indicating coalition-dynamics analysis */
|
|
41
|
+
const COALITION_DYNAMICS_KEYWORDS = [
|
|
42
|
+
'coalition',
|
|
43
|
+
'alliance',
|
|
44
|
+
'EPP',
|
|
45
|
+
'S&D',
|
|
46
|
+
'Renew',
|
|
47
|
+
'Greens',
|
|
48
|
+
];
|
|
49
|
+
/** Keywords indicating historical context */
|
|
50
|
+
const HISTORICAL_CONTEXT_KEYWORDS = [
|
|
51
|
+
'historically',
|
|
52
|
+
'since 2019',
|
|
53
|
+
'previous term',
|
|
54
|
+
'compared to',
|
|
55
|
+
];
|
|
56
|
+
/** Keywords indicating evidence-based reasoning */
|
|
57
|
+
const EVIDENCE_BASED_KEYWORDS = [
|
|
58
|
+
'according to',
|
|
59
|
+
'data shows',
|
|
60
|
+
'evidence suggests',
|
|
61
|
+
'figures',
|
|
62
|
+
];
|
|
63
|
+
/** Keywords indicating scenario planning or projections */
|
|
64
|
+
const SCENARIO_PLANNING_KEYWORDS = [
|
|
65
|
+
'if ',
|
|
66
|
+
'could',
|
|
67
|
+
'scenario',
|
|
68
|
+
'projection',
|
|
69
|
+
'forecast',
|
|
70
|
+
];
|
|
71
|
+
/** Keywords indicating stated confidence levels */
|
|
72
|
+
const CONFIDENCE_LEVEL_KEYWORDS = [
|
|
73
|
+
'likely',
|
|
74
|
+
'probably',
|
|
75
|
+
'uncertain',
|
|
76
|
+
'confidence',
|
|
77
|
+
];
|
|
78
|
+
// ─── Stakeholder detection sets ───────────────────────────────────────────────
|
|
79
|
+
/** All known stakeholder categories and their keyword signals */
|
|
80
|
+
const STAKEHOLDER_KEYWORDS = [
|
|
81
|
+
{ name: 'MEPs/Parliament', keywords: ['MEP', 'parliament', 'parliamentarian', 'deputy'] },
|
|
82
|
+
{
|
|
83
|
+
name: 'Commission',
|
|
84
|
+
keywords: ['Commission', 'commissioner', 'European Commission'],
|
|
85
|
+
},
|
|
86
|
+
{ name: 'Council', keywords: ['Council', 'presidency', 'member states'] },
|
|
87
|
+
{
|
|
88
|
+
name: 'member states/governments',
|
|
89
|
+
keywords: ['government', 'national', 'member state', 'minister'],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'civil society/NGOs',
|
|
93
|
+
keywords: ['civil society', 'NGO', 'non-governmental', 'advocacy'],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'industry/business',
|
|
97
|
+
keywords: ['industry', 'business', 'corporate', 'sector', 'company'],
|
|
98
|
+
},
|
|
99
|
+
{ name: 'citizens', keywords: ['citizen', 'public', 'voter', 'constituent'] },
|
|
100
|
+
{ name: 'media', keywords: ['media', 'press', 'journalist', 'outlet'] },
|
|
101
|
+
];
|
|
102
|
+
// ─── Placeholder / generic-phrase patterns ────────────────────────────────────
|
|
103
|
+
/** Patterns indicating vague or un-replaced generic phrases */
|
|
104
|
+
const GENERIC_PHRASE_PATTERNS = [
|
|
105
|
+
/various committees/iu,
|
|
106
|
+
/several MEPs/iu,
|
|
107
|
+
/multiple documents/iu,
|
|
108
|
+
/some countries/iu,
|
|
109
|
+
];
|
|
110
|
+
// ─── EP document-reference pattern ───────────────────────────────────────────
|
|
111
|
+
/**
|
|
112
|
+
* Patterns matching known EP document reference formats.
|
|
113
|
+
* Uses separate patterns to avoid alternation complexity flagged by security/detect-unsafe-regex.
|
|
114
|
+
* Covers: TA-10-2026-0123, PE-123, PE-123.456, A9-0123, B9-0123, C9-0123, P9_TA(2024)0001
|
|
115
|
+
* Excludes broad matches like EU-27 or EEA-32.
|
|
116
|
+
*/
|
|
117
|
+
const EP_DOC_PATTERNS = [
|
|
118
|
+
/\bTA-\d+-\d+-\d+\b/gu, // TA-10-2026-0001 (TA prefix + three numeric segments)
|
|
119
|
+
/\bPE-\d+\.\d+\b/gu, // PE-123.456 (dotted PE reference)
|
|
120
|
+
/\bPE-\d+(?!\.\d)\b/gu, // PE-123 (simple PE reference, excludes dotted)
|
|
121
|
+
/\b[A-C]\d-\d+\b/gu, // A9-0123, B9-0002, C9-0003 (variable-length digits)
|
|
122
|
+
/\bP\d_TA\(\d{4}\)\d+\b/gu, // P9_TA(2024)0001
|
|
123
|
+
];
|
|
124
|
+
/** CSS class selector for deep-analysis sections (extracted to avoid duplication) */
|
|
125
|
+
const CLASS_DEEP_ANALYSIS = 'class="deep-analysis"';
|
|
126
|
+
/** Pattern to extract a leading ISO date (YYYY-MM-DD) from an article identifier */
|
|
127
|
+
const ARTICLE_DATE_PATTERN = /^(\d{4}-\d{2}-\d{2})/u;
|
|
128
|
+
// ─── HTML entity map ──────────────────────────────────────────────────────────
|
|
129
|
+
/** Common HTML entities to decode when extracting plain text */
|
|
130
|
+
const HTML_ENTITY_MAP = {
|
|
131
|
+
'&': '&',
|
|
132
|
+
'<': '<',
|
|
133
|
+
'>': '>',
|
|
134
|
+
'"': '"',
|
|
135
|
+
''': "'",
|
|
136
|
+
''': "'",
|
|
137
|
+
' ': ' ',
|
|
138
|
+
};
|
|
139
|
+
/** Pattern matching named and numeric HTML entities */
|
|
140
|
+
const HTML_ENTITY_PATTERN = /&(?:#(\d+)|#x([0-9a-fA-F]+)|([a-zA-Z]+));/gu;
|
|
141
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
142
|
+
/**
|
|
143
|
+
* Decode HTML entities in a string.
|
|
144
|
+
* Handles named entities (&, <, >, ", ', ', )
|
|
145
|
+
* and numeric references ({, {).
|
|
146
|
+
*
|
|
147
|
+
* @param text - Text possibly containing HTML entities
|
|
148
|
+
* @returns Text with entities replaced by their character equivalents
|
|
149
|
+
*/
|
|
150
|
+
function decodeHtmlEntities(text) {
|
|
151
|
+
return text.replace(HTML_ENTITY_PATTERN, (match, decimal, hex, named) => {
|
|
152
|
+
if (decimal !== undefined) {
|
|
153
|
+
const cp = parseInt(decimal, 10);
|
|
154
|
+
try {
|
|
155
|
+
return String.fromCodePoint(cp);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return match;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (hex !== undefined) {
|
|
162
|
+
const cp = parseInt(hex, 16);
|
|
163
|
+
try {
|
|
164
|
+
return String.fromCodePoint(cp);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return match;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (named !== undefined) {
|
|
171
|
+
const key = `&${named};`;
|
|
172
|
+
return HTML_ENTITY_MAP[key] ?? match;
|
|
173
|
+
}
|
|
174
|
+
return match;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Count non-overlapping occurrences of a CSS class or id string in HTML.
|
|
179
|
+
*
|
|
180
|
+
* @param html - HTML string to search
|
|
181
|
+
* @param selector - CSS class or id token to count (e.g. `class="metric"`)
|
|
182
|
+
* @returns Number of occurrences found
|
|
183
|
+
*/
|
|
184
|
+
function countOccurrences(html, selector) {
|
|
185
|
+
let count = 0;
|
|
186
|
+
let index = html.indexOf(selector);
|
|
187
|
+
while (index !== -1) {
|
|
188
|
+
count++;
|
|
189
|
+
index = html.indexOf(selector, index + selector.length);
|
|
190
|
+
}
|
|
191
|
+
return count;
|
|
192
|
+
}
|
|
193
|
+
/** Pattern matching all class attribute values in HTML */
|
|
194
|
+
const CLASS_ATTR_PATTERN = /class="([^"]*)"/gu;
|
|
195
|
+
/**
|
|
196
|
+
* Check whether any `class="…"` attribute in the HTML contains the given token
|
|
197
|
+
* as an exact CSS class (whitespace-delimited). Unlike `\b` word-boundary matching,
|
|
198
|
+
* this prevents false positives from hyphenated classes (e.g. `dashboard-grid` does
|
|
199
|
+
* not match `dashboard`).
|
|
200
|
+
*
|
|
201
|
+
* @param html - HTML string to scan
|
|
202
|
+
* @param token - Exact CSS class name to detect
|
|
203
|
+
* @returns true if an exact class token match is found
|
|
204
|
+
*/
|
|
205
|
+
function hasExactClassToken(html, token) {
|
|
206
|
+
CLASS_ATTR_PATTERN.lastIndex = 0;
|
|
207
|
+
let match;
|
|
208
|
+
while ((match = CLASS_ATTR_PATTERN.exec(html)) !== null) {
|
|
209
|
+
const value = match[1] ?? '';
|
|
210
|
+
const classes = value.split(/\s+/);
|
|
211
|
+
if (classes.includes(token))
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Count the number of elements whose `class` attribute contains the given token
|
|
218
|
+
* as an exact CSS class (whitespace-delimited). Unlike simple substring counting,
|
|
219
|
+
* this correctly counts multi-class attributes like `class="metric-card pipeline-on-track"`.
|
|
220
|
+
*
|
|
221
|
+
* @param html - HTML string to scan
|
|
222
|
+
* @param token - Exact CSS class name to count
|
|
223
|
+
* @returns Number of elements with the given class token
|
|
224
|
+
*/
|
|
225
|
+
function countExactClassToken(html, token) {
|
|
226
|
+
CLASS_ATTR_PATTERN.lastIndex = 0;
|
|
227
|
+
let count = 0;
|
|
228
|
+
let match;
|
|
229
|
+
while ((match = CLASS_ATTR_PATTERN.exec(html)) !== null) {
|
|
230
|
+
const value = match[1] ?? '';
|
|
231
|
+
const classes = value.split(/\s+/);
|
|
232
|
+
if (classes.includes(token))
|
|
233
|
+
count++;
|
|
234
|
+
}
|
|
235
|
+
return count;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Check whether at least one keyword from a list is present in a text string.
|
|
239
|
+
*
|
|
240
|
+
* Uses a leading word-boundary anchor (`\b`) so that keywords like "national"
|
|
241
|
+
* do not false-match inside longer words like "international", while still
|
|
242
|
+
* matching inflected forms such as "citizens" for the keyword "citizen".
|
|
243
|
+
*
|
|
244
|
+
* @param text - Text to search (comparison is case-insensitive)
|
|
245
|
+
* @param keywords - Keywords to look for
|
|
246
|
+
* @returns true if any keyword is found
|
|
247
|
+
*/
|
|
248
|
+
function containsAnyKeyword(text, keywords) {
|
|
249
|
+
return keywords.some((kw) => {
|
|
250
|
+
const escaped = kw.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
251
|
+
const pattern = new RegExp(`\\b${escaped}`, 'iu');
|
|
252
|
+
return pattern.test(text);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
// stripScriptBlocks is imported from html-sanitize.ts
|
|
256
|
+
/**
|
|
257
|
+
* Extract the plain text content from the `<main>` element of an HTML string.
|
|
258
|
+
* Falls back to the full document when no `<main>` is found.
|
|
259
|
+
* Decodes HTML entities so keyword detection works on real article HTML.
|
|
260
|
+
*
|
|
261
|
+
* @param html - Raw HTML string
|
|
262
|
+
* @returns Plain text stripped of tags, scripts, and HTML entities
|
|
263
|
+
*/
|
|
264
|
+
function extractPlainText(html) {
|
|
265
|
+
const mainMatch = /<main[^>]*>([\s\S]*?)<\/main>/u.exec(html);
|
|
266
|
+
const source = mainMatch?.[1] ?? html;
|
|
267
|
+
const stripped = stripScriptBlocks(source)
|
|
268
|
+
.replace(/<[^>]+>/gu, ' ')
|
|
269
|
+
.replace(/\s+/gu, ' ')
|
|
270
|
+
.trim();
|
|
271
|
+
return decodeHtmlEntities(stripped);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Tokens that identify a `<section>` element as analysis content.
|
|
275
|
+
* Non-analysis sections (e.g. `article-sources`, `sitemap-section`) are excluded.
|
|
276
|
+
*/
|
|
277
|
+
const ANALYSIS_SECTION_TOKENS = [
|
|
278
|
+
'analysis',
|
|
279
|
+
'analysis-section',
|
|
280
|
+
'deep-analysis',
|
|
281
|
+
'swot-analysis',
|
|
282
|
+
'dashboard',
|
|
283
|
+
'mindmap-section',
|
|
284
|
+
'sankey-section',
|
|
285
|
+
];
|
|
286
|
+
/** Pattern to extract the class attribute value from a single HTML tag */
|
|
287
|
+
const CLASS_VALUE_PATTERN = /class="([^"]*)"/iu;
|
|
288
|
+
/**
|
|
289
|
+
* Count structural analysis sections in HTML.
|
|
290
|
+
* Only counts `<section>` elements whose class attribute contains a known
|
|
291
|
+
* analysis-related token, preventing inflation from non-analysis sections
|
|
292
|
+
* like sources or footer wrappers.
|
|
293
|
+
*
|
|
294
|
+
* @param html - Raw HTML string
|
|
295
|
+
* @returns Number of analysis-content sections found
|
|
296
|
+
*/
|
|
297
|
+
function countAnalysisSections(html) {
|
|
298
|
+
const SECTION_TAG = /<section\b[^>]*>/giu;
|
|
299
|
+
let count = 0;
|
|
300
|
+
let m;
|
|
301
|
+
while ((m = SECTION_TAG.exec(html)) !== null) {
|
|
302
|
+
const tag = m[0];
|
|
303
|
+
const cv = CLASS_VALUE_PATTERN.exec(tag);
|
|
304
|
+
if (cv?.[1]) {
|
|
305
|
+
const tokens = cv[1].split(/\s+/).filter(Boolean);
|
|
306
|
+
if (tokens.some((t) => ANALYSIS_SECTION_TOKENS.includes(t))) {
|
|
307
|
+
count++;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return count;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Count `<li>` elements inside containers matching the given class attribute.
|
|
315
|
+
* Used to count evidence items in `<ul class="perspective-evidence"><li>…</li></ul>`
|
|
316
|
+
* and `<ul class="evidence-refs"><li lang="…">…</li></ul>` structures produced by
|
|
317
|
+
* the deep-analysis and stakeholder perspective generators.
|
|
318
|
+
*
|
|
319
|
+
* Locates each container by its class attribute, determines the enclosing element
|
|
320
|
+
* tag name, finds the end of the opening tag (`>`), and extracts content up to
|
|
321
|
+
* the balanced closing tag for that specific element — ensuring correct scoping
|
|
322
|
+
* even when the container is a `<ul>` (which `findBalancedContent` does not track).
|
|
323
|
+
*
|
|
324
|
+
* Counts both `<li>` (bare) and `<li ` (with attributes like `lang="…"`) to handle
|
|
325
|
+
* attributed list items while avoiding false matches on `<link>` or `<listing>` tags.
|
|
326
|
+
*
|
|
327
|
+
* @param html - HTML string to search
|
|
328
|
+
* @param containerClass - Class attribute string to match (e.g. `class="perspective-evidence"`)
|
|
329
|
+
* @returns Number of `<li>` children found across all matching containers
|
|
330
|
+
*/
|
|
331
|
+
function countListItemsInClass(html, containerClass) {
|
|
332
|
+
let total = 0;
|
|
333
|
+
let idx = html.indexOf(containerClass);
|
|
334
|
+
while (idx !== -1) {
|
|
335
|
+
const content = extractContainerContent(html, idx, containerClass);
|
|
336
|
+
if (content) {
|
|
337
|
+
// Count both bare <li> and attributed <li ...> (e.g. <li lang="en">)
|
|
338
|
+
// Using '<li>' and '<li ' avoids false matches on <link> or <listing>
|
|
339
|
+
total += countOccurrences(content, '<li>') + countOccurrences(content, '<li ');
|
|
340
|
+
}
|
|
341
|
+
idx = html.indexOf(containerClass, idx + 1);
|
|
342
|
+
}
|
|
343
|
+
return total;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Count only direct (top-level) `<li>` children of the first container matching
|
|
347
|
+
* the given class attribute prefix. Tracks nesting depth of list elements
|
|
348
|
+
* (`<ul>`, `<ol>`) so that `<li>` tags inside nested sublists are excluded.
|
|
349
|
+
*
|
|
350
|
+
* This avoids inflating the count for deep-but-narrow structures where nested
|
|
351
|
+
* subnodes are also wrapped in `<li>` elements.
|
|
352
|
+
*
|
|
353
|
+
* @param html - HTML string to search
|
|
354
|
+
* @param containerClassPrefix - Class attribute prefix to locate (e.g. `class="mindmap-branches`)
|
|
355
|
+
* @returns Number of direct `<li>` children in the first matching container
|
|
356
|
+
*/
|
|
357
|
+
function countDirectListChildren(html, containerClassPrefix) {
|
|
358
|
+
const idx = html.indexOf(containerClassPrefix);
|
|
359
|
+
if (idx < 0)
|
|
360
|
+
return 0;
|
|
361
|
+
// Complete the attribute value to find the closing quote
|
|
362
|
+
const quoteEnd = html.indexOf('"', idx + containerClassPrefix.length);
|
|
363
|
+
if (quoteEnd < 0)
|
|
364
|
+
return 0;
|
|
365
|
+
const fullAttr = html.slice(idx, quoteEnd + 1);
|
|
366
|
+
const content = extractContainerContent(html, idx, fullAttr);
|
|
367
|
+
if (!content)
|
|
368
|
+
return 0;
|
|
369
|
+
// Scan through the container content tracking list nesting depth.
|
|
370
|
+
// Only count <li> tags at depth 0 (direct children of the container).
|
|
371
|
+
let count = 0;
|
|
372
|
+
let listDepth = 0;
|
|
373
|
+
const tagPattern = /<(\/?)(?:ul|ol|li)(?=[\s>/])/giu;
|
|
374
|
+
let m = tagPattern.exec(content);
|
|
375
|
+
while (m) {
|
|
376
|
+
const isClosing = m[1] === '/';
|
|
377
|
+
const tagLower = m[0].replace(/^<\/?/u, '').toLowerCase();
|
|
378
|
+
if (tagLower === 'ul' || tagLower === 'ol') {
|
|
379
|
+
if (isClosing) {
|
|
380
|
+
listDepth = Math.max(0, listDepth - 1);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
listDepth++;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
else if (tagLower === 'li' && !isClosing && listDepth === 0) {
|
|
387
|
+
count++;
|
|
388
|
+
}
|
|
389
|
+
m = tagPattern.exec(content);
|
|
390
|
+
}
|
|
391
|
+
return count;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* attribute match at the given position. Identifies the tag name by searching
|
|
395
|
+
* backwards for `<tagname`, then finds the end of the opening tag (`>`), and
|
|
396
|
+
* uses balanced tag matching on that specific element to locate the matching
|
|
397
|
+
* closing tag.
|
|
398
|
+
*
|
|
399
|
+
* @param html - Full HTML string
|
|
400
|
+
* @param attrIdx - Index where the matched attribute starts within `html`
|
|
401
|
+
* @param attr - The attribute string that was matched
|
|
402
|
+
* @returns Inner HTML of the container, or empty string if extraction fails
|
|
403
|
+
*/
|
|
404
|
+
function extractContainerContent(html, attrIdx, attr) {
|
|
405
|
+
// Search backwards from the attribute to find the opening `<`
|
|
406
|
+
let openBracket = attrIdx - 1;
|
|
407
|
+
while (openBracket >= 0 && html[openBracket] !== '<')
|
|
408
|
+
openBracket--;
|
|
409
|
+
if (openBracket < 0)
|
|
410
|
+
return '';
|
|
411
|
+
// Extract the tag name (e.g. "ul", "div", "section")
|
|
412
|
+
const tagSlice = html.slice(openBracket + 1, attrIdx).trim();
|
|
413
|
+
const tagNameMatch = /^([a-z][a-z0-9]*)/iu.exec(tagSlice);
|
|
414
|
+
if (!tagNameMatch)
|
|
415
|
+
return '';
|
|
416
|
+
const tagName = tagNameMatch[1];
|
|
417
|
+
// Find the end of the opening tag
|
|
418
|
+
const closeAngle = html.indexOf('>', attrIdx + attr.length);
|
|
419
|
+
if (closeAngle < 0)
|
|
420
|
+
return '';
|
|
421
|
+
const contentStart = closeAngle + 1;
|
|
422
|
+
// Balanced matching for this specific tag name
|
|
423
|
+
// tagName is validated by /^([a-z][a-z0-9]*)/ — alphanumeric only, safe for RegExp
|
|
424
|
+
// eslint-disable-next-line security/detect-non-literal-regexp
|
|
425
|
+
const balancePattern = new RegExp(`</?${tagName}[\\s>/]`, 'giu');
|
|
426
|
+
balancePattern.lastIndex = contentStart;
|
|
427
|
+
let depth = 1;
|
|
428
|
+
let m = balancePattern.exec(html);
|
|
429
|
+
while (m) {
|
|
430
|
+
if (m[0].startsWith('</')) {
|
|
431
|
+
depth--;
|
|
432
|
+
if (depth === 0)
|
|
433
|
+
return html.slice(contentStart, m.index);
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
depth++;
|
|
437
|
+
}
|
|
438
|
+
m = balancePattern.exec(html);
|
|
439
|
+
}
|
|
440
|
+
return '';
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Count evidence and document references in HTML.
|
|
444
|
+
* Detects evidence markers from the actual generator output:
|
|
445
|
+
* - `<ul class="perspective-evidence"><li>…</li></ul>` — deep-analysis evidence items
|
|
446
|
+
* - `<ul class="evidence-refs"><li lang="…">…</li></ul>` — reasoning-chain evidence items
|
|
447
|
+
* - `class="swot-ref-evidence"` — SWOT cross-reference evidence markers
|
|
448
|
+
* - `class="evidence"` — generic evidence markers (legacy / tests)
|
|
449
|
+
* - `data-reference` attributes
|
|
450
|
+
* - EP document reference codes (TA-, PE-, A9-, P9_TA patterns)
|
|
451
|
+
*
|
|
452
|
+
* Strips `<script>` blocks once up front so that JSON-LD metadata and other
|
|
453
|
+
* inline scripts do not inflate any evidence counts.
|
|
454
|
+
*
|
|
455
|
+
* @param html - Raw HTML string
|
|
456
|
+
* @returns Number of evidence references found
|
|
457
|
+
*/
|
|
458
|
+
function countEvidenceRefs(html) {
|
|
459
|
+
// Strip script blocks (e.g. JSON-LD) once for all evidence counting
|
|
460
|
+
// to avoid inflated counts from matching substrings inside scripts.
|
|
461
|
+
const htmlNoScripts = stripScriptBlocks(html);
|
|
462
|
+
// Count <li> items inside perspective-evidence containers (deep-analysis generator)
|
|
463
|
+
const perspectiveEvidenceItems = countListItemsInClass(htmlNoScripts, 'class="perspective-evidence"');
|
|
464
|
+
// Count <li> items inside evidence-refs containers (reasoning-chain generator)
|
|
465
|
+
const evidenceRefsItems = countListItemsInClass(htmlNoScripts, 'class="evidence-refs"');
|
|
466
|
+
// Count SWOT cross-reference evidence markers (swot-content generator)
|
|
467
|
+
const swotRefEvidence = countOccurrences(htmlNoScripts, 'class="swot-ref-evidence"');
|
|
468
|
+
// Legacy / generic evidence markers
|
|
469
|
+
const evidenceClasses = countOccurrences(htmlNoScripts, 'class="evidence"');
|
|
470
|
+
const dataRefs = countOccurrences(htmlNoScripts, 'data-reference');
|
|
471
|
+
// EP document reference codes
|
|
472
|
+
const matched = new Set();
|
|
473
|
+
for (const pattern of EP_DOC_PATTERNS) {
|
|
474
|
+
pattern.lastIndex = 0;
|
|
475
|
+
const hits = htmlNoScripts.match(pattern);
|
|
476
|
+
if (hits) {
|
|
477
|
+
for (const hit of hits) {
|
|
478
|
+
matched.add(hit);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
const epRefs = matched.size;
|
|
483
|
+
return (perspectiveEvidenceItems +
|
|
484
|
+
evidenceRefsItems +
|
|
485
|
+
swotRefEvidence +
|
|
486
|
+
evidenceClasses +
|
|
487
|
+
dataRefs +
|
|
488
|
+
epRefs);
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Search backwards from a given index to find the opening `<` of the
|
|
492
|
+
* enclosing HTML tag.
|
|
493
|
+
*
|
|
494
|
+
* @param html - HTML string to search
|
|
495
|
+
* @param fromIdx - Index to start searching backwards from
|
|
496
|
+
* @param fallback - Value to return if no `<` is found
|
|
497
|
+
* @returns Index of the opening `<`, or `fallback` if none is found
|
|
498
|
+
*/
|
|
499
|
+
function findTagStartBefore(html, fromIdx, fallback) {
|
|
500
|
+
let pos = fromIdx - 1;
|
|
501
|
+
while (pos >= 0 && html[pos] !== '<')
|
|
502
|
+
pos--;
|
|
503
|
+
return pos >= 0 ? pos : fallback;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Count evidence markers inside deep-analysis sections only, preventing
|
|
507
|
+
* inflation from evidence markers elsewhere in the article.
|
|
508
|
+
*
|
|
509
|
+
* Iterates ALL matching deep-analysis sections (not just the first),
|
|
510
|
+
* so articles with multiple deep-analysis blocks are fully counted.
|
|
511
|
+
* Deduplicates matches across class and id patterns using the opening-tag
|
|
512
|
+
* boundary (`<` position) so that a tag matching both class and id is counted
|
|
513
|
+
* only once.
|
|
514
|
+
*
|
|
515
|
+
* Detects:
|
|
516
|
+
* - `<li>` items inside `<ul class="perspective-evidence">` — real generator output
|
|
517
|
+
* - `class="swot-ref-evidence"` — SWOT cross-reference evidence
|
|
518
|
+
* - `class="evidence"` — generic/legacy evidence markers
|
|
519
|
+
* - `data-reference` attributes
|
|
520
|
+
*
|
|
521
|
+
* @param html - Raw HTML string
|
|
522
|
+
* @returns Evidence count restricted to deep-analysis section(s)
|
|
523
|
+
*/
|
|
524
|
+
function countDeepAnalysisSectionEvidence(html) {
|
|
525
|
+
const openPatterns = [/class="deep-analysis"[^>]*>/giu, /id="[^"]*deep[^"]*"[^>]*>/giu];
|
|
526
|
+
let total = 0;
|
|
527
|
+
const countedTagStarts = new Set();
|
|
528
|
+
for (const pattern of openPatterns) {
|
|
529
|
+
pattern.lastIndex = 0;
|
|
530
|
+
let openMatch = pattern.exec(html);
|
|
531
|
+
while (openMatch) {
|
|
532
|
+
const tagStart = findTagStartBefore(html, openMatch.index, openMatch.index);
|
|
533
|
+
if (!countedTagStarts.has(tagStart)) {
|
|
534
|
+
countedTagStarts.add(tagStart);
|
|
535
|
+
const startIdx = openMatch.index + openMatch[0].length;
|
|
536
|
+
const sectionContent = findBalancedContent(html, startIdx);
|
|
537
|
+
if (sectionContent) {
|
|
538
|
+
total +=
|
|
539
|
+
countListItemsInClass(sectionContent, 'class="perspective-evidence"') +
|
|
540
|
+
countOccurrences(sectionContent, 'class="swot-ref-evidence"') +
|
|
541
|
+
countOccurrences(sectionContent, 'class="evidence"') +
|
|
542
|
+
countOccurrences(sectionContent, 'data-reference');
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
openMatch = pattern.exec(html);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return total;
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Compute the mindmap branch count.
|
|
552
|
+
*
|
|
553
|
+
* Priority order:
|
|
554
|
+
* 1. `data-branch-count="N"` attribute on `.mindmap-container` — the generators
|
|
555
|
+
* set this to the exact number of top-level branches (`config.branches.length`
|
|
556
|
+
* or `domainNodes.length`), so it is the most reliable source.
|
|
557
|
+
* 2. `class="mindmap-branch"` element count.
|
|
558
|
+
* 3. Direct `<li>` children of the first `.mindmap-branches` container (layer-1
|
|
559
|
+
* only), using nesting-depth tracking to exclude nested subnodes.
|
|
560
|
+
*
|
|
561
|
+
* @param html - Raw HTML string
|
|
562
|
+
* @returns Number of mindmap branches detected
|
|
563
|
+
*/
|
|
564
|
+
function computeMindmapBranches(html) {
|
|
565
|
+
// 1. Prefer the explicit data-branch-count attribute set by the generators
|
|
566
|
+
const attrMatch = /data-branch-count="(\d+)"/u.exec(html);
|
|
567
|
+
if (attrMatch?.[1]) {
|
|
568
|
+
const parsed = parseInt(attrMatch[1], 10);
|
|
569
|
+
if (parsed > 0)
|
|
570
|
+
return parsed;
|
|
571
|
+
}
|
|
572
|
+
// 2. Count class="mindmap-branch" elements
|
|
573
|
+
const branchCount = countOccurrences(html, 'class="mindmap-branch"');
|
|
574
|
+
if (branchCount > 0)
|
|
575
|
+
return branchCount;
|
|
576
|
+
// 3. Count only direct <li> children of the mindmap-branches container
|
|
577
|
+
return countDirectListChildren(html, 'class="mindmap-branches');
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Walk forward from a starting position to find balanced closing tag content.
|
|
581
|
+
*
|
|
582
|
+
* @param html - HTML string
|
|
583
|
+
* @param startIdx - Position right after the opening tag
|
|
584
|
+
* @returns Content between the opening and its balanced closing tag, or empty string
|
|
585
|
+
*/
|
|
586
|
+
function findBalancedContent(html, startIdx) {
|
|
587
|
+
let depth = 1;
|
|
588
|
+
const closeTagPattern = /<\/?(?:div|section|article)[\s>/]/giu;
|
|
589
|
+
closeTagPattern.lastIndex = startIdx;
|
|
590
|
+
let tagMatch = closeTagPattern.exec(html);
|
|
591
|
+
while (tagMatch) {
|
|
592
|
+
if (tagMatch[0].startsWith('</')) {
|
|
593
|
+
depth--;
|
|
594
|
+
if (depth === 0)
|
|
595
|
+
return html.slice(startIdx, tagMatch.index);
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
depth++;
|
|
599
|
+
}
|
|
600
|
+
tagMatch = closeTagPattern.exec(html);
|
|
601
|
+
}
|
|
602
|
+
return '';
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Check whether generic/placeholder phrases appear in the article text.
|
|
606
|
+
*
|
|
607
|
+
* @param html - Raw HTML string
|
|
608
|
+
* @returns true if any generic phrase pattern is detected
|
|
609
|
+
*/
|
|
610
|
+
function hasGenericPhrases(html) {
|
|
611
|
+
return GENERIC_PHRASE_PATTERNS.some((pattern) => pattern.test(html));
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Clamp a numeric value between 0 and 100.
|
|
615
|
+
*
|
|
616
|
+
* @param value - Value to clamp
|
|
617
|
+
* @returns Value clamped to [0, 100]
|
|
618
|
+
*/
|
|
619
|
+
function clamp100(value) {
|
|
620
|
+
return Math.max(0, Math.min(100, value));
|
|
621
|
+
}
|
|
622
|
+
// ─── Analysis depth assessment ────────────────────────────────────────────────
|
|
623
|
+
/**
|
|
624
|
+
* Assess the analytical depth of an article by detecting keyword signals.
|
|
625
|
+
*
|
|
626
|
+
* Accepts either raw HTML or pre-extracted plain text. When called from
|
|
627
|
+
* {@link scoreArticleQuality} the text is already extracted, avoiding
|
|
628
|
+
* redundant HTML stripping.
|
|
629
|
+
*
|
|
630
|
+
* @param htmlOrText - Raw HTML string or pre-extracted plain text
|
|
631
|
+
* @param preExtracted - If true, treat `htmlOrText` as already-extracted plain text
|
|
632
|
+
* @returns Analysis depth score with per-dimension flags and composite score
|
|
633
|
+
*/
|
|
634
|
+
export function assessAnalysisDepth(htmlOrText, preExtracted = false) {
|
|
635
|
+
const text = preExtracted ? htmlOrText : extractPlainText(htmlOrText);
|
|
636
|
+
const politicalContextPresent = containsAnyKeyword(text, POLITICAL_CONTEXT_KEYWORDS);
|
|
637
|
+
const coalitionDynamicsAnalyzed = containsAnyKeyword(text, COALITION_DYNAMICS_KEYWORDS);
|
|
638
|
+
const historicalContextProvided = containsAnyKeyword(text, HISTORICAL_CONTEXT_KEYWORDS);
|
|
639
|
+
const evidenceBasedConclusions = containsAnyKeyword(text, EVIDENCE_BASED_KEYWORDS);
|
|
640
|
+
const scenarioPlanning = containsAnyKeyword(text, SCENARIO_PLANNING_KEYWORDS);
|
|
641
|
+
const confidenceLevelsIndicated = containsAnyKeyword(text, CONFIDENCE_LEVEL_KEYWORDS);
|
|
642
|
+
const dimensions = [
|
|
643
|
+
politicalContextPresent,
|
|
644
|
+
coalitionDynamicsAnalyzed,
|
|
645
|
+
historicalContextProvided,
|
|
646
|
+
evidenceBasedConclusions,
|
|
647
|
+
scenarioPlanning,
|
|
648
|
+
confidenceLevelsIndicated,
|
|
649
|
+
];
|
|
650
|
+
const presentCount = dimensions.filter(Boolean).length;
|
|
651
|
+
const score = clamp100(Math.round((presentCount / dimensions.length) * 100));
|
|
652
|
+
return {
|
|
653
|
+
politicalContextPresent,
|
|
654
|
+
coalitionDynamicsAnalyzed,
|
|
655
|
+
historicalContextProvided,
|
|
656
|
+
evidenceBasedConclusions,
|
|
657
|
+
scenarioPlanning,
|
|
658
|
+
confidenceLevelsIndicated,
|
|
659
|
+
score,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
// ─── Stakeholder coverage assessment ─────────────────────────────────────────
|
|
663
|
+
/**
|
|
664
|
+
* Assess how many stakeholder perspectives are covered in the article text.
|
|
665
|
+
*
|
|
666
|
+
* Accepts either raw HTML or pre-extracted plain text. When called from
|
|
667
|
+
* {@link scoreArticleQuality} the text is already extracted, avoiding
|
|
668
|
+
* redundant HTML stripping.
|
|
669
|
+
*
|
|
670
|
+
* @param htmlOrText - Raw HTML string or pre-extracted plain text
|
|
671
|
+
* @param preExtracted - If true, treat `htmlOrText` as already-extracted plain text
|
|
672
|
+
* @returns Stakeholder coverage assessment with present/missing lists and scores
|
|
673
|
+
*/
|
|
674
|
+
export function assessStakeholderCoverage(htmlOrText, preExtracted = false) {
|
|
675
|
+
const text = preExtracted ? htmlOrText : extractPlainText(htmlOrText);
|
|
676
|
+
const perspectivesPresent = [];
|
|
677
|
+
const perspectivesMissing = [];
|
|
678
|
+
for (const stakeholder of STAKEHOLDER_KEYWORDS) {
|
|
679
|
+
if (containsAnyKeyword(text, stakeholder.keywords)) {
|
|
680
|
+
perspectivesPresent.push(stakeholder.name);
|
|
681
|
+
}
|
|
682
|
+
else {
|
|
683
|
+
perspectivesMissing.push(stakeholder.name);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
const total = STAKEHOLDER_KEYWORDS.length;
|
|
687
|
+
const balanceScore = clamp100(Math.round((perspectivesPresent.length / total) * 100));
|
|
688
|
+
// Reasoning quality: bonus for not using generic phrases, penalty if they are present
|
|
689
|
+
const genericPenalty = hasGenericPhrases(text) ? 20 : 0;
|
|
690
|
+
const baseReasoningScore = balanceScore;
|
|
691
|
+
const reasoningQuality = clamp100(baseReasoningScore - genericPenalty);
|
|
692
|
+
return {
|
|
693
|
+
perspectivesPresent,
|
|
694
|
+
perspectivesMissing,
|
|
695
|
+
balanceScore,
|
|
696
|
+
reasoningQuality,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
// ─── Visualization quality assessment ────────────────────────────────────────
|
|
700
|
+
/**
|
|
701
|
+
* Assess the quality of embedded visual elements (SWOT, dashboard, mindmap, deep analysis).
|
|
702
|
+
*
|
|
703
|
+
* @param html - Raw HTML string of the article
|
|
704
|
+
* @returns Visualization quality assessment with per-element flags and composite score
|
|
705
|
+
*/
|
|
706
|
+
export function assessVisualizationQuality(html) {
|
|
707
|
+
// SWOT: exact class-token match supports multi-class attributes
|
|
708
|
+
// (e.g. class="swot-analysis swot-multidimensional")
|
|
709
|
+
const swotPresent = hasExactClassToken(html, 'swot-analysis') || html.includes('id="swot-analysis"');
|
|
710
|
+
// Partial match: quadrant classes include a variant suffix (e.g. "swot-quadrant swot-strengths")
|
|
711
|
+
const swotDimensions = countOccurrences(html, 'swot-quadrant') + countOccurrences(html, 'data-dimension');
|
|
712
|
+
// Dashboard: exact class-token match prevents false positives from hyphenated
|
|
713
|
+
// classes like "dashboard-grid", "dashboard-panel", "dashboard-chart"
|
|
714
|
+
const dashboardPresent = hasExactClassToken(html, 'dashboard') || html.includes('id="dashboard"');
|
|
715
|
+
const dashboardMetrics = countExactClassToken(html, 'metric-card') + countExactClassToken(html, 'dashboard-metric');
|
|
716
|
+
// Trend indicators: metric-trend-up/-down/-stable classes, or arrow symbols
|
|
717
|
+
const dashboardTrends = html.includes('class="metric-trend-') || html.includes('↑') || html.includes('↓');
|
|
718
|
+
// Mindmap: exact class-token match supports multi-class attributes
|
|
719
|
+
const mindmapPresent = hasExactClassToken(html, 'mindmap-section') ||
|
|
720
|
+
hasExactClassToken(html, 'mindmap-container') ||
|
|
721
|
+
html.includes('id="mindmap"');
|
|
722
|
+
const mindmapBranches = mindmapPresent ? computeMindmapBranches(html) : 0;
|
|
723
|
+
const deepAnalysisPresent = html.includes(CLASS_DEEP_ANALYSIS) || /id="[^"]*deep[^"]*"/iu.test(html);
|
|
724
|
+
// Restrict evidence counting to deep-analysis section(s) only to avoid
|
|
725
|
+
// inflating the metric with evidence markers elsewhere in the article.
|
|
726
|
+
const deepAnalysisEvidence = deepAnalysisPresent ? countDeepAnalysisSectionEvidence(html) : 0;
|
|
727
|
+
const score = computeVisualizationScore({
|
|
728
|
+
swotPresent,
|
|
729
|
+
swotDimensions,
|
|
730
|
+
dashboardPresent,
|
|
731
|
+
dashboardMetrics,
|
|
732
|
+
dashboardTrends,
|
|
733
|
+
mindmapPresent,
|
|
734
|
+
mindmapBranches,
|
|
735
|
+
deepAnalysisPresent,
|
|
736
|
+
deepAnalysisEvidence,
|
|
737
|
+
});
|
|
738
|
+
return {
|
|
739
|
+
swotPresent,
|
|
740
|
+
swotDimensions,
|
|
741
|
+
dashboardPresent,
|
|
742
|
+
dashboardMetrics,
|
|
743
|
+
dashboardTrends,
|
|
744
|
+
mindmapPresent,
|
|
745
|
+
mindmapBranches,
|
|
746
|
+
deepAnalysisPresent,
|
|
747
|
+
deepAnalysisEvidence,
|
|
748
|
+
score,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Compute a 0–100 composite visualization score from individual element assessments.
|
|
753
|
+
*
|
|
754
|
+
* @param v - Visualization dimensions (without the score field)
|
|
755
|
+
* @returns Composite visualization score clamped to [0, 100]
|
|
756
|
+
*/
|
|
757
|
+
function computeVisualizationScore(v) {
|
|
758
|
+
let score = 0;
|
|
759
|
+
// SWOT contribution (max 25 points)
|
|
760
|
+
if (v.swotPresent) {
|
|
761
|
+
score += 10;
|
|
762
|
+
score += Math.min(15, v.swotDimensions * 5);
|
|
763
|
+
}
|
|
764
|
+
// Dashboard contribution (max 25 points)
|
|
765
|
+
if (v.dashboardPresent) {
|
|
766
|
+
score += 10;
|
|
767
|
+
score += Math.min(10, v.dashboardMetrics * 2);
|
|
768
|
+
if (v.dashboardTrends)
|
|
769
|
+
score += 5;
|
|
770
|
+
}
|
|
771
|
+
// Mindmap contribution (max 25 points)
|
|
772
|
+
if (v.mindmapPresent) {
|
|
773
|
+
score += 10;
|
|
774
|
+
score += Math.min(15, v.mindmapBranches * 5);
|
|
775
|
+
}
|
|
776
|
+
// Deep analysis contribution (max 25 points)
|
|
777
|
+
if (v.deepAnalysisPresent) {
|
|
778
|
+
score += 10;
|
|
779
|
+
score += Math.min(15, v.deepAnalysisEvidence * 3);
|
|
780
|
+
}
|
|
781
|
+
return clamp100(score);
|
|
782
|
+
}
|
|
783
|
+
// ─── Overall score calculation ────────────────────────────────────────────────
|
|
784
|
+
/**
|
|
785
|
+
* Compute the weighted overall quality score (0–100) from component scores.
|
|
786
|
+
*
|
|
787
|
+
* Weights:
|
|
788
|
+
* - Analysis depth: 25 %
|
|
789
|
+
* - Stakeholder balance: 20 %
|
|
790
|
+
* - Visualization: 25 %
|
|
791
|
+
* - Word count: 15 %
|
|
792
|
+
* - Evidence references: 15 %
|
|
793
|
+
*
|
|
794
|
+
* @param depth - Analysis depth score object
|
|
795
|
+
* @param coverage - Stakeholder coverage score object
|
|
796
|
+
* @param viz - Visualization quality score object
|
|
797
|
+
* @param wordCount - Plain-text word count of the article
|
|
798
|
+
* @param evidenceRefs - Number of evidence/document references
|
|
799
|
+
* @returns Overall quality score clamped to [0, 100]
|
|
800
|
+
*/
|
|
801
|
+
export function calculateOverallScore(depth, coverage, viz, wordCount, evidenceRefs) {
|
|
802
|
+
const wordCountScore = clamp100(Math.round(((wordCount - WORD_COUNT_MIN) / (WORD_COUNT_MAX - WORD_COUNT_MIN)) * 100));
|
|
803
|
+
const evidenceScore = clamp100(Math.round((evidenceRefs / EVIDENCE_MAX) * 100));
|
|
804
|
+
const overall = depth.score * WEIGHT_ANALYSIS_DEPTH +
|
|
805
|
+
coverage.balanceScore * WEIGHT_STAKEHOLDER +
|
|
806
|
+
viz.score * WEIGHT_VISUALIZATION +
|
|
807
|
+
wordCountScore * WEIGHT_WORD_COUNT +
|
|
808
|
+
evidenceScore * WEIGHT_EVIDENCE;
|
|
809
|
+
return clamp100(Math.round(overall));
|
|
810
|
+
}
|
|
811
|
+
// ─── Grade assignment ─────────────────────────────────────────────────────────
|
|
812
|
+
/**
|
|
813
|
+
* Convert an overall score to a letter grade.
|
|
814
|
+
*
|
|
815
|
+
* @param score - Overall quality score (0–100)
|
|
816
|
+
* @returns Letter grade A–F
|
|
817
|
+
*/
|
|
818
|
+
function scoreToGrade(score) {
|
|
819
|
+
if (score >= GRADE_A_MIN)
|
|
820
|
+
return 'A';
|
|
821
|
+
if (score >= GRADE_B_MIN)
|
|
822
|
+
return 'B';
|
|
823
|
+
if (score >= GRADE_C_MIN)
|
|
824
|
+
return 'C';
|
|
825
|
+
if (score >= GRADE_D_MIN)
|
|
826
|
+
return 'D';
|
|
827
|
+
return 'F';
|
|
828
|
+
}
|
|
829
|
+
// ─── Non-English language adjustments ─────────────────────────────────────────
|
|
830
|
+
/**
|
|
831
|
+
* Baseline score assigned to keyword-based analysis dimensions for non-English
|
|
832
|
+
* articles, so that translated content is not systematically penalised by the
|
|
833
|
+
* English-only keyword lists.
|
|
834
|
+
*/
|
|
835
|
+
const NON_ENGLISH_BASELINE = 50;
|
|
836
|
+
/**
|
|
837
|
+
* Adjust analysis depth for non-English articles.
|
|
838
|
+
*
|
|
839
|
+
* English keyword lists do not apply to translated text, so we raise the
|
|
840
|
+
* composite score to at least a baseline when the raw keyword scan scored low.
|
|
841
|
+
* This prevents non-English articles from being systematically under-scored.
|
|
842
|
+
*
|
|
843
|
+
* @param depth - Raw analysis depth from keyword scanning
|
|
844
|
+
* @returns Adjusted analysis depth with a baseline floor
|
|
845
|
+
*/
|
|
846
|
+
function adjustNonEnglishAnalysisDepth(depth) {
|
|
847
|
+
return {
|
|
848
|
+
...depth,
|
|
849
|
+
score: Math.max(depth.score, NON_ENGLISH_BASELINE),
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Adjust stakeholder coverage for non-English articles.
|
|
854
|
+
*
|
|
855
|
+
* English stakeholder keyword lists may not match translated terms, so we apply
|
|
856
|
+
* a baseline floor to prevent systematically low balance and reasoning scores.
|
|
857
|
+
*
|
|
858
|
+
* @param coverage - Raw stakeholder coverage from keyword scanning
|
|
859
|
+
* @returns Adjusted coverage with baseline floors on balance and reasoning scores
|
|
860
|
+
*/
|
|
861
|
+
function adjustNonEnglishStakeholderCoverage(coverage) {
|
|
862
|
+
return {
|
|
863
|
+
...coverage,
|
|
864
|
+
balanceScore: Math.max(coverage.balanceScore, NON_ENGLISH_BASELINE),
|
|
865
|
+
reasoningQuality: Math.max(coverage.reasoningQuality, NON_ENGLISH_BASELINE),
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
// ─── Recommendation generation ────────────────────────────────────────────────
|
|
869
|
+
/**
|
|
870
|
+
* Generate actionable improvement recommendations based on a partial quality report.
|
|
871
|
+
*
|
|
872
|
+
* For non-English articles, keyword-based analysis-depth and stakeholder recommendations
|
|
873
|
+
* are omitted because the underlying boolean flags derive from English-only keyword
|
|
874
|
+
* lists, making them unreliable for translated content.
|
|
875
|
+
*
|
|
876
|
+
* @param report - Quality report without the recommendations field
|
|
877
|
+
* @returns Array of recommendation strings (may be empty for high-quality articles)
|
|
878
|
+
*/
|
|
879
|
+
export function generateRecommendations(report) {
|
|
880
|
+
const recs = [];
|
|
881
|
+
const isEnglish = report.lang === 'en';
|
|
882
|
+
addWordCountRecommendations(report, recs);
|
|
883
|
+
// Only emit keyword-dependent recommendations for English articles; non-English
|
|
884
|
+
// articles have baseline-adjusted scores and keyword detection is not reliable.
|
|
885
|
+
if (isEnglish) {
|
|
886
|
+
addAnalysisDepthRecommendations(report.analysisDepth, recs);
|
|
887
|
+
addStakeholderRecommendations(report.stakeholderCoverage, recs);
|
|
888
|
+
}
|
|
889
|
+
addVisualizationRecommendations(report.visualizationQuality, recs);
|
|
890
|
+
addEvidenceRecommendations(report, recs);
|
|
891
|
+
return recs;
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Add word-count related recommendations.
|
|
895
|
+
*
|
|
896
|
+
* @param report - Partial quality report
|
|
897
|
+
* @param recs - Mutable array to push recommendations into
|
|
898
|
+
*/
|
|
899
|
+
function addWordCountRecommendations(report, recs) {
|
|
900
|
+
if (report.wordCount < 500) {
|
|
901
|
+
recs.push('Expand article length to at least 500 words for Grade C quality');
|
|
902
|
+
}
|
|
903
|
+
else if (report.wordCount < WORD_COUNT_MAX) {
|
|
904
|
+
recs.push(`Increase article depth to ${WORD_COUNT_MAX} words for Grade A quality (currently ${report.wordCount})`);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Add analysis-depth recommendations.
|
|
909
|
+
*
|
|
910
|
+
* @param depth - Analysis depth score
|
|
911
|
+
* @param recs - Mutable array to push recommendations into
|
|
912
|
+
*/
|
|
913
|
+
function addAnalysisDepthRecommendations(depth, recs) {
|
|
914
|
+
if (!depth.politicalContextPresent) {
|
|
915
|
+
recs.push('Add political context: discuss coalitions, majorities, and opposition dynamics');
|
|
916
|
+
}
|
|
917
|
+
if (!depth.coalitionDynamicsAnalyzed) {
|
|
918
|
+
recs.push('Analyse coalition dynamics between EPP, S&D, Renew, and other groups');
|
|
919
|
+
}
|
|
920
|
+
if (!depth.historicalContextProvided) {
|
|
921
|
+
recs.push('Provide historical context by comparing to previous terms or key milestones');
|
|
922
|
+
}
|
|
923
|
+
if (!depth.evidenceBasedConclusions) {
|
|
924
|
+
recs.push('Support conclusions with data, figures, or cited evidence');
|
|
925
|
+
}
|
|
926
|
+
if (!depth.scenarioPlanning) {
|
|
927
|
+
recs.push('Include forward-looking scenarios or projections');
|
|
928
|
+
}
|
|
929
|
+
if (!depth.confidenceLevelsIndicated) {
|
|
930
|
+
recs.push('State confidence levels or acknowledge uncertainty in assessments');
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Add stakeholder coverage recommendations.
|
|
935
|
+
*
|
|
936
|
+
* @param coverage - Stakeholder coverage assessment
|
|
937
|
+
* @param recs - Mutable array to push recommendations into
|
|
938
|
+
*/
|
|
939
|
+
function addStakeholderRecommendations(coverage, recs) {
|
|
940
|
+
if (coverage.perspectivesMissing.length > 0) {
|
|
941
|
+
recs.push(`Add perspectives from missing stakeholders: ${coverage.perspectivesMissing.join(', ')}`);
|
|
942
|
+
}
|
|
943
|
+
if (coverage.reasoningQuality < 60) {
|
|
944
|
+
recs.push('Replace generic phrases (e.g. "several MEPs", "some countries") with specific named entities');
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Add visualization quality recommendations.
|
|
949
|
+
*
|
|
950
|
+
* @param viz - Visualization quality assessment
|
|
951
|
+
* @param recs - Mutable array to push recommendations into
|
|
952
|
+
*/
|
|
953
|
+
function addVisualizationRecommendations(viz, recs) {
|
|
954
|
+
if (!viz.swotPresent) {
|
|
955
|
+
recs.push('Add a SWOT analysis section to strengthen political assessment');
|
|
956
|
+
}
|
|
957
|
+
else if (viz.swotDimensions < 3) {
|
|
958
|
+
recs.push(`Expand SWOT dimensions to at least 3 (currently ${viz.swotDimensions})`);
|
|
959
|
+
}
|
|
960
|
+
if (!viz.dashboardPresent) {
|
|
961
|
+
recs.push('Add a data dashboard with key metrics for quantitative support');
|
|
962
|
+
}
|
|
963
|
+
else if (viz.dashboardMetrics < 5) {
|
|
964
|
+
recs.push(`Add more dashboard metrics to reach 5 (currently ${viz.dashboardMetrics})`);
|
|
965
|
+
}
|
|
966
|
+
if (!viz.mindmapPresent) {
|
|
967
|
+
recs.push('Add a mindmap to illustrate relationships and conceptual structure');
|
|
968
|
+
}
|
|
969
|
+
else if (viz.mindmapBranches < 3) {
|
|
970
|
+
recs.push(`Add more mindmap branches to reach 3 (currently ${viz.mindmapBranches})`);
|
|
971
|
+
}
|
|
972
|
+
if (!viz.deepAnalysisPresent) {
|
|
973
|
+
recs.push('Add deep-analysis sections to provide substantive investigative content');
|
|
974
|
+
}
|
|
975
|
+
else if (viz.deepAnalysisEvidence < 3) {
|
|
976
|
+
recs.push(`Include more evidence items in deep-analysis sections (currently ${viz.deepAnalysisEvidence})`);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Add evidence-reference recommendations.
|
|
981
|
+
*
|
|
982
|
+
* @param report - Partial quality report
|
|
983
|
+
* @param recs - Mutable array to push recommendations into
|
|
984
|
+
*/
|
|
985
|
+
function addEvidenceRecommendations(report, recs) {
|
|
986
|
+
if (report.evidenceReferences < 3) {
|
|
987
|
+
recs.push('Add at least 3 evidence references or EP document citations');
|
|
988
|
+
}
|
|
989
|
+
else if (report.evidenceReferences < 10) {
|
|
990
|
+
recs.push(`Increase evidence references to 10 for Grade A quality (currently ${report.evidenceReferences})`);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
994
|
+
/**
|
|
995
|
+
* Score the quality of a generated article and produce a comprehensive report.
|
|
996
|
+
*
|
|
997
|
+
* This is the primary entry point for the quality assessment pipeline.
|
|
998
|
+
*
|
|
999
|
+
* @param html - Complete HTML string of the generated article
|
|
1000
|
+
* @param articleId - Unique identifier for the article (typically the filename slug)
|
|
1001
|
+
* @param lang - Language code of the article (e.g. `"en"`, `"de"`)
|
|
1002
|
+
* @param articleType - Article category string (e.g. `"week-ahead"`)
|
|
1003
|
+
* @returns Comprehensive quality report including grade, score and recommendations
|
|
1004
|
+
*/
|
|
1005
|
+
export function scoreArticleQuality(html, articleId, lang, articleType) {
|
|
1006
|
+
// Extract plain text once to avoid redundant HTML stripping in sub-assessors
|
|
1007
|
+
const plainText = extractPlainText(html);
|
|
1008
|
+
const wordCount = plainText ? plainText.split(' ').length : 0;
|
|
1009
|
+
const analysisSections = countAnalysisSections(html);
|
|
1010
|
+
const evidenceReferences = countEvidenceRefs(html);
|
|
1011
|
+
// For non-English articles, keyword-based analysis-depth and stakeholder scoring
|
|
1012
|
+
// uses English keywords which may not appear in translated text. Weight structural
|
|
1013
|
+
// signals (visualization, word count, evidence) more heavily by treating keyword
|
|
1014
|
+
// dimensions as partially present when the language is not English.
|
|
1015
|
+
const isEnglish = lang === 'en';
|
|
1016
|
+
const analysisDepth = isEnglish
|
|
1017
|
+
? assessAnalysisDepth(plainText, true)
|
|
1018
|
+
: adjustNonEnglishAnalysisDepth(assessAnalysisDepth(plainText, true));
|
|
1019
|
+
const stakeholderCoverage = isEnglish
|
|
1020
|
+
? assessStakeholderCoverage(plainText, true)
|
|
1021
|
+
: adjustNonEnglishStakeholderCoverage(assessStakeholderCoverage(plainText, true));
|
|
1022
|
+
const visualizationQuality = assessVisualizationQuality(html);
|
|
1023
|
+
const overallScore = calculateOverallScore(analysisDepth, stakeholderCoverage, visualizationQuality, wordCount, evidenceReferences);
|
|
1024
|
+
const grade = scoreToGrade(overallScore);
|
|
1025
|
+
const passesQualityGate = overallScore >= QUALITY_GATE_THRESHOLD;
|
|
1026
|
+
// Derive the article's date from its ID when possible (slug format: YYYY-MM-DD-…),
|
|
1027
|
+
// falling back to the current execution date only if the ID does not contain a date prefix.
|
|
1028
|
+
const dateMatch = ARTICLE_DATE_PATTERN.exec(articleId);
|
|
1029
|
+
const date = dateMatch?.[1] ?? new Date().toISOString().split('T')[0] ?? '';
|
|
1030
|
+
const partial = {
|
|
1031
|
+
articleId,
|
|
1032
|
+
date,
|
|
1033
|
+
type: articleType,
|
|
1034
|
+
lang,
|
|
1035
|
+
wordCount,
|
|
1036
|
+
analysisSections,
|
|
1037
|
+
evidenceReferences,
|
|
1038
|
+
analysisDepth,
|
|
1039
|
+
stakeholderCoverage,
|
|
1040
|
+
visualizationQuality,
|
|
1041
|
+
overallScore,
|
|
1042
|
+
grade,
|
|
1043
|
+
passesQualityGate,
|
|
1044
|
+
};
|
|
1045
|
+
const recommendations = generateRecommendations(partial);
|
|
1046
|
+
return { ...partial, recommendations };
|
|
1047
|
+
}
|
|
1048
|
+
//# sourceMappingURL=article-quality-scorer.js.map
|