dsh-plugin-show-me-data 0.1.0

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 (130) hide show
  1. package/LICENSE +27 -0
  2. package/README.md +96 -0
  3. package/cordis.patch.yml +40 -0
  4. package/docs/01-product-effect.md +178 -0
  5. package/docs/02-architecture.md +275 -0
  6. package/docs/03-data-contracts.md +291 -0
  7. package/docs/04-sources.md +342 -0
  8. package/docs/05-ui-spec.md +167 -0
  9. package/docs/06-ai-layer.md +194 -0
  10. package/docs/07-implementation-plan.md +399 -0
  11. package/docs/08-test-plan.md +133 -0
  12. package/docs/09-packaging-install.md +249 -0
  13. package/docs/10-kickoff-prompt.md +94 -0
  14. package/docs/11-decisions.md +203 -0
  15. package/docs/12-runtime-verified.md +115 -0
  16. package/docs/13-acceptance.md +153 -0
  17. package/docs/14-progress.md +150 -0
  18. package/docs/15-publish.md +185 -0
  19. package/lib/app/ai-deterministic.js +327 -0
  20. package/lib/app/ai-validate.js +284 -0
  21. package/lib/app/ai.js +440 -0
  22. package/lib/app/health.js +77 -0
  23. package/lib/app/overview.js +349 -0
  24. package/lib/app/propose-indicator.js +122 -0
  25. package/lib/app/refresh.js +251 -0
  26. package/lib/app/series-view.js +195 -0
  27. package/lib/app/watchlist.js +102 -0
  28. package/lib/client.js +4322 -0
  29. package/lib/core/ai/prompts.js +213 -0
  30. package/lib/core/chart/axis.js +133 -0
  31. package/lib/core/chart/bar.js +58 -0
  32. package/lib/core/chart/candle.js +216 -0
  33. package/lib/core/chart/line.js +186 -0
  34. package/lib/core/chart/scale.js +132 -0
  35. package/lib/core/format.js +143 -0
  36. package/lib/core/indicators/catalog.js +1011 -0
  37. package/lib/core/indicators/resolve.js +196 -0
  38. package/lib/core/insight/digest.js +250 -0
  39. package/lib/core/insight/rank.js +115 -0
  40. package/lib/core/insight/related.js +90 -0
  41. package/lib/core/insight/rules.js +417 -0
  42. package/lib/core/stats/derive.js +123 -0
  43. package/lib/core/stats/series.js +465 -0
  44. package/lib/core/time/range.js +242 -0
  45. package/lib/core/types.js +478 -0
  46. package/lib/host/ai/discussion.js +559 -0
  47. package/lib/host/ai/dsh-llm-gateway.js +333 -0
  48. package/lib/host/config.js +194 -0
  49. package/lib/host/http/respond.js +165 -0
  50. package/lib/host/http/routes.js +689 -0
  51. package/lib/host/index.js +293 -0
  52. package/lib/host/infra/fs-repos.js +179 -0
  53. package/lib/host/infra/memory-fallback.js +64 -0
  54. package/lib/host/tools/define-tool.js +295 -0
  55. package/lib/host/tools/register.js +431 -0
  56. package/lib/host.js +7 -0
  57. package/lib/ports/clock.js +57 -0
  58. package/lib/ports/snapshot-repo.js +48 -0
  59. package/lib/sources/eastmoney-macro.js +197 -0
  60. package/lib/sources/eastmoney-quote.js +201 -0
  61. package/lib/sources/ecb.js +179 -0
  62. package/lib/sources/fred.js +207 -0
  63. package/lib/sources/http.js +136 -0
  64. package/lib/sources/ohlc.js +36 -0
  65. package/lib/sources/quote-cascade.js +177 -0
  66. package/lib/sources/registry.js +153 -0
  67. package/lib/sources/sina-cn.js +197 -0
  68. package/lib/sources/sina-us.js +187 -0
  69. package/lib/sources/tencent.js +158 -0
  70. package/lib/sources/us-treasury-rates.js +275 -0
  71. package/lib/sources/us-treasury.js +196 -0
  72. package/lib/sources/worldbank.js +170 -0
  73. package/package.json +69 -0
  74. package/src/app/ai-deterministic.js +327 -0
  75. package/src/app/ai-validate.js +284 -0
  76. package/src/app/ai.js +440 -0
  77. package/src/app/health.js +77 -0
  78. package/src/app/overview.js +349 -0
  79. package/src/app/propose-indicator.js +122 -0
  80. package/src/app/refresh.js +251 -0
  81. package/src/app/series-view.js +195 -0
  82. package/src/app/watchlist.js +102 -0
  83. package/src/client/api.js +323 -0
  84. package/src/client/components.js +1877 -0
  85. package/src/client/copy.js +368 -0
  86. package/src/client/index.js +169 -0
  87. package/src/client/store.js +219 -0
  88. package/src/core/ai/prompts.js +213 -0
  89. package/src/core/chart/axis.js +133 -0
  90. package/src/core/chart/bar.js +58 -0
  91. package/src/core/chart/candle.js +216 -0
  92. package/src/core/chart/line.js +186 -0
  93. package/src/core/chart/scale.js +132 -0
  94. package/src/core/format.js +143 -0
  95. package/src/core/indicators/catalog.js +1011 -0
  96. package/src/core/indicators/resolve.js +196 -0
  97. package/src/core/insight/digest.js +250 -0
  98. package/src/core/insight/rank.js +115 -0
  99. package/src/core/insight/related.js +90 -0
  100. package/src/core/insight/rules.js +417 -0
  101. package/src/core/stats/derive.js +123 -0
  102. package/src/core/stats/series.js +465 -0
  103. package/src/core/time/range.js +242 -0
  104. package/src/core/types.js +478 -0
  105. package/src/host/ai/discussion.js +559 -0
  106. package/src/host/ai/dsh-llm-gateway.js +333 -0
  107. package/src/host/config.js +194 -0
  108. package/src/host/http/respond.js +165 -0
  109. package/src/host/http/routes.js +689 -0
  110. package/src/host/index.js +293 -0
  111. package/src/host/infra/fs-repos.js +179 -0
  112. package/src/host/infra/memory-fallback.js +64 -0
  113. package/src/host/tools/define-tool.js +295 -0
  114. package/src/host/tools/register.js +431 -0
  115. package/src/ports/clock.js +57 -0
  116. package/src/ports/snapshot-repo.js +48 -0
  117. package/src/sources/eastmoney-macro.js +197 -0
  118. package/src/sources/eastmoney-quote.js +201 -0
  119. package/src/sources/ecb.js +179 -0
  120. package/src/sources/fred.js +207 -0
  121. package/src/sources/http.js +136 -0
  122. package/src/sources/ohlc.js +36 -0
  123. package/src/sources/quote-cascade.js +177 -0
  124. package/src/sources/registry.js +153 -0
  125. package/src/sources/sina-cn.js +197 -0
  126. package/src/sources/sina-us.js +187 -0
  127. package/src/sources/tencent.js +158 -0
  128. package/src/sources/us-treasury-rates.js +275 -0
  129. package/src/sources/us-treasury.js +196 -0
  130. package/src/sources/worldbank.js +170 -0
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "dsh-plugin-show-me-data",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Show Me Data — global macro/rates/equity/bond indicator radar for DeepSeek Harness (host half + browser panel)",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "deepseek-harness",
9
+ "dsh",
10
+ "dsh-plugin",
11
+ "plugin",
12
+ "macro",
13
+ "indicators",
14
+ "dashboard",
15
+ "fred",
16
+ "worldbank",
17
+ "candlestick"
18
+ ],
19
+ "author": "hhhcbw",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/hhhcbw/dsh-plugin-show-me-data.git"
23
+ },
24
+ "homepage": "https://github.com/hhhcbw/dsh-plugin-show-me-data#readme",
25
+ "bugs": {
26
+ "url": "https://github.com/hhhcbw/dsh-plugin-show-me-data/issues"
27
+ },
28
+ "main": "lib/host.js",
29
+ "exports": {
30
+ ".": "./lib/host.js",
31
+ "./client": "./lib/client.js",
32
+ "./package.json": "./package.json"
33
+ },
34
+ "dsh": {
35
+ "bundle": {
36
+ "patch": "./cordis.patch.yml"
37
+ },
38
+ "client": {
39
+ "platform": "web",
40
+ "inject": [
41
+ "@deepseek-ai/dsh-client-runtime"
42
+ ]
43
+ }
44
+ },
45
+ "files": [
46
+ "lib",
47
+ "src",
48
+ "docs",
49
+ "cordis.patch.yml",
50
+ "README.md",
51
+ "LICENSE"
52
+ ],
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "scripts": {
57
+ "test": "node --test \"test/**/*.test.js\"",
58
+ "test:net": "RUN_NET=1 node --test \"test/net/*.test.js\"",
59
+ "test:boot": "RUN_BOOT=1 node --test \"test/net/boot.test.js\"",
60
+ "coverage": "node --test --experimental-test-coverage \"test/**/*.test.js\"",
61
+ "build": "node scripts/build-client.mjs && node scripts/build-host.mjs",
62
+ "prepublishOnly": "node scripts/build-client.mjs && node scripts/build-host.mjs && node --test \"test/**/*.test.js\"",
63
+ "record": "RUN_NET=1 node scripts/record-fixtures.mjs",
64
+ "smoke": "node scripts/smoke.mjs"
65
+ },
66
+ "engines": {
67
+ "node": ">=20"
68
+ }
69
+ }
@@ -0,0 +1,327 @@
1
+ /**
2
+ * The deterministic AI gateway — the offline product path (docs/06 §1).
3
+ *
4
+ * This is **not a mock**: when no LLM is configured, the panel still explains,
5
+ * summarises and answers, using the same digest the model would have received
6
+ * and citing the exact values it cites. Every result is labelled
7
+ * 'mode: 'deterministic'' so the user always knows which one they are reading.
8
+ *
9
+ * @module app/ai-deterministic
10
+ */
11
+ import { fingerprint } from '../core/insight/rank.js'
12
+ import { formatNumber } from '../core/insight/digest.js'
13
+
14
+ /** Marker the UI renders as 「确定性摘要模式」. */
15
+ export const MODE = 'deterministic'
16
+
17
+ /**
18
+ * Below this score a catalog hit is too weak to present as a candidate.
19
+ *
20
+ * Measured against the shipped catalog: a real question about a catalog
21
+ * indicator scores 96-310 (label and alias hits), while an incidental token
22
+ * overlap - asking about tomorrow's oil price and hitting the WTI series on the
23
+ * word 油 - scores 38. The floor therefore sits between the two, so an
24
+ * unanswerable question gets "数据不足" instead of a price.
25
+ */
26
+ export const WEAK_MATCH_SCORE = 60
27
+
28
+ /**
29
+ * Floor for relevance computed against digest entries by {@link matchEntries}.
30
+ *
31
+ * The two scales are not comparable: a catalog search (core/indicators/resolve)
32
+ * weighs exact ids and aliases and scores a real hit 96-310, while the local
33
+ * digest matcher is additive token overlap and scores the same hit around 7.
34
+ * Using one number for both would either drop real answers or keep weak ones,
35
+ * so each path carries its own floor.
36
+ */
37
+ export const WEAK_ENTRY_SCORE = 3
38
+
39
+ /**
40
+ * Build the cited points for one digest entry.
41
+ *
42
+ * @param {object} entry - digest entry.
43
+ * @returns {Array<{ indicatorId: string, t: string, v: number, sourceRefUrl: string }>} cited points.
44
+ */
45
+ function citesOf(entry) {
46
+ const stats = entry.stats ?? {}
47
+ const points = []
48
+ if (typeof stats.latest === 'number' && typeof stats.latestAt === 'string') {
49
+ points.push({ indicatorId: entry.indicator.id, t: stats.latestAt, v: stats.latest, sourceRefUrl: entry.sourceRef?.url })
50
+ }
51
+ if (typeof stats.prev === 'number' && entry.points?.length >= 2) {
52
+ const previous = entry.points[entry.points.length - 2]
53
+ points.push({ indicatorId: entry.indicator.id, t: previous.t, v: previous.v, sourceRefUrl: entry.sourceRef?.url })
54
+ }
55
+ return points
56
+ }
57
+
58
+ /**
59
+ * Create the deterministic gateway.
60
+ *
61
+ * @returns {object} 'AiGateway' implementation.
62
+ */
63
+ export function createDeterministicGateway() {
64
+ /**
65
+ * Explain one indicator without a model.
66
+ *
67
+ * @param {{ entries: object[], indicatorId: string, range?: object }} request - request.
68
+ * @returns {Promise<object>} 'AiResult'.
69
+ */
70
+ async function explain({ entries, indicatorId, range }) {
71
+ const entry = (entries ?? []).find((candidate) => candidate.indicator.id === indicatorId)
72
+ if (entry === undefined) {
73
+ return {
74
+ markdown: '数据不足:没有找到该指标在当前范围内的观测,无法生成解析。',
75
+ usedPoints: [],
76
+ insufficient: '该指标在当前范围内没有可用观测',
77
+ mode: MODE,
78
+ fingerprint: fingerprint(`explain|${indicatorId}|empty`),
79
+ }
80
+ }
81
+ const { indicator, stats } = entry
82
+ const decimals = indicator.display?.decimals ?? 2
83
+ const lines = []
84
+ lines.push(`### ${indicator.label.zh}(${indicator.label.en})`)
85
+ lines.push('')
86
+ if (stats === undefined) {
87
+ return {
88
+ markdown: `${lines.join('\n')}\n数据不足:该指标在本范围内没有任何可用观测。`,
89
+ usedPoints: [],
90
+ insufficient: '该指标在本范围内没有可用观测',
91
+ mode: MODE,
92
+ fingerprint: fingerprint(`explain|${indicatorId}|nostats`),
93
+ }
94
+ }
95
+ lines.push(
96
+ `**事实**:最新观测为 ${formatNumber(stats.latest, decimals)}${indicator.unit}(${stats.latestAt})` +
97
+ `[${indicator.id}@${stats.latestAt}, ${stats.latest}]。`,
98
+ )
99
+ if (stats.changeAbs !== undefined) {
100
+ lines.push(
101
+ `- 较上期变化 ${stats.changeAbs >= 0 ? '+' : ''}${formatNumber(stats.changeAbs, decimals)}${indicator.unit}` +
102
+ (stats.changePct === undefined ? '' : `(${formatNumber(stats.changePct, 1)}%)`),
103
+ )
104
+ }
105
+ if (stats.yoy !== undefined) lines.push(`- 同比 ${formatNumber(stats.yoy, 1)}%`)
106
+ lines.push(`- 口径:${indicator.freq} 频,单位 ${indicator.unit}${indicator.seasonal === 'SA' ? ',季调' : ''}`)
107
+ if (indicator.notes?.zh) lines.push(`- 说明:${indicator.notes.zh}`)
108
+ if (stats.percentile !== undefined) {
109
+ lines.push(`- 分位:最新值位于本区间 ${formatNumber(stats.percentile * 100, 0)}% 分位`)
110
+ }
111
+ if (stats.zScoreLatestChange !== undefined) {
112
+ lines.push(`- 变化强度:最近一次变化为 ${formatNumber(stats.zScoreLatestChange, 1)}σ`)
113
+ }
114
+ if (range !== undefined) lines.push(`- 区间:${range.from} .. ${range.to}`)
115
+ lines.push('')
116
+ lines.push('**可能的影响**(以下为规则化解读,不构成投资建议):')
117
+ const polarity = indicator.display?.polarity
118
+ lines.push(
119
+ polarity === 'up-is-good'
120
+ ? '- 该指标上行通常被视为改善,下行为恶化;请结合其他指标交叉验证。'
121
+ : polarity === 'down-is-good'
122
+ ? '- 该指标下行通常被视为改善,上行为恶化;请结合其他指标交叉验证。'
123
+ : '- 该指标的方向本身不直接代表好坏,需要看它与其他变量(增长、通胀、政策)的相对关系。',
124
+ )
125
+ return {
126
+ markdown: lines.join('\n'),
127
+ usedPoints: citesOf(entry),
128
+ mode: MODE,
129
+ fingerprint: fingerprint(`explain|${indicatorId}|${range?.from ?? ''}|${range?.to ?? ''}|${stats.latest}`),
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Summarise a period without a model.
135
+ *
136
+ * @param {{ entries: object[], range?: object, text?: string }} request - request; 'text' overrides the rendered body.
137
+ * @returns {Promise<object>} 'AiResult'.
138
+ */
139
+ async function summarize({ entries, range, text }) {
140
+ const markdown = text ?? renderFallback(entries, range)
141
+ const usedPoints = (entries ?? []).flatMap(citesOf)
142
+ if (usedPoints.length === 0) {
143
+ return {
144
+ markdown: '数据不足:当前没有任何可用的指标观测,无法生成时段总结。',
145
+ usedPoints: [],
146
+ insufficient: '本次没有任何可用观测',
147
+ mode: MODE,
148
+ fingerprint: fingerprint('summary|empty'),
149
+ }
150
+ }
151
+ return {
152
+ markdown,
153
+ usedPoints,
154
+ mode: MODE,
155
+ fingerprint: fingerprint(`summary|${range?.from ?? ''}|${range?.to ?? ''}|${usedPoints.length}`),
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Answer a question using only the digest, deterministically.
161
+ *
162
+ * @param {{ entries: object[], question: string, range?: object, matches?: object[] }} request - request.
163
+ * @returns {Promise<object>} 'AiResult'.
164
+ */
165
+ async function answer({ entries, question, range, matches }) {
166
+ // The same confidence floor the propose path uses: a weak match must produce
167
+ // "数据不足" rather than an unrelated number.
168
+ // Only entries that were resolved through search carry a `matchScore`; a
169
+ // caller handing pre-selected digest entries is taken at its word.
170
+ // `matches` arrives from a catalog search (its own scale); otherwise the
171
+ // question is matched locally against the digest entries.
172
+ const floor = matches === undefined ? WEAK_ENTRY_SCORE : WEAK_MATCH_SCORE
173
+ const candidates = (matches ?? matchEntries(entries, question)).filter((entry) => {
174
+ // `matchScore` is relevance; `score` is the entry's attention score and
175
+ // must never be read as relevance.
176
+ const relevance = entry.matchScore ?? entry.score
177
+ return relevance === undefined || relevance >= floor
178
+ })
179
+ if (candidates.length === 0) {
180
+ return {
181
+ markdown:
182
+ `数据不足:面板中的数据无法回答「${question ?? ''}」。\n\n` +
183
+ '- 当前可用指标里没有与该问题相关的口径。\n' +
184
+ '- 可尝试:换一个更具体的指标名称(例如「美国 CPI 同比」「中国制造业 PMI」),或缩小时间范围。',
185
+ usedPoints: [],
186
+ insufficient: '问题超出面板数据覆盖范围',
187
+ mode: MODE,
188
+ fingerprint: fingerprint(`answer|${question ?? ''}|none`),
189
+ }
190
+ }
191
+ // Callers may hand either digest entries ({ indicator, stats, points }) or
192
+ // flat catalog matches ({ id, label, ... }); normalize to digest entries so
193
+ // a lookup path can never crash the answer.
194
+ const byId = new Map((entries ?? []).map((entry) => [entry.indicator?.id, entry]))
195
+ const top = candidates
196
+ .slice(0, 3)
197
+ .map((candidate) => byId.get(candidate.indicator?.id ?? candidate.id) ?? candidate)
198
+ .filter((candidate) => candidate.indicator !== undefined)
199
+ const lines = []
200
+ lines.push(`在面板数据范围内,与「${question}」最相关的是:`)
201
+ for (const entry of top) {
202
+ const decimals = entry.indicator.display?.decimals ?? 2
203
+ if (entry.stats === undefined) {
204
+ lines.push(`- ${entry.indicator.label.zh}:数据不足,本范围内没有观测。`)
205
+ continue
206
+ }
207
+ // The citation form is included so the deterministic answer satisfies the
208
+ // same validator the model's answer has to pass.
209
+ lines.push(
210
+ `- ${entry.indicator.label.zh}:最新 ${formatNumber(entry.stats.latest, decimals)}${entry.indicator.unit}(${entry.stats.latestAt})` +
211
+ `[${entry.indicator.id}@${entry.stats.latestAt}, ${entry.stats.latest}]` +
212
+ (entry.stats.changeAbs === undefined ? '' : `,较上期 ${entry.stats.changeAbs >= 0 ? '+' : ''}${formatNumber(entry.stats.changeAbs, decimals)}`),
213
+ )
214
+ }
215
+ lines.push('')
216
+ lines.push('**依据**:以上数值均来自面板内的公开数据源,可在每条指标卡片的来源徽章处打开原始页面。')
217
+ lines.push('**数据边界**:只覆盖面板中列出的指标与时间范围;不包含新闻、事件与预测。')
218
+ return {
219
+ markdown: lines.join('\n'),
220
+ usedPoints: top.flatMap(citesOf),
221
+ mode: MODE,
222
+ fingerprint: fingerprint(`answer|${question ?? ''}|${top.map((entry) => entry.indicator.id).join(',')}`),
223
+ }
224
+ }
225
+
226
+ /**
227
+ * 'propose' without a model can only answer from the existing catalog.
228
+ *
229
+ * @param {{ request: string, matches?: object[] }} input - propose input.
230
+ * @returns {Promise<{ candidates: object[], unsupported: object[], mode: string }>} result.
231
+ */
232
+ async function propose({ request, matches = [] }) {
233
+ // Only a genuinely strong catalog hit becomes a candidate. A weak fuzzy match
234
+ // (asking for a German business-climate index and getting "US dollar index"
235
+ // because both contain 指数) must not be dressed up as an answer.
236
+ const confident = matches.filter((entry) => (entry.score ?? 0) >= WEAK_MATCH_SCORE)
237
+ if (confident.length > 0) {
238
+ const best = confident[0].indicator
239
+ return {
240
+ candidates: [
241
+ {
242
+ // The catalog definition is echoed whole: it already passed
243
+ // validateIndicatorDef in the catalog suite, and re-validating it
244
+ // through the propose gate would reject fields this echo omits
245
+ // (seasonal, notes) for no reason.
246
+ ...best,
247
+ fromCatalog: true,
248
+ confidence: Number(((matches[0].score ?? 0) / 150).toFixed(2)),
249
+ rationale: `目录检索命中已有指标 ${best.id}(确定性模式不做自然语言推理)。`,
250
+ },
251
+ ],
252
+ unsupported: [],
253
+ mode: MODE,
254
+ }
255
+ }
256
+ return {
257
+ candidates: [],
258
+ unsupported: [
259
+ {
260
+ request,
261
+ reason: '确定性摘要模式无法解析自然语言需求,也不会猜测数据源标识。',
262
+ alternatives: ['在指标目录中检索并手动添加', '配置 LLM 后重试对话式添加'],
263
+ },
264
+ ],
265
+ mode: MODE,
266
+ }
267
+ }
268
+
269
+ /**
270
+ * @returns {{ mode: string }} mode description.
271
+ */
272
+ function describe() {
273
+ return { mode: MODE }
274
+ }
275
+
276
+ return { explain, summarize, answer, propose, describe }
277
+ }
278
+
279
+ /**
280
+ * Rank digest entries against a free-text question.
281
+ *
282
+ * @param {object[]} entries - digest entries.
283
+ * @param {string} question - question text.
284
+ * @returns {object[]} matching entries, best first.
285
+ */
286
+ export function matchEntries(entries, question) {
287
+ const text = String(question ?? '').toLowerCase()
288
+ if (text.trim() === '') return []
289
+ return (entries ?? [])
290
+ .map((entry) => {
291
+ const haystack = [entry.indicator.id, entry.indicator.label.zh, entry.indicator.label.en, ...(entry.indicator.aliases ?? [])]
292
+ .join(' ')
293
+ .toLowerCase()
294
+ let score = 0
295
+ for (const token of text.split(/[\s,,。??、]+/).filter((token) => token.length >= 2)) {
296
+ if (haystack.includes(token)) score += 2
297
+ }
298
+ for (const alias of entry.indicator.aliases ?? []) {
299
+ if (text.includes(alias.toLowerCase())) score += 3
300
+ }
301
+ if (text.includes(entry.indicator.label.zh)) score += 4
302
+ return { entry, score }
303
+ })
304
+ .filter((candidate) => candidate.score > 0)
305
+ .sort((a, b) => b.score - a.score || b.entry.indicator.importance - a.entry.indicator.importance)
306
+ .map((candidate) => ({ ...candidate.entry, matchScore: candidate.score }))
307
+ }
308
+
309
+ /**
310
+ * The plain-text fallback summary used when 'core/insight/digest' did not
311
+ * already render one.
312
+ *
313
+ * @param {object[]} entries - digest entries.
314
+ * @param {{ from: string, to: string }} [range] - range.
315
+ * @returns {string} markdown.
316
+ */
317
+ function renderFallback(entries, range) {
318
+ if (!entries || entries.length === 0) return '数据不足:当前没有任何可用的指标观测。'
319
+ const lines = ['### 时段数据摘要']
320
+ if (range !== undefined) lines.push(`区间:${range.from} .. ${range.to}`)
321
+ for (const entry of entries.slice(0, 10)) {
322
+ const decimals = entry.indicator.display?.decimals ?? 2
323
+ lines.push(`- ${entry.indicator.label.zh}:${formatNumber(entry.stats?.latest, decimals)}${entry.indicator.unit}(${entry.stats?.latestAt ?? 'n/a'})`)
324
+ }
325
+ lines.push('- 数据来自公开源,仅供研究参考,不构成投资建议。')
326
+ return lines.join('\n')
327
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Output validation — the enforcement point that turns "AI explanation" into a
3
+ * verifiable feature (docs/06 §3).
4
+ *
5
+ * Three checks, in order of severity:
6
+ * 1. every '[id@date, value]' citation must match a real digest point;
7
+ * 2. every number in the text must be traceable to the digest (or be a
8
+ * whitelisted shape such as a year, a section ordinal or a percentage sign);
9
+ * 3. the answer must not claim the future or give advice.
10
+ *
11
+ * A violation never reaches the user: the caller retries once with the feedback,
12
+ * then degrades to the deterministic summary.
13
+ *
14
+ * @module app/ai-validate
15
+ */
16
+
17
+ /** Citation syntax the model is told to use: '[id@date, value]'. */
18
+ export const CITATION_RE = /\[([a-z0-9]+(?:\.[a-z0-9]+)+)@(\d{4}-\d{2}-\d{2})\s*,\s*(-?\d+(?:\.\d+)?)\s*\]/g
19
+
20
+ /** Numbers that are allowed without a data source, with the reason why. */
21
+ export const NUMBER_WHITELIST = [
22
+ { pattern: /^(19|20)\d{2}$/, reason: 'year' },
23
+ { pattern: /^\d{4}-\d{2}-\d{2}$/, reason: 'date' },
24
+ { pattern: /^(0|1|2|3|4|5|6|7|8|9|10)$/, reason: 'small ordinal' },
25
+ { pattern: /^(100|12|4|3|2|1)$/, reason: 'structural constant (percent/month/quarter counts)' },
26
+ ]
27
+
28
+ /**
29
+ * Whether a number is the difference between two published values.
30
+ *
31
+ * "从 4.5% 到 4.1%,两端相差 0.4 个百分点" is the whole point of a data panel,
32
+ * and both observations are cited in the same sentence. Refusing it degraded
33
+ * correct answers; accepting it still refuses a number that appears nowhere in
34
+ * the data (a valuation multiple, a policy rate the panel never fetched).
35
+ *
36
+ * @param {number} value - number found in the text.
37
+ * @param {number[]} candidates - published numbers.
38
+ * @param {number} [tolerance] - rounding tolerance for the difference.
39
+ * @returns {boolean} whether it is a difference of two published values.
40
+ */
41
+ export function isPublishedDifference(value, candidates) {
42
+ // Tolerance follows the precision the digest prints: two readings quoted to
43
+ // one decimal are subtracted to one decimal by anyone reading them, so the
44
+ // allowance is half of that last digit and no more. A wider window would wave
45
+ // through a number that merely happens to sit near *some* difference.
46
+ const tolerance = 0.05 / 10 ** Math.max(0, decimalsOf(value) - 1)
47
+ for (const a of candidates) {
48
+ if (!Number.isFinite(a)) continue
49
+ for (const b of candidates) {
50
+ if (!Number.isFinite(b)) continue
51
+ if (Math.abs(Math.abs(a - b) - value) <= tolerance) return true
52
+ }
53
+ }
54
+ return false
55
+ }
56
+
57
+ /**
58
+ * Digits after the decimal point in the *written* form of a number.
59
+ *
60
+ * @param {number} value - number from the text.
61
+ * @returns {number} decimal places (0 for an integer).
62
+ */
63
+ function decimalsOf(value) {
64
+ const text = String(value)
65
+ const dot = text.indexOf('.')
66
+ return dot === -1 ? 0 : text.length - dot - 1
67
+ }
68
+
69
+ /** Phrases that assert a prediction or investment advice. */
70
+ export const FORBIDDEN_PATTERNS = [
71
+ { pattern: /必然|一定会|肯定会|毫无疑问/, reason: 'absolute wording' },
72
+ { pattern: /建议(买入|卖出|加仓|减仓|做多|做空)|买入信号|卖出信号/, reason: 'investment advice' },
73
+ // A modal future marker followed by a direction verb. Deliberately not
74
+ // date-anchored: in practice any `会/将 + 涨跌` in a data panel's output is a
75
+ // forecast, and requiring a nearby date made the check miss the common
76
+ // phrasing `明天会上涨`.
77
+ { pattern: /(?:会|将)\s*(?:继续)?\s*(?:上涨|涨|下跌|跌|上行|下行|走高|走低|突破|跌破)/, reason: 'price prediction' },
78
+ { pattern: /目标价|看到\d+点|涨到\d+|跌到\d+/, reason: 'price target' },
79
+ ]
80
+
81
+ /**
82
+ * One digest entry flattened for validation.
83
+ *
84
+ * @typedef {Object} ValidationData
85
+ * @property {Record<string, { values: number[], dates: string[], stats: number[] }>} byIndicator
86
+ * @property {number[]} counts - observation counts the digest states about itself.
87
+ */
88
+
89
+ /**
90
+ * Build the validation index from digest entries.
91
+ *
92
+ * @param {Array<{ indicator: object, stats?: object, points?: Array<{ t: string, v: number }> }>} entries - digest entries.
93
+ * @returns {ValidationData} validation data.
94
+ */
95
+ export function buildValidationData(entries) {
96
+ const byIndicator = {}
97
+ // Counts the digest itself publishes ("points=13 missing=2"). They describe the
98
+ // series rather than measure it, so a model restating them is not inventing a
99
+ // number — refusing them would penalise it for reading our own output.
100
+ const counts = []
101
+ for (const entry of entries ?? []) {
102
+ const id = entry?.indicator?.id
103
+ if (typeof id !== 'string') continue
104
+ const bucket = byIndicator[id] ?? { values: [], dates: [], stats: [] }
105
+ for (const point of entry.points ?? []) {
106
+ bucket.values.push(point.v)
107
+ bucket.dates.push(point.t)
108
+ }
109
+ const stats = entry.stats ?? {}
110
+ for (const key of ['count', 'missingCount']) {
111
+ if (typeof stats[key] === 'number' && Number.isFinite(stats[key])) counts.push(stats[key])
112
+ }
113
+ counts.push((entry.points ?? []).length)
114
+ for (const key of ['latest', 'prev', 'changeAbs', 'changePct', 'yoy', 'mom', 'mean', 'min', 'max', 'stdDev', 'slope', 'zScoreLatestChange', 'percentile']) {
115
+ if (typeof stats[key] === 'number' && Number.isFinite(stats[key])) bucket.stats.push(stats[key])
116
+ }
117
+ if (typeof stats.latest === 'number') bucket.values.push(stats.latest)
118
+ if (typeof stats.latestAt === 'string') bucket.dates.push(stats.latestAt)
119
+ byIndicator[id] = bucket
120
+ }
121
+ return { byIndicator, counts }
122
+ }
123
+
124
+ /**
125
+ * Whether a number appears in the digest, within a display rounding tolerance.
126
+ *
127
+ * @param {number} value - the number found in the text.
128
+ * @param {number[]} candidates - digest numbers.
129
+ * @returns {boolean} whether it is traceable.
130
+ */
131
+ export function matchesData(value, candidates) {
132
+ // Tolerance is deliberately tight: a number the model invented must not pass
133
+ // just because it is "close enough" to a real observation.
134
+ const tolerance = 1e-6
135
+ for (const candidate of candidates) {
136
+ if (!Number.isFinite(candidate)) continue
137
+ if (Math.abs(candidate - value) <= tolerance) return true
138
+ // The model is asked to round to `decimals`; accept any rounding of the value.
139
+ for (const decimals of [0, 1, 2, 3, 4]) {
140
+ if (Number(candidate.toFixed(decimals)) === value) return true
141
+ }
142
+ // Percentage points written without the sign, and unit-scaled forms
143
+ // (e.g. a percentage reported in decimal form).
144
+ if (Math.abs(candidate * 100 - value) <= tolerance) return true
145
+ if (Math.abs(candidate / 100 - value) <= tolerance) return true
146
+ }
147
+ return false
148
+ }
149
+
150
+ /**
151
+ * Extract every number literal from a text, with its position.
152
+ *
153
+ * @param {string} text - answer text.
154
+ * @returns {Array<{ raw: string, value: number, index: number }>} numbers.
155
+ */
156
+ export function extractNumbers(text) {
157
+ const found = []
158
+ // Strip citations (they are validated separately) and calendar dates: the
159
+ // digits inside `2026-08-01` are not claims, and matching them produced
160
+ // nonsense violations like `-08`.
161
+ const cleaned = String(text ?? '')
162
+ .replace(CITATION_RE, ' ')
163
+ .replace(/\d{4}-\d{2}-\d{2}/g, ' ')
164
+ .replace(/\d{4}-\d{2}(?!\d)/g, ' ')
165
+ for (const match of cleaned.matchAll(/(?<![\d-])-?\d+(?:\.\d+)?/g)) {
166
+ found.push({ raw: match[0], value: Number(match[0]), index: match.index ?? 0 })
167
+ }
168
+ return found
169
+ }
170
+
171
+ /**
172
+ * Validate one answer against the digest.
173
+ *
174
+ * @param {object} input - validation input.
175
+ * @param {string} input.text - the model's answer.
176
+ * @param {ValidationData} input.data - digest index.
177
+ * @param {boolean} [input.allowEmptyCitations] - permit "数据不足" answers without citations.
178
+ * @param {string} [input.echoOf] - text the answer may legitimately contain verbatim (the user's question).
179
+ * @returns {{ ok: boolean, violations: Array<{ kind: string, detail: string, raw?: string }>, usedPoints: Array<{ indicatorId: string, t: string, v: number }>, insufficient: string|undefined }} verdict.
180
+ */
181
+ export function validateAnswer({ text, data, allowEmptyCitations = true, echoOf, digestText }) {
182
+ const violations = []
183
+ const usedPoints = []
184
+ const source = typeof text === 'string' ? text : ''
185
+ const knownIds = new Set(Object.keys(data?.byIndicator ?? {}))
186
+
187
+ // An empty answer is never acceptable: the caller degrades to the
188
+ // deterministic summary instead of showing the user a blank panel.
189
+ if (source.trim() === '') {
190
+ return {
191
+ ok: false,
192
+ violations: [{ kind: 'empty-answer', detail: '模型返回了空回答' }],
193
+ usedPoints: [],
194
+ insufficient: undefined,
195
+ }
196
+ }
197
+
198
+ // 1) citations
199
+ for (const match of source.matchAll(CITATION_RE)) {
200
+ const [, indicatorId, date, rawValue] = match
201
+ const bucket = data?.byIndicator?.[indicatorId]
202
+ if (bucket === undefined) {
203
+ violations.push({ kind: 'unknown-indicator', detail: `引用不存在的指标 ${indicatorId}`, raw: match[0] })
204
+ continue
205
+ }
206
+ if (!bucket.dates.includes(date)) {
207
+ violations.push({ kind: 'unknown-date', detail: `${indicatorId} 在 ${date} 没有观测`, raw: match[0] })
208
+ continue
209
+ }
210
+ const value = Number(rawValue)
211
+ if (!matchesData(value, bucket.values)) {
212
+ violations.push({ kind: 'value-mismatch', detail: `${indicatorId}@${date} 的值 ${value} 与数据不符`, raw: match[0] })
213
+ continue
214
+ }
215
+ usedPoints.push({ indicatorId, t: date, v: value })
216
+ }
217
+
218
+ // 2) unsourced numbers
219
+ //
220
+ // "Sourced" means the digest shows it. Statistics and observations are the
221
+ // obvious part, but a label ("标普 500"), a field name and a computed delta all
222
+ // print there too, and a model restating any of them was being accused of
223
+ // inventing a number — which degraded perfectly good answers to the
224
+ // deterministic summary. Everything the digest prints is therefore traceable
225
+ // by definition, so the digest's own text is scanned as one more source.
226
+ const printed = extractNumbers(typeof digestText === 'string' ? digestText : '')
227
+ // Measurements only. `counts` and the digest text carry structural numbers
228
+ // ("points=250", a label's "500"), and differencing those against a reading
229
+ // would let an invented number through as "4.1 - 3".
230
+ const measurements = Object.values(data?.byIndicator ?? {}).flatMap((bucket) => [...bucket.values, ...bucket.stats])
231
+ const allNumbers = [...measurements, ...(data?.counts ?? []), ...printed.map((number) => number.value)]
232
+ for (const number of extractNumbers(source)) {
233
+ if (NUMBER_WHITELIST.some((entry) => entry.pattern.test(number.raw))) continue
234
+ if (matchesData(number.value, allNumbers)) continue
235
+ // Rounding tolerance, not a free pass: a reader comparing two published
236
+ // points does exactly this subtraction, and a published difference is
237
+ // arithmetic on our data rather than a fabricated observation.
238
+ if (isPublishedDifference(number.value, measurements)) continue
239
+ violations.push({ kind: 'unsourced-number', detail: `数字 ${number.raw} 无法在数据中找到来源`, raw: number.raw })
240
+ }
241
+
242
+ // 3) predictions and advice. A phrase the user themselves wrote is echoed by
243
+ // the answer ("数据不足:无法回答「明天油价会涨吗?」"), which is not the
244
+ // answer asserting a forecast — so the question's own wording is excused.
245
+ const echo = typeof echoOf === 'string' ? echoOf : ''
246
+ const withoutEcho = echo === '' ? source : source.split(echo).join(' ')
247
+ for (const rule of FORBIDDEN_PATTERNS) {
248
+ const match = rule.pattern.exec(withoutEcho)
249
+ if (match !== null) violations.push({ kind: 'forbidden-wording', detail: `${rule.reason}:${match[0]}`, raw: match[0] })
250
+ }
251
+
252
+ const insufficient = /数据不足/.test(source) ? source.match(/数据不足[^。\n]*/)?.[0] ?? '数据不足' : undefined
253
+ if (usedPoints.length === 0 && insufficient === undefined && !allowEmptyCitations) {
254
+ violations.push({ kind: 'no-citations', detail: '回答没有引用任何数据点' })
255
+ }
256
+ if (usedPoints.length === 0 && insufficient === undefined && knownIds.size > 0 && source.trim() !== '') {
257
+ violations.push({ kind: 'no-citations', detail: '回答没有引用任何数据点,也没有说明数据不足' })
258
+ }
259
+
260
+ return { ok: violations.length === 0, violations, usedPoints, insufficient }
261
+ }
262
+
263
+ /**
264
+ * Decide what to do with a verdict (docs/06 §3 rule 3).
265
+ *
266
+ * @param {{ ok: boolean }} verdict - validation verdict.
267
+ * @param {number} attempt - how many attempts have been made (1-based).
268
+ * @param {number} [maxAttempts] - retry budget.
269
+ * @returns {'accept'|'retry'|'degrade'} action.
270
+ */
271
+ export function decideAction(verdict, attempt, maxAttempts = 2) {
272
+ if (verdict.ok) return 'accept'
273
+ return attempt < maxAttempts ? 'retry' : 'degrade'
274
+ }
275
+
276
+ /**
277
+ * Human-readable violation list for the retry prompt.
278
+ *
279
+ * @param {Array<{ detail: string }>} violations - violations.
280
+ * @returns {string[]} lines.
281
+ */
282
+ export function violationLines(violations) {
283
+ return (violations ?? []).map((entry) => entry.detail)
284
+ }