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.
Files changed (276) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +1005 -0
  3. package/SECURITY.md +151 -0
  4. package/package.json +131 -0
  5. package/scripts/constants/committee-indicator-map.d.ts +199 -0
  6. package/scripts/constants/committee-indicator-map.d.ts.map +1 -0
  7. package/scripts/constants/committee-indicator-map.js +1224 -0
  8. package/scripts/constants/committee-indicator-map.js.map +1 -0
  9. package/scripts/constants/config.d.ts +38 -0
  10. package/scripts/constants/config.d.ts.map +1 -0
  11. package/scripts/constants/config.js +66 -0
  12. package/scripts/constants/config.js.map +1 -0
  13. package/scripts/constants/language-articles.d.ts +84 -0
  14. package/scripts/constants/language-articles.d.ts.map +1 -0
  15. package/scripts/constants/language-articles.js +6771 -0
  16. package/scripts/constants/language-articles.js.map +1 -0
  17. package/scripts/constants/language-core.d.ts +38 -0
  18. package/scripts/constants/language-core.d.ts.map +1 -0
  19. package/scripts/constants/language-core.js +90 -0
  20. package/scripts/constants/language-core.js.map +1 -0
  21. package/scripts/constants/language-ui.d.ts +82 -0
  22. package/scripts/constants/language-ui.d.ts.map +1 -0
  23. package/scripts/constants/language-ui.js +889 -0
  24. package/scripts/constants/language-ui.js.map +1 -0
  25. package/scripts/constants/languages.d.ts +14 -0
  26. package/scripts/constants/languages.d.ts.map +1 -0
  27. package/scripts/constants/languages.js +15 -0
  28. package/scripts/constants/languages.js.map +1 -0
  29. package/scripts/generators/analysis-builders.d.ts +266 -0
  30. package/scripts/generators/analysis-builders.d.ts.map +1 -0
  31. package/scripts/generators/analysis-builders.js +2903 -0
  32. package/scripts/generators/analysis-builders.js.map +1 -0
  33. package/scripts/generators/breaking-content.d.ts +45 -0
  34. package/scripts/generators/breaking-content.d.ts.map +1 -0
  35. package/scripts/generators/breaking-content.js +530 -0
  36. package/scripts/generators/breaking-content.js.map +1 -0
  37. package/scripts/generators/committee-helpers.d.ts +54 -0
  38. package/scripts/generators/committee-helpers.d.ts.map +1 -0
  39. package/scripts/generators/committee-helpers.js +154 -0
  40. package/scripts/generators/committee-helpers.js.map +1 -0
  41. package/scripts/generators/dashboard-content.d.ts +95 -0
  42. package/scripts/generators/dashboard-content.d.ts.map +1 -0
  43. package/scripts/generators/dashboard-content.js +630 -0
  44. package/scripts/generators/dashboard-content.js.map +1 -0
  45. package/scripts/generators/deep-analysis-content.d.ts +23 -0
  46. package/scripts/generators/deep-analysis-content.d.ts.map +1 -0
  47. package/scripts/generators/deep-analysis-content.js +831 -0
  48. package/scripts/generators/deep-analysis-content.js.map +1 -0
  49. package/scripts/generators/mindmap-content.d.ts +55 -0
  50. package/scripts/generators/mindmap-content.d.ts.map +1 -0
  51. package/scripts/generators/mindmap-content.js +512 -0
  52. package/scripts/generators/mindmap-content.js.map +1 -0
  53. package/scripts/generators/motions-content.d.ts +50 -0
  54. package/scripts/generators/motions-content.d.ts.map +1 -0
  55. package/scripts/generators/motions-content.js +391 -0
  56. package/scripts/generators/motions-content.js.map +1 -0
  57. package/scripts/generators/news-enhanced.d.ts +14 -0
  58. package/scripts/generators/news-enhanced.d.ts.map +1 -0
  59. package/scripts/generators/news-enhanced.js +169 -0
  60. package/scripts/generators/news-enhanced.js.map +1 -0
  61. package/scripts/generators/news-indexes.d.ts +31 -0
  62. package/scripts/generators/news-indexes.d.ts.map +1 -0
  63. package/scripts/generators/news-indexes.js +410 -0
  64. package/scripts/generators/news-indexes.js.map +1 -0
  65. package/scripts/generators/pipeline/fetch-stage.d.ts +352 -0
  66. package/scripts/generators/pipeline/fetch-stage.d.ts.map +1 -0
  67. package/scripts/generators/pipeline/fetch-stage.js +1522 -0
  68. package/scripts/generators/pipeline/fetch-stage.js.map +1 -0
  69. package/scripts/generators/pipeline/generate-stage.d.ts +43 -0
  70. package/scripts/generators/pipeline/generate-stage.d.ts.map +1 -0
  71. package/scripts/generators/pipeline/generate-stage.js +204 -0
  72. package/scripts/generators/pipeline/generate-stage.js.map +1 -0
  73. package/scripts/generators/pipeline/output-stage.d.ts +48 -0
  74. package/scripts/generators/pipeline/output-stage.d.ts.map +1 -0
  75. package/scripts/generators/pipeline/output-stage.js +145 -0
  76. package/scripts/generators/pipeline/output-stage.js.map +1 -0
  77. package/scripts/generators/pipeline/transform-stage.d.ts +57 -0
  78. package/scripts/generators/pipeline/transform-stage.d.ts.map +1 -0
  79. package/scripts/generators/pipeline/transform-stage.js +111 -0
  80. package/scripts/generators/pipeline/transform-stage.js.map +1 -0
  81. package/scripts/generators/propositions-content.d.ts +29 -0
  82. package/scripts/generators/propositions-content.d.ts.map +1 -0
  83. package/scripts/generators/propositions-content.js +90 -0
  84. package/scripts/generators/propositions-content.js.map +1 -0
  85. package/scripts/generators/sankey-content.d.ts +45 -0
  86. package/scripts/generators/sankey-content.d.ts.map +1 -0
  87. package/scripts/generators/sankey-content.js +227 -0
  88. package/scripts/generators/sankey-content.js.map +1 -0
  89. package/scripts/generators/sitemap.d.ts +66 -0
  90. package/scripts/generators/sitemap.d.ts.map +1 -0
  91. package/scripts/generators/sitemap.js +562 -0
  92. package/scripts/generators/sitemap.js.map +1 -0
  93. package/scripts/generators/strategies/article-strategy.d.ts +146 -0
  94. package/scripts/generators/strategies/article-strategy.d.ts.map +1 -0
  95. package/scripts/generators/strategies/article-strategy.js +4 -0
  96. package/scripts/generators/strategies/article-strategy.js.map +1 -0
  97. package/scripts/generators/strategies/breaking-news-strategy.d.ts +64 -0
  98. package/scripts/generators/strategies/breaking-news-strategy.d.ts.map +1 -0
  99. package/scripts/generators/strategies/breaking-news-strategy.js +246 -0
  100. package/scripts/generators/strategies/breaking-news-strategy.js.map +1 -0
  101. package/scripts/generators/strategies/committee-reports-strategy.d.ts +93 -0
  102. package/scripts/generators/strategies/committee-reports-strategy.d.ts.map +1 -0
  103. package/scripts/generators/strategies/committee-reports-strategy.js +447 -0
  104. package/scripts/generators/strategies/committee-reports-strategy.js.map +1 -0
  105. package/scripts/generators/strategies/month-ahead-strategy.d.ts +60 -0
  106. package/scripts/generators/strategies/month-ahead-strategy.d.ts.map +1 -0
  107. package/scripts/generators/strategies/month-ahead-strategy.js +175 -0
  108. package/scripts/generators/strategies/month-ahead-strategy.js.map +1 -0
  109. package/scripts/generators/strategies/monthly-review-strategy.d.ts +66 -0
  110. package/scripts/generators/strategies/monthly-review-strategy.d.ts.map +1 -0
  111. package/scripts/generators/strategies/monthly-review-strategy.js +204 -0
  112. package/scripts/generators/strategies/monthly-review-strategy.js.map +1 -0
  113. package/scripts/generators/strategies/motions-strategy.d.ts +61 -0
  114. package/scripts/generators/strategies/motions-strategy.d.ts.map +1 -0
  115. package/scripts/generators/strategies/motions-strategy.js +215 -0
  116. package/scripts/generators/strategies/motions-strategy.js.map +1 -0
  117. package/scripts/generators/strategies/propositions-strategy.d.ts +60 -0
  118. package/scripts/generators/strategies/propositions-strategy.d.ts.map +1 -0
  119. package/scripts/generators/strategies/propositions-strategy.js +257 -0
  120. package/scripts/generators/strategies/propositions-strategy.js.map +1 -0
  121. package/scripts/generators/strategies/week-ahead-strategy.d.ts +57 -0
  122. package/scripts/generators/strategies/week-ahead-strategy.d.ts.map +1 -0
  123. package/scripts/generators/strategies/week-ahead-strategy.js +178 -0
  124. package/scripts/generators/strategies/week-ahead-strategy.js.map +1 -0
  125. package/scripts/generators/strategies/weekly-review-strategy.d.ts +63 -0
  126. package/scripts/generators/strategies/weekly-review-strategy.d.ts.map +1 -0
  127. package/scripts/generators/strategies/weekly-review-strategy.js +211 -0
  128. package/scripts/generators/strategies/weekly-review-strategy.js.map +1 -0
  129. package/scripts/generators/swot-content.d.ts +42 -0
  130. package/scripts/generators/swot-content.d.ts.map +1 -0
  131. package/scripts/generators/swot-content.js +366 -0
  132. package/scripts/generators/swot-content.js.map +1 -0
  133. package/scripts/generators/week-ahead-content.d.ts +103 -0
  134. package/scripts/generators/week-ahead-content.d.ts.map +1 -0
  135. package/scripts/generators/week-ahead-content.js +610 -0
  136. package/scripts/generators/week-ahead-content.js.map +1 -0
  137. package/scripts/index.d.ts +40 -0
  138. package/scripts/index.d.ts.map +1 -0
  139. package/scripts/index.js +53 -0
  140. package/scripts/index.js.map +1 -0
  141. package/scripts/mcp/ep-mcp-client.d.ts +471 -0
  142. package/scripts/mcp/ep-mcp-client.d.ts.map +1 -0
  143. package/scripts/mcp/ep-mcp-client.js +734 -0
  144. package/scripts/mcp/ep-mcp-client.js.map +1 -0
  145. package/scripts/mcp/mcp-connection.d.ts +264 -0
  146. package/scripts/mcp/mcp-connection.d.ts.map +1 -0
  147. package/scripts/mcp/mcp-connection.js +790 -0
  148. package/scripts/mcp/mcp-connection.js.map +1 -0
  149. package/scripts/mcp/mcp-health.d.ts +75 -0
  150. package/scripts/mcp/mcp-health.d.ts.map +1 -0
  151. package/scripts/mcp/mcp-health.js +78 -0
  152. package/scripts/mcp/mcp-health.js.map +1 -0
  153. package/scripts/mcp/mcp-retry.d.ts +94 -0
  154. package/scripts/mcp/mcp-retry.d.ts.map +1 -0
  155. package/scripts/mcp/mcp-retry.js +127 -0
  156. package/scripts/mcp/mcp-retry.js.map +1 -0
  157. package/scripts/mcp/wb-mcp-client.d.ts +38 -0
  158. package/scripts/mcp/wb-mcp-client.d.ts.map +1 -0
  159. package/scripts/mcp/wb-mcp-client.js +112 -0
  160. package/scripts/mcp/wb-mcp-client.js.map +1 -0
  161. package/scripts/templates/article-template.d.ts +9 -0
  162. package/scripts/templates/article-template.d.ts.map +1 -0
  163. package/scripts/templates/article-template.js +378 -0
  164. package/scripts/templates/article-template.js.map +1 -0
  165. package/scripts/templates/section-builders.d.ts +28 -0
  166. package/scripts/templates/section-builders.d.ts.map +1 -0
  167. package/scripts/templates/section-builders.js +142 -0
  168. package/scripts/templates/section-builders.js.map +1 -0
  169. package/scripts/types/analysis.d.ts +115 -0
  170. package/scripts/types/analysis.d.ts.map +1 -0
  171. package/scripts/types/analysis.js +4 -0
  172. package/scripts/types/analysis.js.map +1 -0
  173. package/scripts/types/common.d.ts +584 -0
  174. package/scripts/types/common.d.ts.map +1 -0
  175. package/scripts/types/common.js +96 -0
  176. package/scripts/types/common.js.map +1 -0
  177. package/scripts/types/generation.d.ts +104 -0
  178. package/scripts/types/generation.d.ts.map +1 -0
  179. package/scripts/types/generation.js +4 -0
  180. package/scripts/types/generation.js.map +1 -0
  181. package/scripts/types/index.d.ts +24 -0
  182. package/scripts/types/index.d.ts.map +1 -0
  183. package/scripts/types/index.js +16 -0
  184. package/scripts/types/index.js.map +1 -0
  185. package/scripts/types/intelligence.d.ts +129 -0
  186. package/scripts/types/intelligence.d.ts.map +1 -0
  187. package/scripts/types/intelligence.js +4 -0
  188. package/scripts/types/intelligence.js.map +1 -0
  189. package/scripts/types/mcp.d.ts +418 -0
  190. package/scripts/types/mcp.d.ts.map +1 -0
  191. package/scripts/types/mcp.js +4 -0
  192. package/scripts/types/mcp.js.map +1 -0
  193. package/scripts/types/parliament.d.ts +388 -0
  194. package/scripts/types/parliament.d.ts.map +1 -0
  195. package/scripts/types/parliament.js +4 -0
  196. package/scripts/types/parliament.js.map +1 -0
  197. package/scripts/types/quality.d.ts +114 -0
  198. package/scripts/types/quality.d.ts.map +1 -0
  199. package/scripts/types/quality.js +4 -0
  200. package/scripts/types/quality.js.map +1 -0
  201. package/scripts/types/stakeholder.d.ts +88 -0
  202. package/scripts/types/stakeholder.d.ts.map +1 -0
  203. package/scripts/types/stakeholder.js +16 -0
  204. package/scripts/types/stakeholder.js.map +1 -0
  205. package/scripts/types/visualization.d.ts +708 -0
  206. package/scripts/types/visualization.d.ts.map +1 -0
  207. package/scripts/types/visualization.js +4 -0
  208. package/scripts/types/visualization.js.map +1 -0
  209. package/scripts/types/world-bank.d.ts +85 -0
  210. package/scripts/types/world-bank.d.ts.map +1 -0
  211. package/scripts/types/world-bank.js +4 -0
  212. package/scripts/types/world-bank.js.map +1 -0
  213. package/scripts/utils/article-category.d.ts +18 -0
  214. package/scripts/utils/article-category.d.ts.map +1 -0
  215. package/scripts/utils/article-category.js +49 -0
  216. package/scripts/utils/article-category.js.map +1 -0
  217. package/scripts/utils/article-quality-scorer.d.ts +87 -0
  218. package/scripts/utils/article-quality-scorer.d.ts.map +1 -0
  219. package/scripts/utils/article-quality-scorer.js +1048 -0
  220. package/scripts/utils/article-quality-scorer.js.map +1 -0
  221. package/scripts/utils/content-metadata.d.ts +34 -0
  222. package/scripts/utils/content-metadata.d.ts.map +1 -0
  223. package/scripts/utils/content-metadata.js +249 -0
  224. package/scripts/utils/content-metadata.js.map +1 -0
  225. package/scripts/utils/content-validator.d.ts +94 -0
  226. package/scripts/utils/content-validator.d.ts.map +1 -0
  227. package/scripts/utils/content-validator.js +489 -0
  228. package/scripts/utils/content-validator.js.map +1 -0
  229. package/scripts/utils/copy-test-reports.d.ts +9 -0
  230. package/scripts/utils/copy-test-reports.d.ts.map +1 -0
  231. package/scripts/utils/copy-test-reports.js +508 -0
  232. package/scripts/utils/copy-test-reports.js.map +1 -0
  233. package/scripts/utils/file-utils.d.ts +144 -0
  234. package/scripts/utils/file-utils.d.ts.map +1 -0
  235. package/scripts/utils/file-utils.js +374 -0
  236. package/scripts/utils/file-utils.js.map +1 -0
  237. package/scripts/utils/fix-articles.d.ts +27 -0
  238. package/scripts/utils/fix-articles.d.ts.map +1 -0
  239. package/scripts/utils/fix-articles.js +510 -0
  240. package/scripts/utils/fix-articles.js.map +1 -0
  241. package/scripts/utils/generate-docs-index.d.ts +8 -0
  242. package/scripts/utils/generate-docs-index.d.ts.map +1 -0
  243. package/scripts/utils/generate-docs-index.js +275 -0
  244. package/scripts/utils/generate-docs-index.js.map +1 -0
  245. package/scripts/utils/html-sanitize.d.ts +18 -0
  246. package/scripts/utils/html-sanitize.d.ts.map +1 -0
  247. package/scripts/utils/html-sanitize.js +57 -0
  248. package/scripts/utils/html-sanitize.js.map +1 -0
  249. package/scripts/utils/intelligence-analysis.d.ts +173 -0
  250. package/scripts/utils/intelligence-analysis.d.ts.map +1 -0
  251. package/scripts/utils/intelligence-analysis.js +936 -0
  252. package/scripts/utils/intelligence-analysis.js.map +1 -0
  253. package/scripts/utils/intelligence-index.d.ts +126 -0
  254. package/scripts/utils/intelligence-index.d.ts.map +1 -0
  255. package/scripts/utils/intelligence-index.js +731 -0
  256. package/scripts/utils/intelligence-index.js.map +1 -0
  257. package/scripts/utils/metadata-utils.d.ts +14 -0
  258. package/scripts/utils/metadata-utils.d.ts.map +1 -0
  259. package/scripts/utils/metadata-utils.js +18 -0
  260. package/scripts/utils/metadata-utils.js.map +1 -0
  261. package/scripts/utils/news-metadata.d.ts +47 -0
  262. package/scripts/utils/news-metadata.d.ts.map +1 -0
  263. package/scripts/utils/news-metadata.js +259 -0
  264. package/scripts/utils/news-metadata.js.map +1 -0
  265. package/scripts/utils/validate-articles.d.ts +2 -0
  266. package/scripts/utils/validate-articles.d.ts.map +1 -0
  267. package/scripts/utils/validate-articles.js +284 -0
  268. package/scripts/utils/validate-articles.js.map +1 -0
  269. package/scripts/utils/validate-ep-api.d.ts +51 -0
  270. package/scripts/utils/validate-ep-api.d.ts.map +1 -0
  271. package/scripts/utils/validate-ep-api.js +160 -0
  272. package/scripts/utils/validate-ep-api.js.map +1 -0
  273. package/scripts/utils/world-bank-data.d.ts +84 -0
  274. package/scripts/utils/world-bank-data.d.ts.map +1 -0
  275. package/scripts/utils/world-bank-data.js +311 -0
  276. 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
+ '&lt;': '<',
133
+ '&gt;': '>',
134
+ '&quot;': '"',
135
+ '&#39;': "'",
136
+ '&apos;': "'",
137
+ '&nbsp;': ' ',
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 (&amp;, &lt;, &gt;, &quot;, &#39;, &apos;, &nbsp;)
145
+ * and numeric references (&#123;, &#x7B;).
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