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,349 @@
1
+ /**
2
+ * The overview use case: "today's panel" (docs/07 T6.2).
3
+ *
4
+ * Responsibilities: resolve the requested range, refresh every visible
5
+ * indicator (with per-item degradation), apply each indicator's display
6
+ * transform, compute stats, run the insight rules, rank the noteworthy list, and
7
+ * return cards that always carry provenance.
8
+ *
9
+ * @module app/overview
10
+ */
11
+ import { applyTransform, stats as computeStats } from '../core/stats/series.js'
12
+ import { buildNoteworthy } from '../core/insight/rank.js'
13
+ import { runRules } from '../core/insight/rules.js'
14
+ import { filterRange, resolveRange } from '../core/time/range.js'
15
+ import { applyDerive } from '../core/stats/derive.js'
16
+
17
+ /** How many points a card sparkline carries. */
18
+ export const SPARKLINE_POINTS = 40
19
+
20
+ /**
21
+ * Expand a set of indicator ids with every operand a derived indicator needs.
22
+ *
23
+ * @param {string[]} ids - requested ids.
24
+ * @param {Record<string, object>} catalogById - catalog index.
25
+ * @returns {Set<string>} closed id set.
26
+ */
27
+ export function withOperands(ids, catalogById) {
28
+ const closed = new Set()
29
+ const visit = (id) => {
30
+ if (closed.has(id)) return
31
+ closed.add(id)
32
+ for (const operand of catalogById[id]?.derive?.operands ?? []) visit(operand)
33
+ }
34
+ for (const id of ids) visit(id)
35
+ return closed
36
+ }
37
+
38
+ /**
39
+ * Build one card's sparkline: the trailing window of transformed values,
40
+ * normalized to its own min/max so the shape is visible regardless of units.
41
+ *
42
+ * @param {Array<{ t: string, v: number }>} points - transformed points.
43
+ * @param {number} [limit] - point budget.
44
+ * @returns {number[]} sparkline values.
45
+ */
46
+ export function sparkline(points, limit = SPARKLINE_POINTS) {
47
+ if (!Array.isArray(points) || points.length === 0) return []
48
+ const tail = points.slice(-limit).map((point) => point.v)
49
+ const min = Math.min(...tail)
50
+ const max = Math.max(...tail)
51
+ if (!Number.isFinite(min) || !Number.isFinite(max)) return []
52
+ if (max === min) return tail.map(() => 0.5)
53
+ return tail.map((value) => (value - min) / (max - min))
54
+ }
55
+
56
+ /**
57
+ * The full display pipeline for one indicator's raw series.
58
+ *
59
+ * @param {object} indicator - catalog definition.
60
+ * @param {object} series - raw (or derived) series.
61
+ * @returns {{ points: Array<{ t: string, v: number }>, stats: object|undefined }} display points and stats.
62
+ */
63
+ export function displaySeries(indicator, series) {
64
+ const transformed = applyTransform(series.points ?? [], indicator.display ?? {})
65
+ const stats = computeStats(transformed, { decimals: indicator.display?.decimals, freq: indicator.freq })
66
+ return { points: transformed, stats }
67
+ }
68
+
69
+ /**
70
+ * Make a series' reported count describe the points a caller actually gets.
71
+ *
72
+ * Statistics stay computed over the whole fetched series — a YoY reading needs
73
+ * twelve prior points even when the caller asked for three months — but the
74
+ * count must not. Leaving it series-wide made the digest print
75
+ * `points=13 missing=1` above a list of 12 dates, and an agent reading the panel
76
+ * context reported that contradiction before any human noticed it.
77
+ *
78
+ * @param {object} indicator - catalog definition.
79
+ * @param {{ points: Array<{ t: string, v: number }>, stats: object|undefined }} display - display pipeline output.
80
+ * @param {{ from: string, to: string }} range - requested range.
81
+ * @returns {object|undefined} stats whose `count` matches the returned points.
82
+ */
83
+ export function describeReturned(indicator, display, range) {
84
+ if (display.stats === undefined) return undefined
85
+ const returned = filterRange(display.points, range)
86
+ return { ...display.stats, count: returned.length }
87
+ }
88
+
89
+ /**
90
+ * Create the overview use case.
91
+ *
92
+ * @param {object} deps - dependencies.
93
+ * @param {object} deps.catalog - indicator catalog.
94
+ * @param {Record<string, object>} deps.catalogById - id → definition.
95
+ * @param {object} deps.refresh - refresh service.
96
+ * @param {import('../ports/clock.js').Clock} deps.clock - clock.
97
+ * @param {object} deps.runtime - runtime deps passed to adapters ('{ fetch, clock }').
98
+ * @param {(msg: string, meta?: object) => void} [deps.log] - logger.
99
+ * @returns {object} overview use case.
100
+ */
101
+ export function createOverviewUseCase({ catalog, catalogById, refresh, clock, runtime, log = () => {} }) {
102
+ /**
103
+ * Resolve the range for a preset.
104
+ *
105
+ * @param {string} preset - range preset.
106
+ * @param {object} [custom] - custom bounds.
107
+ * @returns {{ preset: string, from: string, to: string }} range.
108
+ */
109
+ function rangeFor(preset = '1Y', custom) {
110
+ return resolveRange(preset, clock.today(), custom)
111
+ }
112
+
113
+ /**
114
+ * Refresh and assemble every requested indicator.
115
+ *
116
+ * @param {{ range?: string, groups?: string[], ids?: string[], limit?: number, custom?: object, force?: boolean }} [request] - overview request.
117
+ * @returns {Promise<object>} '{ range, metrics, noteworthy, sources, errors, generatedAt }'.
118
+ */
119
+ async function overview(request = {}) {
120
+ const range = rangeFor(request.range ?? '1Y', request.custom)
121
+ const groups = request.groups
122
+ // Selecting a derived indicator implicitly selects its operands: asking for
123
+ // `us.real10y` must fetch the two rates it is built from.
124
+ const wanted = request.ids === undefined ? undefined : withOperands(request.ids, catalogById)
125
+ const selected = catalog.filter((indicator) => {
126
+ if (wanted !== undefined) return wanted.has(indicator.id)
127
+ if (groups !== undefined && !groups.includes(indicator.group)) return false
128
+ return true
129
+ })
130
+
131
+ const direct = selected.filter((indicator) => indicator.derive === undefined)
132
+ const results = await refresh.refreshAll(
133
+ direct.map((indicator) => ({
134
+ indicatorId: indicator.id,
135
+ adapterId: indicator.source.adapter,
136
+ seriesRef: indicator.source.seriesRef,
137
+ params: indicator.source.params,
138
+ range,
139
+ freq: indicator.freq,
140
+ runtime: { ...runtime, force: request.force === true },
141
+ })),
142
+ )
143
+ const byId = new Map(direct.map((indicator, index) => [indicator.id, results[index]]))
144
+
145
+ // Derived indicators are computed from their operands' *raw* series, then
146
+ // everything goes through the same display pipeline.
147
+ const displayById = new Map()
148
+ for (const indicator of selected) {
149
+ if (indicator.derive !== undefined) continue
150
+ const series = byId.get(indicator.id)
151
+ if (series === undefined || series.status === 'error') {
152
+ displayById.set(indicator.id, { raw: series, points: [], stats: undefined, status: series?.status ?? 'error' })
153
+ continue
154
+ }
155
+ displayById.set(indicator.id, { raw: series, ...displaySeries(indicator, series), status: series.status })
156
+ }
157
+
158
+ for (const indicator of selected.filter((entry) => entry.derive !== undefined)) {
159
+ const seriesById = {}
160
+ for (const operand of indicator.derive.operands) {
161
+ const operandSeries = byId.get(operand)
162
+ if (operandSeries?.points?.length) seriesById[operand] = operandSeries.points
163
+ }
164
+ const derived = applyDerive(indicator.derive, seriesById)
165
+ if (derived.points.length === 0) {
166
+ // A derived series that cannot be computed still carries the provenance
167
+ // of its operands: the card must stay traceable, and the reason has to
168
+ // explain itself rather than showing an empty card.
169
+ const operandRef = byId.get(indicator.derive.operands.find((operand) => byId.get(operand)?.sourceRef !== undefined))?.sourceRef
170
+ displayById.set(indicator.id, {
171
+ raw: operandRef === undefined
172
+ ? undefined
173
+ : {
174
+ sourceRef: { ...operandRef, seriesRef: indicator.derive.operands.join('+'), label: `${operandRef.label}(现算)` },
175
+ },
176
+ points: [],
177
+ stats: undefined,
178
+ status: 'missing',
179
+ derived: true,
180
+ reason: derived.reason,
181
+ missing: derived.missing,
182
+ })
183
+ continue
184
+ }
185
+ // Provenance for a derived series is the provenance of its first operand,
186
+ // labelled so a reader knows the number was computed here rather than
187
+ // published upstream.
188
+ const operandRef = byId.get(indicator.derive.operands[0])?.sourceRef
189
+ const sourceRef = operandRef === undefined
190
+ ? undefined
191
+ : { ...operandRef, seriesRef: indicator.derive.operands.join('+'), label: `${operandRef.label}(现算)` }
192
+ const series = {
193
+ adapterId: 'derived',
194
+ seriesRef: indicator.derive.operands.join('+'),
195
+ points: derived.points,
196
+ meta: { name: indicator.label.zh, freq: indicator.freq },
197
+ fetchedAt: clock.now().toISOString(),
198
+ sourceRef,
199
+ }
200
+ displayById.set(indicator.id, { raw: series, ...displaySeries(indicator, series), status: 'fresh', derived: true })
201
+ }
202
+
203
+ const statusById = {}
204
+ const seriesForRules = {}
205
+ for (const [id, entry] of displayById) {
206
+ statusById[id] = entry.status
207
+ if (entry.points.length > 0) seriesForRules[id] = { points: entry.points, stats: entry.stats }
208
+ }
209
+
210
+ const { hits, errors: ruleErrors } = runRules({ today: clock.today(), seriesById: seriesForRules, catalogById })
211
+ const noteworthy = buildNoteworthy(hits, {
212
+ catalogById,
213
+ statusById,
214
+ limit: request.limit ?? 5,
215
+ minScore: 1,
216
+ })
217
+
218
+ const metrics = selected.map((indicator) => {
219
+ const entry = displayById.get(indicator.id)
220
+ const windowPoints = entry.points
221
+ const ref = entry.raw?.sourceRef
222
+ return {
223
+ indicatorId: indicator.id,
224
+ group: indicator.group,
225
+ label: indicator.label,
226
+ unit: indicator.unit,
227
+ freq: indicator.freq,
228
+ seasonal: indicator.seasonal,
229
+ importance: indicator.importance,
230
+ display: indicator.display,
231
+ notes: indicator.notes,
232
+ tags: indicator.tags ?? [],
233
+ latest: entry.stats?.latest,
234
+ latestAt: entry.stats?.latestAt,
235
+ changeAbs: entry.stats?.changeAbs,
236
+ changePct: entry.stats?.changePct,
237
+ yoy: entry.stats?.yoy,
238
+ sparkline: sparkline(windowPoints),
239
+ pointCount: windowPoints.length,
240
+ status: entry.status,
241
+ lastSuccessAt: entry.raw?.lastSuccessAt,
242
+ errorKind: entry.raw?.error?.kind,
243
+ errorDetail: entry.raw?.error?.detail,
244
+ sourceRef: ref,
245
+ derived: entry.derived === true,
246
+ hits: hits.filter((hit) => hit.indicatorId === indicator.id).map((hit) => ({ ruleId: hit.ruleId, score: hit.score, reason: hit.reason, evidence: hit.evidence })),
247
+ score: noteworthy.find((item) => item.indicatorId === indicator.id)?.score ?? 0,
248
+ }
249
+ })
250
+
251
+ const sources = {}
252
+ for (const [id, entry] of byId) {
253
+ const indicator = catalogById[id]
254
+ const key = indicator?.source?.adapter ?? 'unknown'
255
+ const bucket = sources[key] ?? { adapterId: key, ok: 0, degraded: 0, failed: 0, lastSuccessAt: undefined }
256
+ if (entry.status === 'error') bucket.failed += 1
257
+ else if (entry.status === 'stale') bucket.degraded += 1
258
+ else bucket.ok += 1
259
+ if (entry.lastSuccessAt !== undefined && (bucket.lastSuccessAt === undefined || entry.lastSuccessAt > bucket.lastSuccessAt)) {
260
+ bucket.lastSuccessAt = entry.lastSuccessAt
261
+ }
262
+ sources[key] = bucket
263
+ }
264
+
265
+ // Two indicators can share one series (CPI level and CPI yoy), so the error
266
+ // carries the indicator id from the request rather than being reverse-looked
267
+ // up by series ref. Identical failures are reported once: the panel shows a
268
+ // health list, not a request log.
269
+ const healthErrors = []
270
+ const seenErrors = new Set()
271
+ for (const entry of results) {
272
+ if (entry.status !== 'error') continue
273
+ const key = `${entry.indicatorId ?? ''}|${entry.error?.adapterId ?? ''}|${entry.error?.seriesRef ?? ''}`
274
+ if (seenErrors.has(key)) continue
275
+ seenErrors.add(key)
276
+ healthErrors.push({ indicatorId: entry.indicatorId, ...entry.error })
277
+ }
278
+ for (const error of ruleErrors) log('insight rule failed', error)
279
+
280
+ return {
281
+ range,
282
+ metrics,
283
+ noteworthy: noteworthy.map((entry) => ({
284
+ indicatorId: entry.indicatorId,
285
+ label: catalogById[entry.indicatorId]?.label,
286
+ unit: catalogById[entry.indicatorId]?.unit,
287
+ score: entry.score,
288
+ importance: entry.importance,
289
+ latestAt: entry.latestAt,
290
+ status: entry.status,
291
+ reasons: entry.hits.map((hit) => ({ ruleId: hit.ruleId, reason: hit.reason, evidence: hit.evidence })),
292
+ sourceRef: metrics.find((metric) => metric.indicatorId === entry.indicatorId)?.sourceRef,
293
+ })),
294
+ sources,
295
+ errors: healthErrors,
296
+ generatedAt: clock.now().toISOString(),
297
+ degraded: metrics.some((metric) => metric.status !== 'fresh'),
298
+ }
299
+ }
300
+
301
+ /**
302
+ * The raw (pre-transform) refresh result for one indicator, used by the AI
303
+ * layer to render a digest. Derived indicators resolve through their operands.
304
+ *
305
+ * @param {string} indicatorId - indicator id.
306
+ * @param {{ preset: string, from: string, to: string }} range - resolved range.
307
+ * @returns {Promise<{ points: Array<{ t: string, v: number }>, stats?: object, sourceRef?: object, status: string }|undefined>} series.
308
+ */
309
+ async function seriesFor(indicatorId, range) {
310
+ const indicator = catalogById[indicatorId]
311
+ if (indicator === undefined) return undefined
312
+ if (indicator.derive !== undefined) {
313
+ const seriesById = {}
314
+ for (const operand of indicator.derive.operands) {
315
+ const operandDef = catalogById[operand]
316
+ if (operandDef?.derive !== undefined) continue
317
+ const result = await refresh.refresh({
318
+ adapterId: operandDef.source.adapter,
319
+ seriesRef: operandDef.source.seriesRef,
320
+ params: operandDef.source.params,
321
+ range,
322
+ freq: operandDef.freq,
323
+ runtime,
324
+ })
325
+ if (result.points?.length) seriesById[operand] = result.points
326
+ }
327
+ const derived = applyDerive(indicator.derive, seriesById)
328
+ const operandRef = catalogById[indicator.derive.operands[0]]?.source
329
+ return {
330
+ points: derived.points,
331
+ stats: derived.points.length === 0 ? undefined : describeReturned(indicator, displaySeries(indicator, { points: derived.points }), range),
332
+ sourceRef: operandRef === undefined ? undefined : { ...operandRef, seriesRef: indicator.derive.operands.join('+'), label: '现算' },
333
+ status: derived.points.length > 0 ? 'fresh' : 'missing',
334
+ }
335
+ }
336
+ const result = await refresh.refresh({
337
+ adapterId: indicator.source.adapter,
338
+ seriesRef: indicator.source.seriesRef,
339
+ params: indicator.source.params,
340
+ range,
341
+ freq: indicator.freq,
342
+ runtime,
343
+ })
344
+ const display = displaySeries(indicator, result)
345
+ return { points: display.points, stats: describeReturned(indicator, display, range), sourceRef: result.sourceRef, status: result.status }
346
+ }
347
+
348
+ return { overview, rangeFor, displaySeries, sparkline, seriesFor }
349
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Conversational indicator addition (docs/06 §4).
3
+ *
4
+ * The gateway proposes, but **this module decides**. A candidate is only
5
+ * accepted after it passes the schema validator *and* its series actually
6
+ * fetches: a fabricated 'seriesRef' fails the fetch and lands in 'unsupported'
7
+ * with the failure reason. That is the mechanical guarantee behind "the AI may
8
+ * never invent a series id".
9
+ *
10
+ * @module app/propose-indicator
11
+ */
12
+ import { validateIndicatorDef } from '../core/types.js'
13
+ import { searchIndicators } from '../core/indicators/resolve.js'
14
+ import { adapterIds } from '../sources/registry.js'
15
+
16
+ /**
17
+ * Create the propose use case.
18
+ *
19
+ * @param {object} deps - dependencies.
20
+ * @param {object} deps.gateway - 'AiGateway' (its 'propose' is used).
21
+ * @param {Record<string, object>} deps.catalogById - existing catalog.
22
+ * @param {object} deps.refresh - refresh service, used to verify a series fetches.
23
+ * @param {import('../ports/clock.js').Clock} deps.clock - clock.
24
+ * @param {object} deps.runtime - runtime deps for adapters.
25
+ * @param {(adapterId: string) => boolean} deps.hasAdapter - adapter registry lookup.
26
+ * @param {(msg: string, meta?: object) => void} [deps.log] - logger.
27
+ * @returns {object} propose use case.
28
+ */
29
+ export function createProposeUseCase({ gateway, catalogById, refresh, clock, runtime, hasAdapter, log = () => {} }) {
30
+ /**
31
+ * Propose indicator definitions for a natural-language request.
32
+ *
33
+ * @param {{ text: string, verify?: boolean }} request - request.
34
+ * @returns {Promise<object>} '{ candidates, unsupported, rejected, mode }'.
35
+ */
36
+ async function propose(request = {}) {
37
+ const { text, verify = true } = request
38
+ // The search runs here so both gateway shapes work: the deterministic one
39
+ // takes catalog matches, and an LLM gateway only needs the text.
40
+ const search = searchIndicators(text)
41
+ const proposal = (await gateway.propose({
42
+ request: text,
43
+ supportedAdapters: adapterIds(),
44
+ knownIds: Object.keys(catalogById),
45
+ matches: search.matches,
46
+ })) ?? { candidates: [], unsupported: [] }
47
+
48
+ const rejected = []
49
+ const candidates = []
50
+ const unsupported = [...(proposal.unsupported ?? [])]
51
+
52
+ for (const candidate of proposal.candidates ?? []) {
53
+ // An echo of a catalog entry is already schema-valid (the catalog suite
54
+ // enforces that), so only genuinely new definitions are re-validated here.
55
+ const validation = candidate.fromCatalog === true ? { ok: true, errors: [] } : validateIndicatorDef(candidate)
56
+ if (!validation.ok) {
57
+ rejected.push({ candidate, reason: validation.errors.join('; '), stage: 'schema' })
58
+ continue
59
+ }
60
+ if (!hasAdapter(candidate.source.adapter)) {
61
+ rejected.push({ candidate, reason: `unknown adapter ${candidate.source.adapter}`, stage: 'registry' })
62
+ unsupported.push({
63
+ request: candidate.label?.zh ?? candidate.id,
64
+ reason: `数据源适配器 ${candidate.source.adapter} 未注册`,
65
+ alternatives: ['换用已注册的数据源', '新增该数据源适配器后再试'],
66
+ })
67
+ continue
68
+ }
69
+
70
+ if (verify) {
71
+ // The anti-hallucination gate: an invented seriesRef cannot fetch.
72
+ const range = { preset: '1Y', from: clock.today(), to: clock.today() }
73
+ const probe = await refresh.refresh({
74
+ adapterId: candidate.source.adapter,
75
+ seriesRef: candidate.source.seriesRef,
76
+ params: candidate.source.params,
77
+ range,
78
+ freq: candidate.freq,
79
+ runtime: { ...runtime, force: true },
80
+ })
81
+ if (probe.status !== 'fresh' && probe.status !== 'stale') {
82
+ rejected.push({ candidate, reason: `series did not fetch: ${probe.error?.detail ?? probe.status}`, stage: 'fetch' })
83
+ unsupported.push({
84
+ request: candidate.label?.zh ?? candidate.id,
85
+ reason: `无法从 ${candidate.source.adapter} 取到 seriesRef「${candidate.source.seriesRef}」的数据(${probe.error?.kind ?? probe.status})`,
86
+ alternatives: ['确认上游是否存在该序列', '改用已有指标作为代理'],
87
+ })
88
+ continue
89
+ }
90
+ candidates.push({
91
+ ...candidate,
92
+ conflict: catalogById[candidate.id] !== undefined,
93
+ verified: { points: probe.points.length, latestAt: probe.latestAt, status: probe.status },
94
+ })
95
+ } else {
96
+ candidates.push({ ...candidate, conflict: catalogById[candidate.id] !== undefined })
97
+ }
98
+ }
99
+
100
+ // A rejected candidate is an answer the user needs: say what was attempted and
101
+ // why it could not be added, instead of returning an empty result.
102
+ for (const entry of rejected) {
103
+ const label = entry.candidate?.label?.zh ?? entry.candidate?.id ?? '(未命名)'
104
+ unsupported.push({
105
+ request: label,
106
+ reason:
107
+ entry.stage === 'fetch'
108
+ ? `无法从 ${entry.candidate?.source?.adapter ?? '数据源'} 取到 seriesRef「${entry.candidate?.source?.seriesRef ?? ''}」的数据,因此没有加入。`
109
+ : `候选不合规(${entry.stage}):${entry.reason}`,
110
+ alternatives:
111
+ entry.stage === 'fetch'
112
+ ? ['确认上游是否存在该序列', '改用已收录的同类指标作为代理']
113
+ : ['在指标目录中手动检索已有指标'],
114
+ })
115
+ }
116
+
117
+ log('propose finished', { candidates: candidates.length, rejected: rejected.length, unsupported: unsupported.length })
118
+ return { candidates, unsupported, rejected, mode: proposal.mode ?? gateway.describe?.().mode }
119
+ }
120
+
121
+ return { propose }
122
+ }