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,689 @@
1
+ /**
2
+ * The HTTP route table (docs/05 §3, docs/07 T7.2).
3
+ *
4
+ * Every handler is a thin projection of a use case: parse, call, serialise. The
5
+ * router itself is a pure factory over the use cases, so tests call it with fake
6
+ * request/response objects and no server.
7
+ *
8
+ * @module host/http/routes
9
+ */
10
+ import { API_PREFIX, errorPayload, intParam, listParam, parseRoute, readJsonBody, sendError, sendJson, startSse } from './respond.js'
11
+ import { violationLines } from '../../app/ai-validate.js'
12
+ import { buildDigest, buildInventory } from '../../core/insight/digest.js'
13
+ import { relatedIndicators } from '../../core/insight/related.js'
14
+
15
+ /**
16
+ * Character budget for one discussion's seeded context.
17
+ *
18
+ * The one-shot panel answers are validated against their digest, so that digest
19
+ * stays small; a discussion is a real session the reader keeps talking to, and a
20
+ * context that omits half the panel is what made a session refuse a question it
21
+ * could have answered.
22
+ */
23
+ const DISCUSSION_BUDGET = 12000
24
+
25
+ /**
26
+ * Translate one panel bar size into the parameter its source actually takes.
27
+ *
28
+ * Eastmoney's kline API speaks `klt` (101/102/103); Sina's speaks `scale`
29
+ * (minutes). A source that declares no support gets nothing, so the panel's
30
+ * period control can never silently return the same chart three times.
31
+ *
32
+ * @param {object} useCases - use cases.
33
+ * @param {string} indicatorId - indicator id.
34
+ * @param {string|null} freq - 'day' | 'week' | 'month' | null.
35
+ * @returns {object|undefined} source-param override.
36
+ */
37
+ function supportsBars(useCases, indicatorId) {
38
+ const indicator = useCases.catalog.all.find((entry) => entry.id === indicatorId)
39
+ return useCases.catalog.barSizes?.(indicator?.source?.adapter) === true
40
+ }
41
+
42
+ /**
43
+ * @param {object} useCases - use cases.
44
+ * @param {string} indicatorId - indicator id.
45
+ * @param {string|null} freq - requested bar size.
46
+ * @returns {object|undefined} source-param override.
47
+ */
48
+ function barParams(useCases, indicatorId, freq) {
49
+ if (freq === null || freq === 'day') return undefined
50
+ const indicator = useCases.catalog.all.find((entry) => entry.id === indicatorId)
51
+ const adapterId = indicator?.source?.adapter
52
+ if (useCases.catalog.barSizes?.(adapterId) !== true) return undefined
53
+ // The neutral size name: an adapter that owns several upstreams (the quote
54
+ // cascade) is the only one that knows which parameter its winner takes.
55
+ return { size: freq }
56
+ }
57
+
58
+ /** Routes the panel calls, with their methods. */
59
+ export const ROUTES = [
60
+ { method: 'GET', path: '/health' },
61
+ { method: 'GET', path: '/settings' },
62
+ { method: 'GET', path: '/overview' },
63
+ { method: 'GET', path: '/series' },
64
+ { method: 'GET', path: '/watchlist' },
65
+ { method: 'POST', path: '/watchlist' },
66
+ { method: 'GET', path: '/catalog/search' },
67
+ { method: 'POST', path: '/ai/explain' },
68
+ { method: 'POST', path: '/ai/summary' },
69
+ { method: 'POST', path: '/ai/overview' },
70
+ { method: 'POST', path: '/ai/ask' },
71
+ { method: 'POST', path: '/ai/propose' },
72
+ { method: 'POST', path: '/discuss' },
73
+ { method: 'GET', path: '/discuss/answer' },
74
+ { method: 'POST', path: '/iterate' },
75
+ ]
76
+
77
+ /**
78
+ * Create the route dispatcher.
79
+ *
80
+ * @param {object} deps - dependencies.
81
+ * @param {object} deps.useCases - '{ overview, seriesView, watchlist, health, ai }'.
82
+ * @param {(msg: string, meta?: object) => void} [deps.log] - logger.
83
+ * @param {(durationMs: number, meta: object) => void} [deps.metrics] - metrics sink.
84
+ * @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => Promise<void>} handler.
85
+ */
86
+ export function createRoutes({ useCases, log = () => {}, metrics = () => {} }) {
87
+ /**
88
+ * Dispatch one request.
89
+ *
90
+ * @param {import('node:http').IncomingMessage} req - request.
91
+ * @param {import('node:http').ServerResponse} res - response.
92
+ * @returns {Promise<void>} completion.
93
+ */
94
+ async function handler(req, res) {
95
+ const started = Date.now()
96
+ const { route, query } = parseRoute(req)
97
+ const method = (req.method ?? 'GET').toUpperCase()
98
+ try {
99
+ switch (route) {
100
+ case 'health':
101
+ return await handleHealth(res, useCases, query)
102
+ case 'settings':
103
+ return handleSettings(res, useCases)
104
+ case 'overview':
105
+ return await handleOverview(req, res, useCases, query)
106
+ case 'series':
107
+ return await handleSeries(res, useCases, query)
108
+ case 'watchlist':
109
+ return method === 'POST'
110
+ ? await handleWatchlistPost(req, res, useCases)
111
+ : await handleWatchlistGet(res, useCases)
112
+ case 'catalog/search':
113
+ return await handleSearch(res, useCases, query)
114
+ case 'ai/explain':
115
+ return await handleAiExplain(req, res, useCases)
116
+ case 'ai/summary':
117
+ return await handleAiSummary(req, res, useCases)
118
+ case 'ai/overview':
119
+ return await handleAiOverview(req, res, useCases)
120
+ case 'ai/ask':
121
+ return await handleAiAsk(req, res, useCases)
122
+ case 'ai/propose':
123
+ return await handleAiPropose(req, res, useCases)
124
+ case 'discuss':
125
+ return await handleDiscuss(req, res, useCases)
126
+ case 'discuss/answer':
127
+ return handleDiscussAnswer(res, useCases, query)
128
+ case 'iterate':
129
+ return await handleIterate(req, res, useCases)
130
+ default:
131
+ return sendError(res, 404, 'not-found', `no route ${JSON.stringify(route)} under ${API_PREFIX}`)
132
+ }
133
+ } catch (error) {
134
+ const payload = errorPayload(error)
135
+ log('route failed', { route, method, kind: payload.kind, message: error?.message ?? String(error) })
136
+ if (!res.headersSent) sendError(res, payload.status, payload.kind, payload.detail, { retryable: payload.retryable })
137
+ else res.end()
138
+ } finally {
139
+ metrics(Date.now() - started, { route, method })
140
+ }
141
+ }
142
+
143
+ return handler
144
+ }
145
+
146
+ /**
147
+ * '/health' — per-source availability and the resolved mode.
148
+ *
149
+ * @param {import('node:http').ServerResponse} res - response.
150
+ * @param {object} useCases - use cases.
151
+ * @param {URLSearchParams} query - query.
152
+ * @returns {Promise<void>} completion.
153
+ */
154
+ async function handleHealth(res, useCases, query) {
155
+ // `ids` narrows a probe to the indicators behind one adapter, which the
156
+ // settings screen uses for a per-source connection test.
157
+ // `listParam` answers `undefined` when the key is absent, and this route is
158
+ // also called by tests with no query object at all.
159
+ const ids = query?.get === undefined ? undefined : listParam(query, 'ids')
160
+ const result = await useCases.health({
161
+ probe: query?.get?.('probe') === '1',
162
+ range: query?.get?.('range') ?? undefined,
163
+ ids: ids === undefined || ids.length === 0 ? undefined : ids,
164
+ })
165
+ sendJson(res, 200, result)
166
+ }
167
+
168
+ /**
169
+ * '/settings' — the mount's effective configuration, for the settings screen.
170
+ *
171
+ * @param {import('node:http').ServerResponse} res - response.
172
+ * @param {object} useCases - use cases.
173
+ * @returns {void} completion.
174
+ */
175
+ function handleSettings(res, useCases) {
176
+ sendJson(res, 200, useCases.settings())
177
+ }
178
+
179
+ /**
180
+ * '/overview' — cards, noteworthy list and source health.
181
+ *
182
+ * @param {import('node:http').IncomingMessage} req - request.
183
+ * @param {import('node:http').ServerResponse} res - response.
184
+ * @param {object} useCases - use cases.
185
+ * @param {URLSearchParams} query - query.
186
+ * @returns {Promise<void>} completion.
187
+ */
188
+ async function handleOverview(req, res, useCases, query) {
189
+ const range = query.get('range') ?? '1Y'
190
+ const groups = listParam(query, 'groups')
191
+ const ids = listParam(query, 'ids')
192
+ const request = {
193
+ range,
194
+ groups,
195
+ ids,
196
+ limit: intParam(query, 'limit', 5, { min: 1, max: 20 }),
197
+ force: query.get('force') === '1',
198
+ custom: query.has('from') || query.has('to')
199
+ ? { from: query.get('from'), to: query.get('to') }
200
+ : undefined,
201
+ }
202
+ if (request.custom !== undefined && (request.custom.from === null || request.custom.to === null)) {
203
+ return sendError(res, 400, 'bad-request', 'CUSTOM range needs both `from` and `to`')
204
+ }
205
+ const result = await useCases.overview(request)
206
+ sendJson(res, 200, result)
207
+ }
208
+
209
+ /**
210
+ * '/series' — one indicator's detail view.
211
+ *
212
+ * @param {import('node:http').ServerResponse} res - response.
213
+ * @param {object} useCases - use cases.
214
+ * @param {URLSearchParams} query - query.
215
+ * @returns {Promise<void>} completion.
216
+ */
217
+ async function handleSeries(res, useCases, query) {
218
+ const indicatorId = query.get('indicator')
219
+ if (indicatorId === null || indicatorId.trim() === '') {
220
+ return sendError(res, 400, 'bad-request', 'the `indicator` query parameter is required')
221
+ }
222
+ // `freq` selects the bar size for sources that publish OHLC (day/week/month);
223
+ // it is passed through as a source-param override, so it is part of the cache
224
+ // key rather than a presentation-only flag.
225
+ const freq = query.get('freq')
226
+ const view = await useCases.seriesView({
227
+ indicatorId,
228
+ range: query.get('range') ?? '1Y',
229
+ transform: query.get('transform') ?? undefined,
230
+ compareWith: query.get('compareWith') ?? undefined,
231
+ params: barParams(useCases, indicatorId, freq),
232
+ barSizes: supportsBars(useCases, indicatorId),
233
+ })
234
+ if (view?.error !== undefined) {
235
+ const status = view.error.code === 'unknown-indicator' ? 404 : 409
236
+ return sendError(res, status, view.error.code, view.error.detail)
237
+ }
238
+ sendJson(res, 200, view)
239
+ }
240
+
241
+ /**
242
+ * 'GET /watchlist'.
243
+ *
244
+ * @param {import('node:http').ServerResponse} res - response.
245
+ * @param {object} useCases - use cases.
246
+ * @returns {Promise<void>} completion.
247
+ */
248
+ async function handleWatchlistGet(res, useCases) {
249
+ sendJson(res, 200, { items: await useCases.watchlist.list() })
250
+ }
251
+
252
+ /**
253
+ * 'POST /watchlist' — add / remove / update / reorder.
254
+ *
255
+ * @param {import('node:http').IncomingMessage} req - request.
256
+ * @param {import('node:http').ServerResponse} res - response.
257
+ * @param {object} useCases - use cases.
258
+ * @returns {Promise<void>} completion.
259
+ */
260
+ async function handleWatchlistPost(req, res, useCases) {
261
+ const body = await readJsonBody(req)
262
+ const action = body.action
263
+ if (action === 'add') {
264
+ const result = await useCases.watchlist.add(body.item ?? {}, { createdBy: body.createdBy ?? 'user' })
265
+ if (result.error !== undefined) return sendError(res, 400, result.error.code, result.error.detail)
266
+ return sendJson(res, result.created ? 201 : 200, { status: result.created ? 'created' : 'exists', item: result.item, conflict: result.conflict === true, items: await useCases.watchlist.list() })
267
+ }
268
+ if (action === 'remove') {
269
+ const result = await useCases.watchlist.remove(body.indicatorId)
270
+ if (result.status === 'notFound') return sendError(res, 404, 'not-found', `${body.indicatorId} is not in the watchlist`)
271
+ return sendJson(res, 200, { status: 'removed', items: await useCases.watchlist.list() })
272
+ }
273
+ if (action === 'update') {
274
+ const result = await useCases.watchlist.update(body.indicatorId, body.patch ?? {})
275
+ if (result.status === 'notFound') return sendError(res, 404, 'not-found', `${body.indicatorId} is not in the watchlist`)
276
+ return sendJson(res, 200, { status: 'updated', item: result.item, items: await useCases.watchlist.list() })
277
+ }
278
+ if (action === 'reorder') {
279
+ if (!Array.isArray(body.ids)) return sendError(res, 400, 'bad-request', '`ids` must be an array')
280
+ const result = await useCases.watchlist.reorder(body.ids)
281
+ return sendJson(res, 200, { status: 'reordered', items: result.items, missing: result.missing })
282
+ }
283
+ return sendError(res, 400, 'bad-request', `unknown action ${JSON.stringify(action)}`)
284
+ }
285
+
286
+ /**
287
+ * 'GET /catalog/search'.
288
+ *
289
+ * @param {import('node:http').ServerResponse} res - response.
290
+ * @param {object} useCases - use cases.
291
+ * @param {URLSearchParams} query - query.
292
+ * @returns {Promise<void>} completion.
293
+ */
294
+ async function handleSearch(res, useCases, query) {
295
+ const result = await useCases.search(query.get('q') ?? '', {
296
+ groups: listParam(query, 'groups'),
297
+ limit: intParam(query, 'limit', 12, { min: 1, max: 50 }),
298
+ })
299
+ sendJson(res, 200, result)
300
+ }
301
+
302
+ /**
303
+ * 'POST /ai/explain' and friends: run the AI use case and stream the answer.
304
+ *
305
+ * @param {import('node:http').IncomingMessage} req - request.
306
+ * @param {import('node:http').ServerResponse} res - response.
307
+ * @param {object} useCases - use cases.
308
+ * @returns {Promise<void>} completion.
309
+ */
310
+ async function handleAiExplain(req, res, useCases) {
311
+ const body = await readJsonBody(req)
312
+ if (typeof body.indicator !== 'string') return sendError(res, 400, 'bad-request', '`indicator` is required')
313
+ const request = { indicatorId: body.indicator, range: body.range ?? '1Y', stream: body.stream === true }
314
+ await runAi(res, useCases, () => useCases.ai.explain(request), request.stream)
315
+ }
316
+
317
+ /**
318
+ * @param {import('node:http').IncomingMessage} req - request.
319
+ * @param {import('node:http').ServerResponse} res - response.
320
+ * @param {object} useCases - use cases.
321
+ * @returns {Promise<void>} completion.
322
+ */
323
+ async function handleAiSummary(req, res, useCases) {
324
+ const body = await readJsonBody(req)
325
+ const request = { range: body.range ?? '1Y', indicators: Array.isArray(body.indicators) ? body.indicators : undefined, stream: body.stream === true }
326
+ await runAi(res, useCases, () => useCases.ai.summarize(request), request.stream)
327
+ }
328
+
329
+ /**
330
+ * 'POST /ai/overview' — analyse the panel as a whole, or one group of it.
331
+ *
332
+ * The digest behind this carries every observable in scope *and* the triggered
333
+ * attention rules, so the answer is about structure ("what is going on") rather
334
+ * than about one series.
335
+ *
336
+ * @param {import('node:http').IncomingMessage} req - request.
337
+ * @param {import('node:http').ServerResponse} res - response.
338
+ * @param {object} useCases - use cases.
339
+ * @returns {Promise<void>} completion.
340
+ */
341
+ async function handleAiOverview(req, res, useCases) {
342
+ const body = await readJsonBody(req)
343
+ // A JSON body carries the list natively; only a comma-joined string needs
344
+ // splitting, which is the form a query string would use.
345
+ const groups = Array.isArray(body.groups)
346
+ ? body.groups.filter((entry) => typeof entry === 'string' && entry !== '')
347
+ : typeof body.groups === 'string' && body.groups !== ''
348
+ ? body.groups.split(',').map((entry) => entry.trim()).filter(Boolean)
349
+ : []
350
+ const request = {
351
+ range: body.range ?? '1Y',
352
+ groups: groups.length === 0 ? undefined : groups,
353
+ limit: Number.isInteger(body.limit) ? body.limit : undefined,
354
+ stream: body.stream === true,
355
+ }
356
+ await runAi(res, useCases, () => useCases.ai.overviewAnalysis(request), request.stream)
357
+ }
358
+
359
+ /**
360
+ * @param {import('node:http').IncomingMessage} req - request.
361
+ * @param {import('node:http').ServerResponse} res - response.
362
+ * @param {object} useCases - use cases.
363
+ * @returns {Promise<void>} completion.
364
+ */
365
+ async function handleAiAsk(req, res, useCases) {
366
+ const body = await readJsonBody(req)
367
+ if (typeof body.question !== 'string' || body.question.trim() === '') {
368
+ return sendError(res, 400, 'bad-request', '`question` is required')
369
+ }
370
+ const request = { question: body.question, range: body.range ?? '1Y', stream: body.stream === true }
371
+ await runAi(res, useCases, () => useCases.ai.ask(request), request.stream)
372
+ }
373
+
374
+ /**
375
+ * @param {import('node:http').IncomingMessage} req - request.
376
+ * @param {import('node:http').ServerResponse} res - response.
377
+ * @param {object} useCases - use cases.
378
+ * @returns {Promise<void>} completion.
379
+ */
380
+ async function handleAiPropose(req, res, useCases) {
381
+ const body = await readJsonBody(req)
382
+ if (typeof body.text !== 'string' || body.text.trim() === '') {
383
+ return sendError(res, 400, 'bad-request', '`text` is required')
384
+ }
385
+ const result = await useCases.ai.propose({ text: body.text, verify: body.verify !== false })
386
+ sendJson(res, 200, result)
387
+ }
388
+
389
+ /**
390
+ * `POST /iterate` — open a session in the plugin workspace to extend the plugin.
391
+ *
392
+ * "不能添加,因为没有可用数据源" is the honest answer from a read-only panel, and a
393
+ * dead end for the reader. The plugin cannot register a source at runtime, but it
394
+ * can hand the request to a session that can: this opens one in the plugin root
395
+ * with the request, the per-indicator reasons and the extension contract attached.
396
+ *
397
+ * @param {import('node:http').IncomingMessage} req - request.
398
+ * @param {import('node:http').ServerResponse} res - response.
399
+ * @param {object} useCases - use cases.
400
+ * @returns {Promise<void>} completion.
401
+ */
402
+ async function handleIterate(req, res, useCases) {
403
+ const body = await readJsonBody(req)
404
+ const request = typeof body.request === 'string' ? body.request.trim() : ''
405
+ const reasons = Array.isArray(body.reasons) ? body.reasons.filter((entry) => typeof entry === 'string' && entry !== '') : []
406
+ if (request === '' && reasons.length === 0) {
407
+ return sendError(res, 400, 'bad-request', '`request` or `reasons` is required')
408
+ }
409
+ const result = await useCases.discussion.discuss({
410
+ topicKey: 'iterate',
411
+ label: { zh: '接入新数据源' },
412
+ cwd: useCases.catalog.workspace,
413
+ digest: buildIterationBriefing({ request, reasons, question: typeof body.question === 'string' ? body.question : undefined }),
414
+ // The session has to *do* something: a context-only session queues the
415
+ // briefing without waking the agent, so the reader would land in an empty
416
+ // session that never starts. This opening turn makes the session act.
417
+ question: typeof body.question === 'string' && body.question.trim() !== ''
418
+ ? body.question.trim()
419
+ : '请先核对我给的上游接口是否可用(真实字段名与单位),再给出接入计划;需要执行命令时直接执行。',
420
+ mode: 'new',
421
+ wait: false,
422
+ // The iteration session has to *edit this plugin*, so it needs the reader's
423
+ // composition (file and shell tools); a route cannot see the asking agent, so
424
+ // the browser names the session.
425
+ sessionId: typeof body.sessionId === 'string' && body.sessionId !== '' ? body.sessionId : undefined,
426
+ })
427
+ if (result.ok !== true) {
428
+ const kind = result.error?.kind ?? 'internal'
429
+ const status = kind === 'unavailable' ? 503 : kind === 'bad-request' ? 400 : 502
430
+ return sendError(res, status, kind, result.error?.detail ?? 'could not open an iteration session', {
431
+ retryable: result.error?.retryable === true,
432
+ })
433
+ }
434
+ sendJson(res, 200, { sessionId: result.sessionId, pending: result.pending, provider: result.provider, model: result.model })
435
+ }
436
+
437
+ /**
438
+ * The briefing an iteration session starts from.
439
+ *
440
+ * It states the contract this plugin actually enforces — one adapter file plus one
441
+ * registry line for a source, one catalog entry plus a recorded fixture for an
442
+ * indicator — because that constraint is what keeps the change reviewable.
443
+ *
444
+ * @param {{ request: string, reasons: string[], question?: string }} input - input.
445
+ * @returns {string} briefing text.
446
+ */
447
+ function buildIterationBriefing({ request, reasons, question }) {
448
+ return [
449
+ '[数据雷达] 这条消息来自插件面板,不是用户输入。用户想给面板接入新数据源。',
450
+ '',
451
+ '插件工作区:本会话的 cwd(就是插件源码根目录)',
452
+ `用户原始说法:${request === '' ? '(只提供了失败原因)' : request}`,
453
+ ...(reasons.length === 0 ? [] : ['', '面板无法满足的原因(逐条):', ...reasons.map((reason) => `- ${reason}`)]),
454
+ ...(question === undefined || question === '' ? [] : ['', `用户补充:${question}`]),
455
+ '',
456
+ '面板当前只读取已登记的数据源,运行时无法新增。请在这个工作区里迭代插件代码来接入,路径是:',
457
+ '1. 先验证上游接口真的可用(记录 URL、真实字段名、单位、时间口径),不要猜接口;',
458
+ '2. 新增 `src/sources/<id>.js`:导出 `id`/`label`/`capabilities`/`sourceRef`/`fetchSeries`,并在 `src/sources/registry.js` 加一行;',
459
+ '3. 在 `src/core/indicators/catalog.js` 增加目录项(`unit`/`freq`/`display.transform` 必须与上游口径一致);',
460
+ '4. 用 `RUN_NET=1 node scripts/record-fixtures.mjs --only <adapter>` 录制 fixture,再跑 `node --test "test/**/*.test.js"`;',
461
+ '5. 需要我执行命令时直接说;插件改动要重启 profile 进程才生效。',
462
+ ].join('\n')
463
+ }
464
+
465
+ /**
466
+ * `GET /discuss/answer?session=…` — collect the answer the session produced.
467
+ *
468
+ * The panel opens a discussion with `wait: false` so the GUI can switch to the
469
+ * new session immediately; this is how it comes back for the text a few seconds
470
+ * later instead of leaving its own block empty while the answer streams somewhere
471
+ * the reader has to go and find.
472
+ *
473
+ * @param {import('node:http').ServerResponse} res - response.
474
+ * @param {object} useCases - use cases.
475
+ * @param {URLSearchParams} query - query.
476
+ * @returns {void} completion.
477
+ */
478
+ function handleDiscussAnswer(res, useCases, query) {
479
+ const sessionId = query.get('session')
480
+ if (sessionId === null || sessionId === '') {
481
+ return sendError(res, 400, 'bad-request', '`session` is required')
482
+ }
483
+ const state = useCases.discussion.answer(sessionId)
484
+ if (state.status === 'unknown') {
485
+ // Not ours: the plugin only answers for sessions it opened in this process.
486
+ return sendError(res, 404, 'unknown-session', `no discussion for session ${sessionId}`)
487
+ }
488
+ sendJson(res, 200, state)
489
+ }
490
+
491
+ /**
492
+ * `POST /discuss` — open a real session about one indicator and ask the question.
493
+ *
494
+ * The panel uses this when the user wants a conversation rather than a one-shot
495
+ * answer: the reply comes back as text, and the client switches the GUI to the
496
+ * returned session id so the discussion continues with the full composer.
497
+ *
498
+ * @param {import('node:http').IncomingMessage} req - request.
499
+ * @param {import('node:http').ServerResponse} res - response.
500
+ * @param {object} useCases - use cases.
501
+ * @returns {Promise<void>} completion.
502
+ */
503
+ async function handleDiscuss(req, res, useCases) {
504
+ const body = await readJsonBody(req)
505
+ const wantGroup = typeof body.group === 'string' && body.group.trim() !== ''
506
+ const wantIndicator = typeof body.indicator === 'string' && body.indicator.trim() !== ''
507
+ if (!wantGroup && !wantIndicator) {
508
+ return sendError(res, 400, 'bad-request', '`indicator` or `group` is required')
509
+ }
510
+
511
+ // Three subjects share one session mechanism, and the panel reaches all three
512
+ // from a button: one indicator's card, one "worth watching" item, and the whole
513
+ // panel (optionally narrowed to a group). Only the seeded context differs.
514
+ //
515
+ // Every subject also gets a panel-wide *inventory*: one line per indicator the
516
+ // panel holds. Without it a session seeded with a single block announced that
517
+ // the panel had no 核心 CPI and no CPI 环比 — both of which it has — and refused
518
+ // a question it could have answered.
519
+ const panelInput = await useCases.ai.buildEntries({ range: body.range ?? '1Y', detail: false, noteworthy: 12 })
520
+ let subject
521
+ if (wantGroup) {
522
+ const groups = listParam({ get: () => body.group }, 'group')
523
+ const digestInput = await useCases.ai.buildEntries({ range: body.range ?? '1Y', groups, noteworthy: body.limit })
524
+ const noteworthyIds = Array.isArray(body.noteworthy) ? body.noteworthy.filter((id) => typeof id === 'string') : []
525
+ // A quick question from a notable row still needs the row's numbers, so the
526
+ // caller may narrow the seeded context to the indicators it is about.
527
+ const focus = noteworthyIds.length > 0
528
+ ? digestInput.entries.filter((entry) => noteworthyIds.includes(entry.indicator.id))
529
+ : digestInput.entries
530
+ const scoped = focus.length > 0 ? focus : digestInput.entries
531
+ const detailed = buildDigest(scoped, {
532
+ budget: DISCUSSION_BUDGET,
533
+ title: noteworthyIds.length === 1 ? `讨论上下文:${labelOf(useCases, noteworthyIds[0])}` : `讨论上下文:${groups.length === 0 ? '全部指标' : groups.join('+')}`,
534
+ range: digestInput.range,
535
+ }).text
536
+ // The noteworthy block is the *reason* the reader is looking at the panel, so
537
+ // it travels whole rather than only for the indicator being discussed.
538
+ const noteworthyText = useCases.ai.buildNoteworthyText(
539
+ noteworthyIds.length === 1
540
+ ? (panelInput.noteworthy ?? []).filter((item) => noteworthyIds.includes(item.indicatorId))
541
+ : panelInput.noteworthy,
542
+ )
543
+ const scopeLabel = groups.length === 0 ? '全部指标' : groups.join('+')
544
+ subject = {
545
+ kind: noteworthyIds.length === 1 ? 'noteworthy' : 'group',
546
+ topicKey: noteworthyIds.length === 1 ? noteworthyIds[0] : `group:${groups.length === 0 ? 'ALL' : groups.join('+')}`,
547
+ label: { zh: noteworthyIds.length === 1 ? labelOf(useCases, noteworthyIds[0]) : `${scopeLabel}(整体)` },
548
+ digest: [detailed, noteworthyText === '' ? '' : noteworthyText].filter((part) => part !== '').join('\n'),
549
+ inventory: buildInventory(panelInput.entries, { skip: scoped.map((entry) => entry.indicator.id) }),
550
+ range: digestInput.range,
551
+ }
552
+ } else {
553
+ const indicatorId = body.indicator
554
+ const indicator = useCases.catalog.all.find((entry) => entry.id === indicatorId)
555
+ if (indicator === undefined) {
556
+ return sendError(res, 404, 'unknown-indicator', `unknown indicator ${indicatorId}`)
557
+ }
558
+ // The session is seeded with what the panel shows for this indicator *and* the
559
+ // indicators that share its subject: a discussion about CPI that cannot see
560
+ // 核心 CPI or the CPI index is a discussion that answers "data insufficient".
561
+ const peers = relatedIndicators(useCases.catalog.all, indicatorId)
562
+ const digestInput = await useCases.ai.buildEntries({
563
+ range: body.range ?? '1Y',
564
+ indicators: [indicatorId, ...peers.map((entry) => entry.id)],
565
+ })
566
+ const detailed = buildDigest(digestInput.entries, {
567
+ budget: DISCUSSION_BUDGET,
568
+ title: `讨论上下文:${indicator.label.zh}${peers.length === 0 ? '' : ` 及相关指标(${peers.map((entry) => entry.label.zh).join('、')})`}`,
569
+ range: digestInput.range,
570
+ }).text
571
+ const noteworthyText = useCases.ai.buildNoteworthyText(panelInput.noteworthy)
572
+ subject = {
573
+ kind: 'indicator',
574
+ topicKey: indicatorId,
575
+ indicatorId,
576
+ label: indicator.label,
577
+ digest: [detailed, noteworthyText === '' ? '' : noteworthyText].filter((part) => part !== '').join('\n'),
578
+ inventory: buildInventory(panelInput.entries, { skip: digestInput.entries.map((entry) => entry.indicator.id) }),
579
+ range: digestInput.range,
580
+ }
581
+ }
582
+
583
+ const result = await useCases.discussion.discuss({
584
+ ...subject,
585
+ question: typeof body.question === 'string' ? body.question : undefined,
586
+ // The panel's buttons say 「讨论」, not 「自动提问」: when the reader typed
587
+ // nothing the session still has to run a turn, so it asks the subject's
588
+ // opening question and reports which one. `ask: false` seeds a session and
589
+ // leaves the question to whoever opened it.
590
+ ask: body.ask !== false,
591
+ mode: body.mode === 'topic' ? 'topic' : 'new',
592
+ // The panel opens the session immediately and lets it stream there, so it
593
+ // asks for `wait: false`; a non-GUI caller keeps the synchronous answer.
594
+ wait: body.wait !== false,
595
+ // Which session asked. A web route carries no agent, so the browser tells us:
596
+ // the discussion then inherits the reader's workspace and composition instead
597
+ // of being created into an empty global layer.
598
+ sessionId: typeof body.sessionId === 'string' && body.sessionId !== '' ? body.sessionId : undefined,
599
+ })
600
+ if (result.ok !== true) {
601
+ const kind = result.error?.kind ?? 'internal'
602
+ const status = kind === 'unavailable' ? 503 : kind === 'bad-request' ? 400 : 502
603
+ // A created session with a failed first turn still has to reach the GUI: an
604
+ // error response with no session id leaves the reader with a dead button and
605
+ // a session they cannot find.
606
+ return sendError(
607
+ res,
608
+ status,
609
+ kind,
610
+ result.error?.detail ?? 'discussion failed',
611
+ { retryable: result.error?.retryable === true },
612
+ {
613
+ ...(result.sessionId === undefined ? {} : { sessionId: result.sessionId }),
614
+ ...(result.question === undefined ? {} : { question: result.question, autoQuestion: result.autoQuestion === true }),
615
+ },
616
+ )
617
+ }
618
+ sendJson(res, 200, {
619
+ sessionId: result.sessionId,
620
+ mode: result.mode,
621
+ reply: result.reply,
622
+ pending: result.pending,
623
+ question: result.question,
624
+ autoQuestion: result.autoQuestion,
625
+ provider: result.provider,
626
+ model: result.model,
627
+ topicKey: subject.topicKey,
628
+ })
629
+ }
630
+
631
+ /**
632
+ * Human label for one indicator id, for a discussion's session title.
633
+ *
634
+ * @param {object} useCases - use cases.
635
+ * @param {string} indicatorId - indicator id.
636
+ * @returns {string} label.
637
+ */
638
+ function labelOf(useCases, indicatorId) {
639
+ return useCases.catalog.all.find((entry) => entry.id === indicatorId)?.label?.zh ?? indicatorId
640
+ }
641
+
642
+ /**
643
+ * Run one AI operation, streaming it when asked (docs/06 §1 'AiChunk').
644
+ *
645
+ * A streaming response always ends with a 'done' event carrying the validated
646
+ * 'AiResult', so the client can render the citations even after text has already
647
+ * been painted.
648
+ *
649
+ * @param {import('node:http').ServerResponse} res - response.
650
+ * @param {object} useCases - use cases.
651
+ * @param {() => Promise<object>} run - operation.
652
+ * @param {boolean} stream - whether to stream.
653
+ * @returns {Promise<void>} completion.
654
+ */
655
+ async function runAi(res, useCases, run, stream) {
656
+ if (stream !== true) {
657
+ const result = await run()
658
+ return sendJson(res, 200, result)
659
+ }
660
+ const write = startSse(res)
661
+ let result
662
+ try {
663
+ result = await run()
664
+ } catch (error) {
665
+ const payload = errorPayload(error)
666
+ write('error', { kind: payload.kind, detail: payload.detail })
667
+ res.end()
668
+ return
669
+ }
670
+ // Chunk the markdown so the client renders progressively; the digest is small
671
+ // enough that a coarse chunking keeps the code trivial and the protocol honest.
672
+ const text = String(result.markdown ?? '')
673
+ const size = 400
674
+ for (let i = 0; i < text.length; i += size) {
675
+ write('text', { text: text.slice(i, i + size) })
676
+ }
677
+ write('done', {
678
+ result: {
679
+ markdown: result.markdown,
680
+ usedPoints: result.usedPoints ?? [],
681
+ insufficient: result.insufficient,
682
+ mode: result.mode,
683
+ cached: result.cached === true,
684
+ fingerprint: result.fingerprint,
685
+ violations: violationLines(result.violations ?? []),
686
+ },
687
+ })
688
+ res.end()
689
+ }