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
@@ -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
+ }