dsh-plugin-show-me-data 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/LICENSE +27 -0
  2. package/README.md +96 -0
  3. package/cordis.patch.yml +40 -0
  4. package/docs/01-product-effect.md +178 -0
  5. package/docs/02-architecture.md +275 -0
  6. package/docs/03-data-contracts.md +291 -0
  7. package/docs/04-sources.md +342 -0
  8. package/docs/05-ui-spec.md +167 -0
  9. package/docs/06-ai-layer.md +194 -0
  10. package/docs/07-implementation-plan.md +399 -0
  11. package/docs/08-test-plan.md +133 -0
  12. package/docs/09-packaging-install.md +249 -0
  13. package/docs/10-kickoff-prompt.md +94 -0
  14. package/docs/11-decisions.md +203 -0
  15. package/docs/12-runtime-verified.md +115 -0
  16. package/docs/13-acceptance.md +153 -0
  17. package/docs/14-progress.md +150 -0
  18. package/docs/15-publish.md +185 -0
  19. package/lib/app/ai-deterministic.js +327 -0
  20. package/lib/app/ai-validate.js +284 -0
  21. package/lib/app/ai.js +440 -0
  22. package/lib/app/health.js +77 -0
  23. package/lib/app/overview.js +349 -0
  24. package/lib/app/propose-indicator.js +122 -0
  25. package/lib/app/refresh.js +251 -0
  26. package/lib/app/series-view.js +195 -0
  27. package/lib/app/watchlist.js +102 -0
  28. package/lib/client.js +4322 -0
  29. package/lib/core/ai/prompts.js +213 -0
  30. package/lib/core/chart/axis.js +133 -0
  31. package/lib/core/chart/bar.js +58 -0
  32. package/lib/core/chart/candle.js +216 -0
  33. package/lib/core/chart/line.js +186 -0
  34. package/lib/core/chart/scale.js +132 -0
  35. package/lib/core/format.js +143 -0
  36. package/lib/core/indicators/catalog.js +1011 -0
  37. package/lib/core/indicators/resolve.js +196 -0
  38. package/lib/core/insight/digest.js +250 -0
  39. package/lib/core/insight/rank.js +115 -0
  40. package/lib/core/insight/related.js +90 -0
  41. package/lib/core/insight/rules.js +417 -0
  42. package/lib/core/stats/derive.js +123 -0
  43. package/lib/core/stats/series.js +465 -0
  44. package/lib/core/time/range.js +242 -0
  45. package/lib/core/types.js +478 -0
  46. package/lib/host/ai/discussion.js +559 -0
  47. package/lib/host/ai/dsh-llm-gateway.js +333 -0
  48. package/lib/host/config.js +194 -0
  49. package/lib/host/http/respond.js +165 -0
  50. package/lib/host/http/routes.js +689 -0
  51. package/lib/host/index.js +293 -0
  52. package/lib/host/infra/fs-repos.js +179 -0
  53. package/lib/host/infra/memory-fallback.js +64 -0
  54. package/lib/host/tools/define-tool.js +295 -0
  55. package/lib/host/tools/register.js +431 -0
  56. package/lib/host.js +7 -0
  57. package/lib/ports/clock.js +57 -0
  58. package/lib/ports/snapshot-repo.js +48 -0
  59. package/lib/sources/eastmoney-macro.js +197 -0
  60. package/lib/sources/eastmoney-quote.js +201 -0
  61. package/lib/sources/ecb.js +179 -0
  62. package/lib/sources/fred.js +207 -0
  63. package/lib/sources/http.js +136 -0
  64. package/lib/sources/ohlc.js +36 -0
  65. package/lib/sources/quote-cascade.js +177 -0
  66. package/lib/sources/registry.js +153 -0
  67. package/lib/sources/sina-cn.js +197 -0
  68. package/lib/sources/sina-us.js +187 -0
  69. package/lib/sources/tencent.js +158 -0
  70. package/lib/sources/us-treasury-rates.js +275 -0
  71. package/lib/sources/us-treasury.js +196 -0
  72. package/lib/sources/worldbank.js +170 -0
  73. package/package.json +69 -0
  74. package/src/app/ai-deterministic.js +327 -0
  75. package/src/app/ai-validate.js +284 -0
  76. package/src/app/ai.js +440 -0
  77. package/src/app/health.js +77 -0
  78. package/src/app/overview.js +349 -0
  79. package/src/app/propose-indicator.js +122 -0
  80. package/src/app/refresh.js +251 -0
  81. package/src/app/series-view.js +195 -0
  82. package/src/app/watchlist.js +102 -0
  83. package/src/client/api.js +323 -0
  84. package/src/client/components.js +1877 -0
  85. package/src/client/copy.js +368 -0
  86. package/src/client/index.js +169 -0
  87. package/src/client/store.js +219 -0
  88. package/src/core/ai/prompts.js +213 -0
  89. package/src/core/chart/axis.js +133 -0
  90. package/src/core/chart/bar.js +58 -0
  91. package/src/core/chart/candle.js +216 -0
  92. package/src/core/chart/line.js +186 -0
  93. package/src/core/chart/scale.js +132 -0
  94. package/src/core/format.js +143 -0
  95. package/src/core/indicators/catalog.js +1011 -0
  96. package/src/core/indicators/resolve.js +196 -0
  97. package/src/core/insight/digest.js +250 -0
  98. package/src/core/insight/rank.js +115 -0
  99. package/src/core/insight/related.js +90 -0
  100. package/src/core/insight/rules.js +417 -0
  101. package/src/core/stats/derive.js +123 -0
  102. package/src/core/stats/series.js +465 -0
  103. package/src/core/time/range.js +242 -0
  104. package/src/core/types.js +478 -0
  105. package/src/host/ai/discussion.js +559 -0
  106. package/src/host/ai/dsh-llm-gateway.js +333 -0
  107. package/src/host/config.js +194 -0
  108. package/src/host/http/respond.js +165 -0
  109. package/src/host/http/routes.js +689 -0
  110. package/src/host/index.js +293 -0
  111. package/src/host/infra/fs-repos.js +179 -0
  112. package/src/host/infra/memory-fallback.js +64 -0
  113. package/src/host/tools/define-tool.js +295 -0
  114. package/src/host/tools/register.js +431 -0
  115. package/src/ports/clock.js +57 -0
  116. package/src/ports/snapshot-repo.js +48 -0
  117. package/src/sources/eastmoney-macro.js +197 -0
  118. package/src/sources/eastmoney-quote.js +201 -0
  119. package/src/sources/ecb.js +179 -0
  120. package/src/sources/fred.js +207 -0
  121. package/src/sources/http.js +136 -0
  122. package/src/sources/ohlc.js +36 -0
  123. package/src/sources/quote-cascade.js +177 -0
  124. package/src/sources/registry.js +153 -0
  125. package/src/sources/sina-cn.js +197 -0
  126. package/src/sources/sina-us.js +187 -0
  127. package/src/sources/tencent.js +158 -0
  128. package/src/sources/us-treasury-rates.js +275 -0
  129. package/src/sources/us-treasury.js +196 -0
  130. package/src/sources/worldbank.js +170 -0
package/lib/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
+ }