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
package/src/app/ai.js
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AI orchestration layer (docs/06 §1–§6).
|
|
3
|
+
*
|
|
4
|
+
* One place assembles the digest, calls the gateway, validates the answer,
|
|
5
|
+
* retries once with feedback, degrades to the deterministic gateway, and caches
|
|
6
|
+
* the result. Nothing here trusts the model: a violation never reaches the user.
|
|
7
|
+
*
|
|
8
|
+
* @module app/ai
|
|
9
|
+
*/
|
|
10
|
+
import { buildDigest } from '../core/insight/digest.js'
|
|
11
|
+
import { buildProposePrompt, buildExplainPrompt, buildSummaryPrompt, buildAnswerPrompt, buildOverviewPrompt } from '../core/ai/prompts.js'
|
|
12
|
+
import { fingerprint } from '../core/insight/rank.js'
|
|
13
|
+
import { decideAction, buildValidationData, validateAnswer, violationLines } from './ai-validate.js'
|
|
14
|
+
import { searchIndicators } from '../core/indicators/resolve.js'
|
|
15
|
+
import { inRange } from '../core/time/range.js'
|
|
16
|
+
|
|
17
|
+
/** Default AI cache TTL in minutes (docs/06 §6). */
|
|
18
|
+
export const AI_CACHE_MINUTES = 60
|
|
19
|
+
|
|
20
|
+
/** Human labels for the analysis scopes the panel can request. */
|
|
21
|
+
export const SCOPE_LABELS = {
|
|
22
|
+
ALL: '全部指标',
|
|
23
|
+
US: '美国',
|
|
24
|
+
CN: '中国',
|
|
25
|
+
GLOBAL: '全球',
|
|
26
|
+
CUSTOM: '自选',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create the AI use cases.
|
|
31
|
+
*
|
|
32
|
+
* @param {object} deps - dependencies.
|
|
33
|
+
* @param {object} deps.gateway - primary 'AiGateway'.
|
|
34
|
+
* @param {object} deps.fallback - deterministic 'AiGateway'.
|
|
35
|
+
* @param {object} deps.overview - the overview use case (source of digest entries).
|
|
36
|
+
* @param {Record<string, object>} deps.catalogById - catalog index.
|
|
37
|
+
* @param {import('../ports/snapshot-repo.js').SnapshotRepository} deps.snapshots - cache.
|
|
38
|
+
* @param {import('../ports/clock.js').Clock} deps.clock - clock.
|
|
39
|
+
* @param {object} [deps.propose] - propose use case.
|
|
40
|
+
* @param {(msg: string, meta?: object) => void} [deps.log] - logger.
|
|
41
|
+
* @param {{ maxAttempts?: number, cacheMinutes?: number, digestBudget?: number }} [deps.options] - tuning.
|
|
42
|
+
* @returns {object} AI use cases.
|
|
43
|
+
*/
|
|
44
|
+
export function createAiUseCases({
|
|
45
|
+
gateway,
|
|
46
|
+
fallback,
|
|
47
|
+
overview,
|
|
48
|
+
catalogById,
|
|
49
|
+
snapshots,
|
|
50
|
+
clock,
|
|
51
|
+
propose: proposeUseCase,
|
|
52
|
+
// Re-derives display statistics for a scoped window; the overview use case owns
|
|
53
|
+
// the pipeline, and injecting it keeps this module free of a second copy.
|
|
54
|
+
displaySeries: toDisplay,
|
|
55
|
+
log = () => {},
|
|
56
|
+
options = {},
|
|
57
|
+
}) {
|
|
58
|
+
const maxAttempts = options.maxAttempts ?? 2
|
|
59
|
+
const cacheMinutes = options.cacheMinutes ?? AI_CACHE_MINUTES
|
|
60
|
+
const digestBudget = options.digestBudget ?? 8000
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the digest entries for a request.
|
|
64
|
+
*
|
|
65
|
+
* `detail: false` skips the per-indicator series fan-out and returns entries
|
|
66
|
+
* with only the metrics' headline numbers. Callers use it to render a compact
|
|
67
|
+
* inventory of the whole panel — "what else is here" — which must not cost one
|
|
68
|
+
* series fetch per indicator on every discussion click.
|
|
69
|
+
*
|
|
70
|
+
* @param {{ range?: string, indicators?: string[], groups?: string[], limit?: number, noteworthy?: number, detail?: boolean }} request - request.
|
|
71
|
+
* @returns {Promise<{ entries: object[], range: object, metrics: object[], noteworthy: object[] }>} digest input.
|
|
72
|
+
*/
|
|
73
|
+
async function buildEntries(request) {
|
|
74
|
+
const result = await overview.overview({
|
|
75
|
+
range: request.range ?? '1Y',
|
|
76
|
+
ids: request.indicators,
|
|
77
|
+
groups: request.groups,
|
|
78
|
+
limit: request.noteworthy ?? 5,
|
|
79
|
+
})
|
|
80
|
+
const wanted = new Set(request.indicators ?? result.metrics.map((metric) => metric.indicatorId))
|
|
81
|
+
const entries = []
|
|
82
|
+
for (const metric of result.metrics) {
|
|
83
|
+
if (!wanted.has(metric.indicatorId)) continue
|
|
84
|
+
const indicator = catalogById[metric.indicatorId]
|
|
85
|
+
if (indicator === undefined) continue
|
|
86
|
+
entries.push({
|
|
87
|
+
indicator,
|
|
88
|
+
stats: {
|
|
89
|
+
latest: metric.latest,
|
|
90
|
+
latestAt: metric.latestAt,
|
|
91
|
+
changeAbs: metric.changeAbs,
|
|
92
|
+
changePct: metric.changePct,
|
|
93
|
+
yoy: metric.yoy,
|
|
94
|
+
mean: undefined,
|
|
95
|
+
min: undefined,
|
|
96
|
+
max: undefined,
|
|
97
|
+
stdDev: undefined,
|
|
98
|
+
missingCount: 0,
|
|
99
|
+
count: metric.pointCount,
|
|
100
|
+
},
|
|
101
|
+
points: [],
|
|
102
|
+
status: metric.status,
|
|
103
|
+
score: metric.score,
|
|
104
|
+
sourceRef: metric.sourceRef,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
// The digest renders the same numbers the cards show, so a viewer can check
|
|
108
|
+
// any claim in the answer against the panel.
|
|
109
|
+
if (request.detail === false) return { entries, range: result.range, metrics: result.metrics, noteworthy: result.noteworthy ?? [] }
|
|
110
|
+
const detailed = await Promise.all(
|
|
111
|
+
entries.map(async (entry) => {
|
|
112
|
+
const series = await overview.seriesFor(entry.indicator.id, result.range)
|
|
113
|
+
return series === undefined ? entry : { ...entry, stats: series.stats ?? entry.stats, points: series.points ?? [] }
|
|
114
|
+
}),
|
|
115
|
+
)
|
|
116
|
+
// Statistics and the published point list must describe ONE window. Reading
|
|
117
|
+
// them from the whole fetched series made the digest announce
|
|
118
|
+
// `mean=2616.73 points=250 missing=2577` — numbers from 2004 — beside 250
|
|
119
|
+
// points from the last year, and an agent then reported the contradiction
|
|
120
|
+
// instead of analysing the indicator. Scoped here, every published number
|
|
121
|
+
// describes the range the panel is showing, while the fetched tail still
|
|
122
|
+
// feeds whatever lookback a statistic needs (YoY keeps its earlier points).
|
|
123
|
+
const scoped = detailed.map((entry) => {
|
|
124
|
+
if (typeof toDisplay !== 'function') return entry
|
|
125
|
+
const points = (entry.points ?? []).filter((point) => inRange(point.t, result.range))
|
|
126
|
+
if (points.length === 0) return entry
|
|
127
|
+
return { ...entry, points, stats: toDisplay(entry.indicator, { points }).stats ?? entry.stats }
|
|
128
|
+
})
|
|
129
|
+
return { entries: scoped, range: result.range, metrics: result.metrics, noteworthy: result.noteworthy ?? [] }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Render the triggered attention rules as digest text.
|
|
134
|
+
*
|
|
135
|
+
* "Worth watching today" is a *rule outcome*, not a measurement, so it is not
|
|
136
|
+
* part of any indicator block — but it is the reason a reader opens the panel
|
|
137
|
+
* at all, and an analysis that cannot see it answers the wrong question. The
|
|
138
|
+
* same block feeds the overall-analysis digest and the discussion context a
|
|
139
|
+
* noteworthy item's button opens.
|
|
140
|
+
*
|
|
141
|
+
* @param {Array<{ indicatorId: string, label?: { zh?: string }, reasons?: object[], score?: number, status?: string }>} noteworthy - panel noteworthy items.
|
|
142
|
+
* @returns {string} digest section, or '' when nothing fired.
|
|
143
|
+
*/
|
|
144
|
+
function buildNoteworthyText(noteworthy) {
|
|
145
|
+
const items = noteworthy ?? []
|
|
146
|
+
if (items.length === 0) return ''
|
|
147
|
+
const lines = ['## 今日值得关注(规则触发,非用户提问)']
|
|
148
|
+
for (const item of items) {
|
|
149
|
+
const reasons = (item.reasons ?? [])
|
|
150
|
+
.map((reason) => reason?.reason?.zh ?? reason?.ruleId)
|
|
151
|
+
.filter((text) => typeof text === 'string' && text !== '')
|
|
152
|
+
lines.push(`- ${item.indicatorId} | ${item.label?.zh ?? item.indicatorId} | 关注分 ${formatScore(item.score)} | ${item.status ?? 'unknown'}`)
|
|
153
|
+
if (reasons.length > 0) lines.push(` 触发原因: ${reasons.join(';')}`)
|
|
154
|
+
}
|
|
155
|
+
return lines.join('\n')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* One decimal, so the digest text is stable across renders.
|
|
160
|
+
*
|
|
161
|
+
* @param {number} [value] - score.
|
|
162
|
+
* @returns {string} formatted score.
|
|
163
|
+
*/
|
|
164
|
+
function formatScore(value) {
|
|
165
|
+
return Number.isFinite(value) ? (Math.round(value * 10) / 10).toFixed(1) : '0.0'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Read a cached AI result.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} key - cache key.
|
|
172
|
+
* @returns {Promise<object|undefined>} cached result.
|
|
173
|
+
*/
|
|
174
|
+
async function readCache(key) {
|
|
175
|
+
const entry = await snapshots.read(`ai|${key}`)
|
|
176
|
+
if (entry === undefined) return undefined
|
|
177
|
+
const age = clock.now().getTime() - new Date(entry.storedAt).getTime()
|
|
178
|
+
if (age > cacheMinutes * 60_000) return undefined
|
|
179
|
+
return entry.payload
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Store an AI result.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} key - cache key.
|
|
186
|
+
* @param {object} payload - result.
|
|
187
|
+
* @returns {Promise<void>} completion.
|
|
188
|
+
*/
|
|
189
|
+
async function writeCache(key, payload) {
|
|
190
|
+
await snapshots.write(`ai|${key}`, payload, { ttlMs: cacheMinutes * 60_000, at: clock.now().toISOString() })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Run one operation through the gateway with validation, retry and fallback.
|
|
195
|
+
*
|
|
196
|
+
* @param {object} input - invocation.
|
|
197
|
+
* @param {'explain'|'summarize'|'answer'|'overview'} input.op - operation.
|
|
198
|
+
* @param {object} input.request - operation request.
|
|
199
|
+
* @param {object} input.digestInput - digest entries and range.
|
|
200
|
+
* @param {(gateway: object, violations?: string[]) => Promise<object>} input.invoke - gateway call.
|
|
201
|
+
* @param {(violations?: string[]) => Promise<object>} input.fallbackCall - deterministic call.
|
|
202
|
+
* @param {string} input.cacheKeyValue - cache identity.
|
|
203
|
+
* @returns {Promise<object>} 'AiResult'.
|
|
204
|
+
*/
|
|
205
|
+
async function runValidated({ op, digestInput, digestText, invoke, fallbackCall, cacheKeyValue, request = {} }) {
|
|
206
|
+
const cached = await readCache(cacheKeyValue)
|
|
207
|
+
if (cached !== undefined) {
|
|
208
|
+
log('ai cache hit', { op, key: cacheKeyValue })
|
|
209
|
+
return { ...cached, cached: true }
|
|
210
|
+
}
|
|
211
|
+
const data = buildValidationData(digestInput.entries)
|
|
212
|
+
let violations
|
|
213
|
+
let attempt = 0
|
|
214
|
+
let lastVerdict
|
|
215
|
+
while (attempt < maxAttempts) {
|
|
216
|
+
attempt += 1
|
|
217
|
+
const result = await invoke(gateway, violations)
|
|
218
|
+
if (result?.mode === 'deterministic') {
|
|
219
|
+
const payload = { ...result, cached: false }
|
|
220
|
+
await writeCache(cacheKeyValue, payload)
|
|
221
|
+
return payload
|
|
222
|
+
}
|
|
223
|
+
// An empty answer is a provider failure, not a citation violation: retrying
|
|
224
|
+
// the same call will not help, so degrade immediately and carry the reason.
|
|
225
|
+
if ((result?.markdown ?? '') === '') {
|
|
226
|
+
lastVerdict = {
|
|
227
|
+
ok: false,
|
|
228
|
+
violations: [{ kind: 'empty-answer', detail: result?.emptyReason?.detail ?? '模型返回了空回答' }],
|
|
229
|
+
usedPoints: [],
|
|
230
|
+
insufficient: undefined,
|
|
231
|
+
}
|
|
232
|
+
break
|
|
233
|
+
}
|
|
234
|
+
const verdict = validateAnswer({ text: result.markdown, data, echoOf: request.question, digestText })
|
|
235
|
+
lastVerdict = verdict
|
|
236
|
+
if (decideAction(verdict, attempt, maxAttempts) === 'accept') {
|
|
237
|
+
const payload = {
|
|
238
|
+
...result,
|
|
239
|
+
usedPoints: verdict.usedPoints.length > 0 ? verdict.usedPoints : (result.usedPoints ?? []),
|
|
240
|
+
cached: false,
|
|
241
|
+
}
|
|
242
|
+
await writeCache(cacheKeyValue, payload)
|
|
243
|
+
return payload
|
|
244
|
+
}
|
|
245
|
+
if (decideAction(verdict, attempt, maxAttempts) === 'retry') {
|
|
246
|
+
violations = violationLines(verdict.violations)
|
|
247
|
+
log('ai output failed validation, retrying', { op, violations })
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// Two failed attempts: degrade rather than show unverifiable text.
|
|
252
|
+
log('ai output failed validation, degrading to deterministic', { op, violations: violationLines(lastVerdict?.violations ?? []) })
|
|
253
|
+
const degraded = await fallbackCall(violationLines(lastVerdict?.violations ?? []))
|
|
254
|
+
const payload = {
|
|
255
|
+
...degraded,
|
|
256
|
+
mode: 'deterministic',
|
|
257
|
+
degradedFrom: gateway.describe?.().mode ?? 'llm',
|
|
258
|
+
violations: lastVerdict?.violations ?? [],
|
|
259
|
+
degradedReason: lastVerdict?.violations?.[0]?.detail ?? undefined,
|
|
260
|
+
cached: false,
|
|
261
|
+
}
|
|
262
|
+
await writeCache(cacheKeyValue, payload)
|
|
263
|
+
return payload
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Explain one indicator.
|
|
268
|
+
*
|
|
269
|
+
* @param {{ indicatorId: string, range?: string }} request - request.
|
|
270
|
+
* @returns {Promise<object>} 'AiResult'.
|
|
271
|
+
*/
|
|
272
|
+
async function explain(request) {
|
|
273
|
+
const indicator = catalogById[request.indicatorId]
|
|
274
|
+
if (indicator === undefined) {
|
|
275
|
+
return {
|
|
276
|
+
markdown: `数据不足:目录中没有指标 ${request.indicatorId}。`,
|
|
277
|
+
usedPoints: [],
|
|
278
|
+
insufficient: 'unknown indicator',
|
|
279
|
+
mode: gateway.describe?.().mode ?? 'deterministic',
|
|
280
|
+
fingerprint: fingerprint(`explain|${request.indicatorId}|unknown`),
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const digestInput = await buildEntries({ range: request.range, indicators: [request.indicatorId] })
|
|
284
|
+
const digest = buildDigest(digestInput.entries, { budget: digestBudget, title: `指标解析:${indicator.label.zh}`, range: digestInput.range })
|
|
285
|
+
const cacheKeyValue = `explain|${request.indicatorId}|${digestInput.range.from}|${digestInput.range.to}|${fingerprint(digest.text)}`
|
|
286
|
+
return runValidated({
|
|
287
|
+
op: 'explain',
|
|
288
|
+
digestInput,
|
|
289
|
+
digestText: digest.text,
|
|
290
|
+
cacheKeyValue,
|
|
291
|
+
invoke: async (activeGateway, violations) => {
|
|
292
|
+
const prompt = buildExplainPrompt({ digest: digest.text, indicatorId: indicator.id, label: indicator.label.zh, range: digestInput.range, violations })
|
|
293
|
+
return activeGateway.explain({
|
|
294
|
+
...request,
|
|
295
|
+
...prompt,
|
|
296
|
+
// The raw request carries the preset *string*; the prompt and every
|
|
297
|
+
// gateway expect the resolved `{ from, to }`, so the raw one must not
|
|
298
|
+
// win. It did, and the deterministic answer printed
|
|
299
|
+
// "区间:undefined .. undefined".
|
|
300
|
+
range: digestInput.range,
|
|
301
|
+
entries: digestInput.entries,
|
|
302
|
+
digest: digest.text,
|
|
303
|
+
})
|
|
304
|
+
},
|
|
305
|
+
fallbackCall: async () =>
|
|
306
|
+
fallback.explain({ entries: digestInput.entries, indicatorId: request.indicatorId, range: digestInput.range }),
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Summarise a period.
|
|
312
|
+
*
|
|
313
|
+
* @param {{ range?: string, indicators?: string[] }} request - request.
|
|
314
|
+
* @returns {Promise<object>} 'AiResult'.
|
|
315
|
+
*/
|
|
316
|
+
async function summarize(request) {
|
|
317
|
+
const digestInput = await buildEntries({ range: request.range, indicators: request.indicators })
|
|
318
|
+
const digest = buildDigest(digestInput.entries, { budget: digestBudget, title: '时段总结', range: digestInput.range })
|
|
319
|
+
const cacheKeyValue = `summary|${digestInput.range.from}|${digestInput.range.to}|${fingerprint(digest.text)}`
|
|
320
|
+
return runValidated({
|
|
321
|
+
op: 'summarize',
|
|
322
|
+
digestInput,
|
|
323
|
+
digestText: digest.text,
|
|
324
|
+
cacheKeyValue,
|
|
325
|
+
invoke: async (activeGateway, violations) => {
|
|
326
|
+
const prompt = buildSummaryPrompt({ digest: digest.text, range: digestInput.range, violations })
|
|
327
|
+
return activeGateway.summarize({ ...request, ...prompt, range: digestInput.range, entries: digestInput.entries, digest: digest.text })
|
|
328
|
+
},
|
|
329
|
+
fallbackCall: async () => fallback.summarize({ entries: digestInput.entries, range: digestInput.range }),
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Answer a question from panel data only.
|
|
335
|
+
*
|
|
336
|
+
* @param {{ question: string, range?: string }} request - request.
|
|
337
|
+
* @returns {Promise<object>} 'AiResult'.
|
|
338
|
+
*/
|
|
339
|
+
async function ask(request) {
|
|
340
|
+
const digestInput = await buildEntries({ range: request.range })
|
|
341
|
+
// Resolve the question against the catalog first: this is what lets the
|
|
342
|
+
// deterministic fallback answer "数据不足" instead of picking a weak match.
|
|
343
|
+
digestInput.matches = searchIndicators(request.question, { limit: 5 }).matches.map((entry) => ({ ...entry.indicator, matchScore: entry.score }))
|
|
344
|
+
const digest = buildDigest(digestInput.entries, { budget: digestBudget, title: '面板数据', range: digestInput.range })
|
|
345
|
+
const cacheKeyValue = `answer|${fingerprint(request.question)}|${digestInput.range.from}|${digestInput.range.to}|${fingerprint(digest.text)}`
|
|
346
|
+
return runValidated({
|
|
347
|
+
op: 'answer',
|
|
348
|
+
digestInput,
|
|
349
|
+
digestText: digest.text,
|
|
350
|
+
cacheKeyValue,
|
|
351
|
+
request,
|
|
352
|
+
invoke: async (activeGateway, violations) => {
|
|
353
|
+
const prompt = buildAnswerPrompt({ digest: digest.text, question: request.question, range: digestInput.range, violations })
|
|
354
|
+
return activeGateway.answer({ ...request, ...prompt, range: digestInput.range, entries: digestInput.entries, digest: digest.text })
|
|
355
|
+
},
|
|
356
|
+
fallbackCall: async () =>
|
|
357
|
+
fallback.answer({ entries: digestInput.entries, question: request.question, range: digestInput.range, matches: digestInput.matches }),
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Propose an indicator definition.
|
|
363
|
+
*
|
|
364
|
+
* @param {{ text: string, verify?: boolean }} request - request.
|
|
365
|
+
* @returns {Promise<object>} proposal.
|
|
366
|
+
*/
|
|
367
|
+
async function propose(request) {
|
|
368
|
+
if (proposeUseCase === undefined) {
|
|
369
|
+
return { candidates: [], unsupported: [{ request: request.text, reason: 'propose use case is not wired' }], rejected: [], mode: 'deterministic' }
|
|
370
|
+
}
|
|
371
|
+
return proposeUseCase.propose(request)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Analyse the panel as a whole — or one group of it — with the same rules.
|
|
376
|
+
*
|
|
377
|
+
* The single-indicator path answers "what does this number mean". This one
|
|
378
|
+
* answers "what is going on", so its digest carries every observable in scope
|
|
379
|
+
* *and* the triggered attention rules, and the prompt asks for cross-indicator
|
|
380
|
+
* reading rather than a restatement of one series.
|
|
381
|
+
*
|
|
382
|
+
* @param {{ range?: string, groups?: string[], indicators?: string[], limit?: number }} request - request.
|
|
383
|
+
* @returns {Promise<object>} 'AiResult' plus `{ scope, indicators, noteworthy }`.
|
|
384
|
+
*/
|
|
385
|
+
async function overviewAnalysis(request = {}) {
|
|
386
|
+
const groups = Array.isArray(request.groups) && request.groups.length > 0 ? request.groups : undefined
|
|
387
|
+
const digestInput = await buildEntries({
|
|
388
|
+
range: request.range,
|
|
389
|
+
groups,
|
|
390
|
+
indicators: request.indicators,
|
|
391
|
+
limit: request.limit,
|
|
392
|
+
noteworthy: request.limit,
|
|
393
|
+
})
|
|
394
|
+
const scope = groups === undefined ? 'ALL' : groups.join('+')
|
|
395
|
+
const noteworthyText = buildNoteworthyText(digestInput.noteworthy)
|
|
396
|
+
const title = `整体分析:${SCOPE_LABELS[scope] ?? scope}`
|
|
397
|
+
const digest = buildDigest(digestInput.entries, {
|
|
398
|
+
budget: digestBudget,
|
|
399
|
+
title,
|
|
400
|
+
range: digestInput.range,
|
|
401
|
+
...(noteworthyText === '' ? {} : { extra: noteworthyText }),
|
|
402
|
+
})
|
|
403
|
+
const cacheKeyValue = `overview|${scope}|${digestInput.range.from}|${digestInput.range.to}|${fingerprint(digest.text)}`
|
|
404
|
+
const result = await runValidated({
|
|
405
|
+
op: 'overview',
|
|
406
|
+
digestInput,
|
|
407
|
+
digestText: digest.text,
|
|
408
|
+
cacheKeyValue,
|
|
409
|
+
invoke: async (activeGateway, violations) => {
|
|
410
|
+
const prompt = buildOverviewPrompt({
|
|
411
|
+
digest: digest.text,
|
|
412
|
+
scope: SCOPE_LABELS[scope] ?? scope,
|
|
413
|
+
range: digestInput.range,
|
|
414
|
+
indicatorCount: digestInput.entries.length,
|
|
415
|
+
noteworthyCount: digestInput.noteworthy.length,
|
|
416
|
+
violations,
|
|
417
|
+
})
|
|
418
|
+
return activeGateway.summarize({ ...request, ...prompt, range: digestInput.range, entries: digestInput.entries, digest: digest.text })
|
|
419
|
+
},
|
|
420
|
+
fallbackCall: async () => fallback.summarize({ entries: digestInput.entries, range: digestInput.range }),
|
|
421
|
+
})
|
|
422
|
+
return {
|
|
423
|
+
...result,
|
|
424
|
+
scope,
|
|
425
|
+
scopeLabel: SCOPE_LABELS[scope] ?? scope,
|
|
426
|
+
indicators: digestInput.entries.map((entry) => entry.indicator.id),
|
|
427
|
+
noteworthy: digestInput.noteworthy.map((item) => item.indicatorId),
|
|
428
|
+
digestIncluded: digest.included,
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* @returns {{ mode: string, provider?: string, model?: string, cacheMinutes: number }} description.
|
|
434
|
+
*/
|
|
435
|
+
function describe() {
|
|
436
|
+
return { ...(gateway.describe?.() ?? { mode: 'deterministic' }), cacheMinutes }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return { explain, summarize, ask, overviewAnalysis, propose, describe, buildEntries, buildNoteworthyText }
|
|
440
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The health use case: "which sources are working right now?" (docs/06 §5).
|
|
3
|
+
*
|
|
4
|
+
* @module app/health
|
|
5
|
+
*/
|
|
6
|
+
import { listCapabilities } from '../sources/registry.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create the health use case.
|
|
10
|
+
*
|
|
11
|
+
* @param {object} deps - dependencies.
|
|
12
|
+
* @param {object} deps.overview - the overview use case (provides source health).
|
|
13
|
+
* @param {import('../ports/clock.js').Clock} deps.clock - clock.
|
|
14
|
+
* @param {(msg: string, meta?: object) => void} [deps.log] - logger.
|
|
15
|
+
* @param {{ adapters?: () => Array<{ id: string, label: string, capabilities: any[] }> }} [deps.registry] - adapter registry access.
|
|
16
|
+
* @returns {object} health use case.
|
|
17
|
+
*/
|
|
18
|
+
export function createHealthUseCase({ overview, clock, log = () => {}, registry = { adapters: listCapabilities } }) {
|
|
19
|
+
/**
|
|
20
|
+
* Report per-source status.
|
|
21
|
+
*
|
|
22
|
+
* @param {{ probe?: boolean, range?: string, groups?: string[], ids?: string[] }} [request] - request.
|
|
23
|
+
* @returns {Promise<object>} '{ ok, sources, generatedAt, aiMode, degraded }'.
|
|
24
|
+
*/
|
|
25
|
+
async function health(request = {}) {
|
|
26
|
+
let sourceHealth = {}
|
|
27
|
+
let errors = []
|
|
28
|
+
let aiMode = 'deterministic'
|
|
29
|
+
const probing = request.probe === true || (Array.isArray(request.ids) && request.ids.length > 0)
|
|
30
|
+
try {
|
|
31
|
+
// A probe re-fetches every indicator in scope, ignoring the cache, so it
|
|
32
|
+
// reports whether the *upstream* answers right now rather than what is
|
|
33
|
+
// already stored. Narrowing by `ids` keeps a single-source check cheap.
|
|
34
|
+
const result = await overview.overview({
|
|
35
|
+
// A probe asks "can this deployment fetch right now", so it must touch
|
|
36
|
+
// every observable: a 1Y window leaves sources whose only indicator has
|
|
37
|
+
// older observations untested, and they then report a misleading `null`.
|
|
38
|
+
range: request.range ?? (probing ? 'MAX' : '1Y'),
|
|
39
|
+
limit: 1,
|
|
40
|
+
groups: request.groups,
|
|
41
|
+
ids: request.ids,
|
|
42
|
+
force: probing,
|
|
43
|
+
})
|
|
44
|
+
sourceHealth = result.sources
|
|
45
|
+
errors = result.errors
|
|
46
|
+
} catch (error) {
|
|
47
|
+
// Health must never itself fail: a broken data layer is exactly what it
|
|
48
|
+
// is supposed to report.
|
|
49
|
+
log('health overview failed', { message: error?.message ?? String(error) })
|
|
50
|
+
errors = [{ kind: 'internal', detail: String(error?.message ?? error) }]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const sources = registry.adapters().map((adapter) => {
|
|
54
|
+
const status = sourceHealth[adapter.id]
|
|
55
|
+
return {
|
|
56
|
+
adapterId: adapter.id,
|
|
57
|
+
label: adapter.label,
|
|
58
|
+
capabilities: adapter.capabilities,
|
|
59
|
+
available: status === undefined ? null : status.failed === 0,
|
|
60
|
+
ok: status?.ok ?? 0,
|
|
61
|
+
degraded: status?.degraded ?? 0,
|
|
62
|
+
failed: status?.failed ?? 0,
|
|
63
|
+
lastSuccessAt: status?.lastSuccessAt,
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
ok: sources.every((source) => source.failed === 0),
|
|
69
|
+
sources,
|
|
70
|
+
errors,
|
|
71
|
+
aiMode,
|
|
72
|
+
generatedAt: clock.now().toISOString(),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { health }
|
|
77
|
+
}
|