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.
- package/LICENSE +27 -0
- package/README.md +96 -0
- package/cordis.patch.yml +40 -0
- package/docs/01-product-effect.md +178 -0
- package/docs/02-architecture.md +275 -0
- package/docs/03-data-contracts.md +291 -0
- package/docs/04-sources.md +342 -0
- package/docs/05-ui-spec.md +167 -0
- package/docs/06-ai-layer.md +194 -0
- package/docs/07-implementation-plan.md +399 -0
- package/docs/08-test-plan.md +133 -0
- package/docs/09-packaging-install.md +249 -0
- package/docs/10-kickoff-prompt.md +94 -0
- package/docs/11-decisions.md +203 -0
- package/docs/12-runtime-verified.md +115 -0
- package/docs/13-acceptance.md +153 -0
- package/docs/14-progress.md +150 -0
- package/docs/15-publish.md +185 -0
- package/lib/app/ai-deterministic.js +327 -0
- package/lib/app/ai-validate.js +284 -0
- package/lib/app/ai.js +440 -0
- package/lib/app/health.js +77 -0
- package/lib/app/overview.js +349 -0
- package/lib/app/propose-indicator.js +122 -0
- package/lib/app/refresh.js +251 -0
- package/lib/app/series-view.js +195 -0
- package/lib/app/watchlist.js +102 -0
- package/lib/client.js +4322 -0
- package/lib/core/ai/prompts.js +213 -0
- package/lib/core/chart/axis.js +133 -0
- package/lib/core/chart/bar.js +58 -0
- package/lib/core/chart/candle.js +216 -0
- package/lib/core/chart/line.js +186 -0
- package/lib/core/chart/scale.js +132 -0
- package/lib/core/format.js +143 -0
- package/lib/core/indicators/catalog.js +1011 -0
- package/lib/core/indicators/resolve.js +196 -0
- package/lib/core/insight/digest.js +250 -0
- package/lib/core/insight/rank.js +115 -0
- package/lib/core/insight/related.js +90 -0
- package/lib/core/insight/rules.js +417 -0
- package/lib/core/stats/derive.js +123 -0
- package/lib/core/stats/series.js +465 -0
- package/lib/core/time/range.js +242 -0
- package/lib/core/types.js +478 -0
- package/lib/host/ai/discussion.js +559 -0
- package/lib/host/ai/dsh-llm-gateway.js +333 -0
- package/lib/host/config.js +194 -0
- package/lib/host/http/respond.js +165 -0
- package/lib/host/http/routes.js +689 -0
- package/lib/host/index.js +293 -0
- package/lib/host/infra/fs-repos.js +179 -0
- package/lib/host/infra/memory-fallback.js +64 -0
- package/lib/host/tools/define-tool.js +295 -0
- package/lib/host/tools/register.js +431 -0
- package/lib/host.js +7 -0
- package/lib/ports/clock.js +57 -0
- package/lib/ports/snapshot-repo.js +48 -0
- package/lib/sources/eastmoney-macro.js +197 -0
- package/lib/sources/eastmoney-quote.js +201 -0
- package/lib/sources/ecb.js +179 -0
- package/lib/sources/fred.js +207 -0
- package/lib/sources/http.js +136 -0
- package/lib/sources/ohlc.js +36 -0
- package/lib/sources/quote-cascade.js +177 -0
- package/lib/sources/registry.js +153 -0
- package/lib/sources/sina-cn.js +197 -0
- package/lib/sources/sina-us.js +187 -0
- package/lib/sources/tencent.js +158 -0
- package/lib/sources/us-treasury-rates.js +275 -0
- package/lib/sources/us-treasury.js +196 -0
- package/lib/sources/worldbank.js +170 -0
- package/package.json +69 -0
- package/src/app/ai-deterministic.js +327 -0
- package/src/app/ai-validate.js +284 -0
- package/src/app/ai.js +440 -0
- package/src/app/health.js +77 -0
- package/src/app/overview.js +349 -0
- package/src/app/propose-indicator.js +122 -0
- package/src/app/refresh.js +251 -0
- package/src/app/series-view.js +195 -0
- package/src/app/watchlist.js +102 -0
- package/src/client/api.js +323 -0
- package/src/client/components.js +1877 -0
- package/src/client/copy.js +368 -0
- package/src/client/index.js +169 -0
- package/src/client/store.js +219 -0
- package/src/core/ai/prompts.js +213 -0
- package/src/core/chart/axis.js +133 -0
- package/src/core/chart/bar.js +58 -0
- package/src/core/chart/candle.js +216 -0
- package/src/core/chart/line.js +186 -0
- package/src/core/chart/scale.js +132 -0
- package/src/core/format.js +143 -0
- package/src/core/indicators/catalog.js +1011 -0
- package/src/core/indicators/resolve.js +196 -0
- package/src/core/insight/digest.js +250 -0
- package/src/core/insight/rank.js +115 -0
- package/src/core/insight/related.js +90 -0
- package/src/core/insight/rules.js +417 -0
- package/src/core/stats/derive.js +123 -0
- package/src/core/stats/series.js +465 -0
- package/src/core/time/range.js +242 -0
- package/src/core/types.js +478 -0
- package/src/host/ai/discussion.js +559 -0
- package/src/host/ai/dsh-llm-gateway.js +333 -0
- package/src/host/config.js +194 -0
- package/src/host/http/respond.js +165 -0
- package/src/host/http/routes.js +689 -0
- package/src/host/index.js +293 -0
- package/src/host/infra/fs-repos.js +179 -0
- package/src/host/infra/memory-fallback.js +64 -0
- package/src/host/tools/define-tool.js +295 -0
- package/src/host/tools/register.js +431 -0
- package/src/ports/clock.js +57 -0
- package/src/ports/snapshot-repo.js +48 -0
- package/src/sources/eastmoney-macro.js +197 -0
- package/src/sources/eastmoney-quote.js +201 -0
- package/src/sources/ecb.js +179 -0
- package/src/sources/fred.js +207 -0
- package/src/sources/http.js +136 -0
- package/src/sources/ohlc.js +36 -0
- package/src/sources/quote-cascade.js +177 -0
- package/src/sources/registry.js +153 -0
- package/src/sources/sina-cn.js +197 -0
- package/src/sources/sina-us.js +187 -0
- package/src/sources/tencent.js +158 -0
- package/src/sources/us-treasury-rates.js +275 -0
- package/src/sources/us-treasury.js +196 -0
- 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
|
+
}
|