dsh-plugin-show-me-data 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +27 -0
- package/README.md +96 -0
- package/cordis.patch.yml +40 -0
- package/docs/01-product-effect.md +178 -0
- package/docs/02-architecture.md +275 -0
- package/docs/03-data-contracts.md +291 -0
- package/docs/04-sources.md +342 -0
- package/docs/05-ui-spec.md +167 -0
- package/docs/06-ai-layer.md +194 -0
- package/docs/07-implementation-plan.md +399 -0
- package/docs/08-test-plan.md +133 -0
- package/docs/09-packaging-install.md +249 -0
- package/docs/10-kickoff-prompt.md +94 -0
- package/docs/11-decisions.md +203 -0
- package/docs/12-runtime-verified.md +115 -0
- package/docs/13-acceptance.md +153 -0
- package/docs/14-progress.md +150 -0
- package/docs/15-publish.md +185 -0
- package/lib/app/ai-deterministic.js +327 -0
- package/lib/app/ai-validate.js +284 -0
- package/lib/app/ai.js +440 -0
- package/lib/app/health.js +77 -0
- package/lib/app/overview.js +349 -0
- package/lib/app/propose-indicator.js +122 -0
- package/lib/app/refresh.js +251 -0
- package/lib/app/series-view.js +195 -0
- package/lib/app/watchlist.js +102 -0
- package/lib/client.js +4322 -0
- package/lib/core/ai/prompts.js +213 -0
- package/lib/core/chart/axis.js +133 -0
- package/lib/core/chart/bar.js +58 -0
- package/lib/core/chart/candle.js +216 -0
- package/lib/core/chart/line.js +186 -0
- package/lib/core/chart/scale.js +132 -0
- package/lib/core/format.js +143 -0
- package/lib/core/indicators/catalog.js +1011 -0
- package/lib/core/indicators/resolve.js +196 -0
- package/lib/core/insight/digest.js +250 -0
- package/lib/core/insight/rank.js +115 -0
- package/lib/core/insight/related.js +90 -0
- package/lib/core/insight/rules.js +417 -0
- package/lib/core/stats/derive.js +123 -0
- package/lib/core/stats/series.js +465 -0
- package/lib/core/time/range.js +242 -0
- package/lib/core/types.js +478 -0
- package/lib/host/ai/discussion.js +559 -0
- package/lib/host/ai/dsh-llm-gateway.js +333 -0
- package/lib/host/config.js +194 -0
- package/lib/host/http/respond.js +165 -0
- package/lib/host/http/routes.js +689 -0
- package/lib/host/index.js +293 -0
- package/lib/host/infra/fs-repos.js +179 -0
- package/lib/host/infra/memory-fallback.js +64 -0
- package/lib/host/tools/define-tool.js +295 -0
- package/lib/host/tools/register.js +431 -0
- package/lib/host.js +7 -0
- package/lib/ports/clock.js +57 -0
- package/lib/ports/snapshot-repo.js +48 -0
- package/lib/sources/eastmoney-macro.js +197 -0
- package/lib/sources/eastmoney-quote.js +201 -0
- package/lib/sources/ecb.js +179 -0
- package/lib/sources/fred.js +207 -0
- package/lib/sources/http.js +136 -0
- package/lib/sources/ohlc.js +36 -0
- package/lib/sources/quote-cascade.js +177 -0
- package/lib/sources/registry.js +153 -0
- package/lib/sources/sina-cn.js +197 -0
- package/lib/sources/sina-us.js +187 -0
- package/lib/sources/tencent.js +158 -0
- package/lib/sources/us-treasury-rates.js +275 -0
- package/lib/sources/us-treasury.js +196 -0
- package/lib/sources/worldbank.js +170 -0
- package/package.json +69 -0
- package/src/app/ai-deterministic.js +327 -0
- package/src/app/ai-validate.js +284 -0
- package/src/app/ai.js +440 -0
- package/src/app/health.js +77 -0
- package/src/app/overview.js +349 -0
- package/src/app/propose-indicator.js +122 -0
- package/src/app/refresh.js +251 -0
- package/src/app/series-view.js +195 -0
- package/src/app/watchlist.js +102 -0
- package/src/client/api.js +323 -0
- package/src/client/components.js +1877 -0
- package/src/client/copy.js +368 -0
- package/src/client/index.js +169 -0
- package/src/client/store.js +219 -0
- package/src/core/ai/prompts.js +213 -0
- package/src/core/chart/axis.js +133 -0
- package/src/core/chart/bar.js +58 -0
- package/src/core/chart/candle.js +216 -0
- package/src/core/chart/line.js +186 -0
- package/src/core/chart/scale.js +132 -0
- package/src/core/format.js +143 -0
- package/src/core/indicators/catalog.js +1011 -0
- package/src/core/indicators/resolve.js +196 -0
- package/src/core/insight/digest.js +250 -0
- package/src/core/insight/rank.js +115 -0
- package/src/core/insight/related.js +90 -0
- package/src/core/insight/rules.js +417 -0
- package/src/core/stats/derive.js +123 -0
- package/src/core/stats/series.js +465 -0
- package/src/core/time/range.js +242 -0
- package/src/core/types.js +478 -0
- package/src/host/ai/discussion.js +559 -0
- package/src/host/ai/dsh-llm-gateway.js +333 -0
- package/src/host/config.js +194 -0
- package/src/host/http/respond.js +165 -0
- package/src/host/http/routes.js +689 -0
- package/src/host/index.js +293 -0
- package/src/host/infra/fs-repos.js +179 -0
- package/src/host/infra/memory-fallback.js +64 -0
- package/src/host/tools/define-tool.js +295 -0
- package/src/host/tools/register.js +431 -0
- package/src/ports/clock.js +57 -0
- package/src/ports/snapshot-repo.js +48 -0
- package/src/sources/eastmoney-macro.js +197 -0
- package/src/sources/eastmoney-quote.js +201 -0
- package/src/sources/ecb.js +179 -0
- package/src/sources/fred.js +207 -0
- package/src/sources/http.js +136 -0
- package/src/sources/ohlc.js +36 -0
- package/src/sources/quote-cascade.js +177 -0
- package/src/sources/registry.js +153 -0
- package/src/sources/sina-cn.js +197 -0
- package/src/sources/sina-us.js +187 -0
- package/src/sources/tencent.js +158 -0
- package/src/sources/us-treasury-rates.js +275 -0
- package/src/sources/us-treasury.js +196 -0
- package/src/sources/worldbank.js +170 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The 'ctx.llm' AI gateway (docs/06 §1, docs/07 T9.3).
|
|
3
|
+
*
|
|
4
|
+
* It maps 'GenerateOptions'/'StreamChunk' onto this plugin's 'AiGateway'
|
|
5
|
+
* contract, and it is the only place that talks to a model. Everything it does
|
|
6
|
+
* is optional: if the service is missing, the request fails, or the provider is
|
|
7
|
+
* unset, the caller falls back to the deterministic gateway.
|
|
8
|
+
*
|
|
9
|
+
* @module host/ai/dsh-llm-gateway
|
|
10
|
+
*/
|
|
11
|
+
import { fingerprint } from '../../core/insight/rank.js'
|
|
12
|
+
import { SYSTEM_PROMPT } from '../../core/ai/prompts.js'
|
|
13
|
+
|
|
14
|
+
/** Gateway mode label. */
|
|
15
|
+
export const MODE = 'llm'
|
|
16
|
+
|
|
17
|
+
/** Request timeout (docs/05 §5: 30s, partial output preserved). */
|
|
18
|
+
export const LLM_TIMEOUT_MS = 30_000
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Output budget for one answer.
|
|
22
|
+
*
|
|
23
|
+
* Reasoning models spend tokens on `reasoning-delta` before emitting any
|
|
24
|
+
* `text-delta`, so a budget sized for the answer alone is entirely consumed by
|
|
25
|
+
* thinking and the response arrives with no text at all (observed live with
|
|
26
|
+
* deepseek-v4-flash at 600 tokens). The budget therefore has to cover thinking
|
|
27
|
+
* plus a {@link MAX_CHARS}-ish answer.
|
|
28
|
+
*/
|
|
29
|
+
export const DEFAULT_MAX_TOKENS = 4000
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the message object the 'llm' service expects.
|
|
33
|
+
*
|
|
34
|
+
* The service wants 'Message[]' (with 'id' and 'source'), and its constructors
|
|
35
|
+
* live in packages this plugin deliberately does not import, so a minimal valid
|
|
36
|
+
* message is built here.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} text - user content.
|
|
39
|
+
* @param {{ idSeed: string }} options - identity seed for deterministic ids.
|
|
40
|
+
* @returns {object} message.
|
|
41
|
+
*/
|
|
42
|
+
export function buildUserMessage(text, { idSeed }) {
|
|
43
|
+
return {
|
|
44
|
+
id: `smd-${fingerprint(`${idSeed}|${text.length}|${text.slice(0, 64)}`)}`,
|
|
45
|
+
role: 'user',
|
|
46
|
+
content: [{ type: 'text', text }],
|
|
47
|
+
source: { kind: 'plugin', plugin: 'show-me-data' },
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create the LLM gateway.
|
|
53
|
+
*
|
|
54
|
+
* @param {object} deps - dependencies.
|
|
55
|
+
* @param {object} deps.llm - 'ctx.llm' service.
|
|
56
|
+
* @param {object} [deps.defaultModel] - 'ctx.agentDefaultModel' service.
|
|
57
|
+
* @param {number} [deps.maxChars] - output budget passed to the model.
|
|
58
|
+
* @param {number} [deps.timeoutMs] - request timeout.
|
|
59
|
+
* @param {(msg: string, meta?: object) => void} [deps.log] - logger.
|
|
60
|
+
* @returns {object} 'AiGateway'.
|
|
61
|
+
*/
|
|
62
|
+
export function createDshLlmGateway({ llm, defaultModel, maxChars = 1200, timeoutMs = LLM_TIMEOUT_MS, log = () => {} }) {
|
|
63
|
+
if (llm === undefined || typeof llm.stream !== 'function') {
|
|
64
|
+
throw new Error('createDshLlmGateway: an `llm` service with `stream()` is required')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the provider/model to call.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} [override] - per-request override.
|
|
71
|
+
* @returns {{ provider: string, model: string, reasoningEffort?: string }} selection.
|
|
72
|
+
*/
|
|
73
|
+
function selection(override) {
|
|
74
|
+
if (override?.provider !== undefined && override?.model !== undefined) return override
|
|
75
|
+
const current = defaultModel?.currentSelection?.()
|
|
76
|
+
if (current?.provider !== undefined && current?.model !== undefined) return current
|
|
77
|
+
const providers = typeof llm.listProviders === 'function' ? llm.listProviders() : []
|
|
78
|
+
const first = providers[0]
|
|
79
|
+
if (first !== undefined) {
|
|
80
|
+
const model = first.models?.[0]?.id ?? first.defaultModel ?? first.models?.[0]
|
|
81
|
+
if (model !== undefined) return { provider: first.provider ?? first.id, model: String(model) }
|
|
82
|
+
}
|
|
83
|
+
throw new Error('no LLM provider/model is configured (agentDefaultModel.currentSelection() is empty)')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Run one completion, collecting streamed text.
|
|
88
|
+
*
|
|
89
|
+
* @param {object} input - invocation.
|
|
90
|
+
* @param {string} input.system - system prompt.
|
|
91
|
+
* @param {string} input.user - user prompt.
|
|
92
|
+
* @param {string} input.idSeed - identity seed for the message id.
|
|
93
|
+
* @param {number} [input.maxTokens] - output budget.
|
|
94
|
+
* @returns {Promise<{ text: string, usage?: object, interrupted: boolean }>} completion.
|
|
95
|
+
*/
|
|
96
|
+
async function complete({ system, user, idSeed, maxTokens }) {
|
|
97
|
+
const target = selection()
|
|
98
|
+
const controller = new AbortController()
|
|
99
|
+
const timer = setTimeout(() => controller.abort(new Error('llm timeout')), timeoutMs)
|
|
100
|
+
let text = ''
|
|
101
|
+
// Reasoning text is kept separately: a reasoning model that runs out of
|
|
102
|
+
// budget mid-thought emits no `text` block at all, and showing the user the
|
|
103
|
+
// model's own thinking beats showing them nothing.
|
|
104
|
+
let reasoning = ''
|
|
105
|
+
let usage
|
|
106
|
+
let interrupted = false
|
|
107
|
+
/** Chunk-type census, so an empty answer can explain itself. */
|
|
108
|
+
const census = {}
|
|
109
|
+
let finishReason
|
|
110
|
+
let failure
|
|
111
|
+
try {
|
|
112
|
+
const stream = llm.stream({
|
|
113
|
+
provider: target.provider,
|
|
114
|
+
model: target.model,
|
|
115
|
+
...(target.reasoningEffort === undefined ? {} : { reasoningEffort: target.reasoningEffort }),
|
|
116
|
+
system,
|
|
117
|
+
messages: [buildUserMessage(user, { idSeed })],
|
|
118
|
+
...(maxTokens === undefined ? {} : { maxTokens }),
|
|
119
|
+
signal: controller.signal,
|
|
120
|
+
})
|
|
121
|
+
for await (const chunk of stream) {
|
|
122
|
+
if (chunk?.type !== undefined) census[chunk.type] = (census[chunk.type] ?? 0) + 1
|
|
123
|
+
if (chunk?.type === 'text-delta') text += chunk.text
|
|
124
|
+
else if (chunk?.type === 'reasoning-delta') reasoning += chunk.text
|
|
125
|
+
else if (chunk?.type === 'usage') usage = chunk.usage
|
|
126
|
+
else if (chunk?.type === 'finish') {
|
|
127
|
+
finishReason = chunk.reason?.kind
|
|
128
|
+
if (chunk.reason?.kind === 'aborted' || chunk.reason?.kind === 'error') {
|
|
129
|
+
interrupted = true
|
|
130
|
+
failure = chunk.reason.failure
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (text.length === 0) throw error
|
|
136
|
+
// Keep what we already received: a partial answer beats no answer.
|
|
137
|
+
interrupted = true
|
|
138
|
+
failure = { message: error?.message ?? String(error), code: error?.code }
|
|
139
|
+
log('llm stream interrupted, keeping partial output', { message: error?.message ?? String(error) })
|
|
140
|
+
} finally {
|
|
141
|
+
clearTimeout(timer)
|
|
142
|
+
}
|
|
143
|
+
const textOut = text
|
|
144
|
+
const reasoningOnly = textOut.trim() === '' && reasoning.trim() !== ''
|
|
145
|
+
if (reasoningOnly) {
|
|
146
|
+
// Budget exhausted during thinking. Reasoning is NOT an answer: handing it
|
|
147
|
+
// back as `markdown` let a model's private deliberation ("用户要求重写,且
|
|
148
|
+
// 上次因未引用数据点未通过") reach the panel as if it were the analysis, and
|
|
149
|
+
// it once passed the validator because it restated digest numbers. The
|
|
150
|
+
// answer stays empty — the caller then retries and degrades to the
|
|
151
|
+
// deterministic summary — while the reasoning is kept for diagnostics.
|
|
152
|
+
log('llm produced reasoning but no text (budget likely exhausted during thinking)', {
|
|
153
|
+
provider: target.provider,
|
|
154
|
+
model: target.model,
|
|
155
|
+
finishReason,
|
|
156
|
+
maxTokens,
|
|
157
|
+
reasoningChars: reasoning.length,
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
if (textOut.trim() === '') {
|
|
161
|
+
// An empty completion is a provider-level failure, and the caller must be
|
|
162
|
+
// able to say WHY instead of showing a generic "no model configured".
|
|
163
|
+
log('llm returned no text', { provider: target.provider, model: target.model, census, finishReason, failure })
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
text: textOut,
|
|
167
|
+
// The raw reasoning is diagnostics, never an answer.
|
|
168
|
+
reasoning: reasoningOnly ? reasoning.trim() : undefined,
|
|
169
|
+
usage,
|
|
170
|
+
interrupted,
|
|
171
|
+
reasoningOnly,
|
|
172
|
+
census,
|
|
173
|
+
finishReason,
|
|
174
|
+
failure,
|
|
175
|
+
provider: target.provider,
|
|
176
|
+
model: target.model,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Build an 'AiResult' from a completion.
|
|
182
|
+
*
|
|
183
|
+
* @param {object} input - invocation.
|
|
184
|
+
* @param {string} input.text - completion text.
|
|
185
|
+
* @param {object} input.usage - token usage.
|
|
186
|
+
* @param {boolean} input.interrupted - whether the stream was cut short.
|
|
187
|
+
* @param {string} input.idSeed - fingerprint seed.
|
|
188
|
+
* @returns {object} 'AiResult'.
|
|
189
|
+
*/
|
|
190
|
+
function resultOf({ text, usage, interrupted, idSeed, census, finishReason, failure, provider, model, reasoningOnly, reasoning }) {
|
|
191
|
+
const described = describe()
|
|
192
|
+
return {
|
|
193
|
+
markdown: text,
|
|
194
|
+
// Citations are produced by the validator from the text; an empty list here
|
|
195
|
+
// means the caller must validate before showing anything.
|
|
196
|
+
usedPoints: [],
|
|
197
|
+
mode: MODE,
|
|
198
|
+
interrupted: interrupted === true,
|
|
199
|
+
reasoningOnly: reasoningOnly === true,
|
|
200
|
+
usage,
|
|
201
|
+
provider: described.provider ?? provider,
|
|
202
|
+
model: described.model ?? model,
|
|
203
|
+
// Diagnostic facts travel with the result so the panel can explain an
|
|
204
|
+
// empty answer instead of pretending no model is configured.
|
|
205
|
+
emptyReason:
|
|
206
|
+
text === ''
|
|
207
|
+
? {
|
|
208
|
+
kind: failure !== undefined ? 'provider-error' : reasoningOnly ? 'reasoning-only' : 'no-text',
|
|
209
|
+
detail: failure?.message ??
|
|
210
|
+
(reasoningOnly
|
|
211
|
+
? `模型把全部输出预算用在了思考上(${reasoning?.length ?? 0} 字),没有产出回答`
|
|
212
|
+
: `模型只返回了 ${Object.keys(census ?? {}).join('/') || '无'} 类型的内容,没有文本`),
|
|
213
|
+
code: failure?.code,
|
|
214
|
+
finishReason,
|
|
215
|
+
census,
|
|
216
|
+
}
|
|
217
|
+
: undefined,
|
|
218
|
+
fingerprint: fingerprint(`${idSeed}|${text.length}`),
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* @param {object} request - request with 'system'/'user' prompts already built.
|
|
224
|
+
* @returns {Promise<object>} 'AiResult'.
|
|
225
|
+
*/
|
|
226
|
+
async function explain(request) {
|
|
227
|
+
const completion = await complete({
|
|
228
|
+
system: request.system ?? SYSTEM_PROMPT,
|
|
229
|
+
user: request.user ?? '',
|
|
230
|
+
idSeed: `explain|${request.indicatorId ?? ''}`,
|
|
231
|
+
maxTokens: DEFAULT_MAX_TOKENS,
|
|
232
|
+
})
|
|
233
|
+
return resultOf({ ...completion, idSeed: `explain|${request.indicatorId ?? ''}` })
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* @param {object} request - request.
|
|
238
|
+
* @returns {Promise<object>} 'AiResult'.
|
|
239
|
+
*/
|
|
240
|
+
async function summarize(request) {
|
|
241
|
+
const completion = await complete({
|
|
242
|
+
system: request.system ?? SYSTEM_PROMPT,
|
|
243
|
+
user: request.user ?? '',
|
|
244
|
+
idSeed: `summary|${request.range?.from ?? ''}`,
|
|
245
|
+
maxTokens: DEFAULT_MAX_TOKENS,
|
|
246
|
+
})
|
|
247
|
+
return resultOf({ ...completion, idSeed: `summary|${request.range?.from ?? ''}` })
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* @param {object} request - request.
|
|
252
|
+
* @returns {Promise<object>} 'AiResult'.
|
|
253
|
+
*/
|
|
254
|
+
async function answer(request) {
|
|
255
|
+
const completion = await complete({
|
|
256
|
+
system: request.system ?? SYSTEM_PROMPT,
|
|
257
|
+
user: request.user ?? '',
|
|
258
|
+
idSeed: `answer|${request.question ?? ''}`,
|
|
259
|
+
maxTokens: DEFAULT_MAX_TOKENS,
|
|
260
|
+
})
|
|
261
|
+
return resultOf({ ...completion, idSeed: `answer|${request.question ?? ''}` })
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Ask the model for structured JSON candidates.
|
|
266
|
+
*
|
|
267
|
+
* @param {object} request - request with 'system'/'user'.
|
|
268
|
+
* @returns {Promise<{ candidates: object[], unsupported: object[], mode: string, raw: string }>} proposal.
|
|
269
|
+
*/
|
|
270
|
+
async function propose(request) {
|
|
271
|
+
const completion = await complete({
|
|
272
|
+
system: request.system ?? SYSTEM_PROMPT,
|
|
273
|
+
user: request.user ?? '',
|
|
274
|
+
idSeed: `propose|${request.request ?? ''}`,
|
|
275
|
+
maxTokens: 1500,
|
|
276
|
+
})
|
|
277
|
+
try {
|
|
278
|
+
const parsed = JSON.parse(extractJson(completion.text))
|
|
279
|
+
return {
|
|
280
|
+
candidates: Array.isArray(parsed?.candidates) ? parsed.candidates : [],
|
|
281
|
+
unsupported: Array.isArray(parsed?.unsupported) ? parsed.unsupported : [],
|
|
282
|
+
mode: MODE,
|
|
283
|
+
raw: completion.text,
|
|
284
|
+
}
|
|
285
|
+
} catch (error) {
|
|
286
|
+
// Unparseable model output is not a candidate list: say so rather than
|
|
287
|
+
// inventing one.
|
|
288
|
+
return {
|
|
289
|
+
candidates: [],
|
|
290
|
+
unsupported: [
|
|
291
|
+
{
|
|
292
|
+
request: request.request ?? '',
|
|
293
|
+
reason: `模型返回的不是合法 JSON(${error?.message ?? error}),未做任何猜测。`,
|
|
294
|
+
alternatives: ['换一种说法重试', '在指标目录中手动检索'],
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
mode: MODE,
|
|
298
|
+
raw: completion.text,
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* @returns {{ mode: string, provider?: string, model?: string }} description.
|
|
305
|
+
*/
|
|
306
|
+
function describe() {
|
|
307
|
+
try {
|
|
308
|
+
const target = selection()
|
|
309
|
+
return { mode: MODE, provider: target.provider, model: target.model }
|
|
310
|
+
} catch {
|
|
311
|
+
return { mode: MODE }
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return { explain, summarize, answer, propose, describe }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Pull the first JSON object out of model text (models like to wrap JSON in
|
|
320
|
+
* prose or fences).
|
|
321
|
+
*
|
|
322
|
+
* @param {string} text - model output.
|
|
323
|
+
* @returns {string} JSON candidate.
|
|
324
|
+
*/
|
|
325
|
+
export function extractJson(text) {
|
|
326
|
+
const source = String(text ?? '')
|
|
327
|
+
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(source)
|
|
328
|
+
if (fenced !== null) return fenced[1].trim()
|
|
329
|
+
const start = source.indexOf('{')
|
|
330
|
+
const end = source.lastIndexOf('}')
|
|
331
|
+
if (start !== -1 && end > start) return source.slice(start, end + 1)
|
|
332
|
+
return source.trim()
|
|
333
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin configuration: defaults, validation and the Standard Schema export
|
|
3
|
+
* (docs/09 §5).
|
|
4
|
+
*
|
|
5
|
+
* **Why this file exists.** Cordis validates a loader row's 'config' *before*
|
|
6
|
+
* 'apply' runs, through 'plugin.Config["~standard"].validate(config)'. A plugin
|
|
7
|
+
* that exports a plain defaults object as 'Config' therefore crashes the whole
|
|
8
|
+
* profile at boot ('Cannot read properties of undefined (reading 'validate')'),
|
|
9
|
+
* not just its own row. The exported schema below is a real Standard Schema:
|
|
10
|
+
* valid configuration passes through normalized, invalid configuration becomes
|
|
11
|
+
* 'issues' that the loader reports as a readable boot error.
|
|
12
|
+
*
|
|
13
|
+
* @module host/config
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The row defaults. Kept as data so {@link ConfigSchema} and the docs agree. */
|
|
17
|
+
export const DEFAULT_CONFIG = {
|
|
18
|
+
refreshMinutes: 30,
|
|
19
|
+
cacheTtl: { daily: 15, weekly: 180, monthly: 360, quarterly: 1440 },
|
|
20
|
+
groups: ['US', 'CN', 'GLOBAL', 'CUSTOM'],
|
|
21
|
+
noteworthyLimit: 5,
|
|
22
|
+
storageDir: undefined,
|
|
23
|
+
/** Print AI diagnostics to stderr as well; for troubleshooting a silent model. */
|
|
24
|
+
debug: false,
|
|
25
|
+
ai: { enabled: true, mode: 'auto', maxChars: 1200, cacheMinutes: 60 },
|
|
26
|
+
sources: {
|
|
27
|
+
fred: true,
|
|
28
|
+
'eastmoney-quote': true,
|
|
29
|
+
'eastmoney-macro': true,
|
|
30
|
+
'us-treasury': true,
|
|
31
|
+
worldbank: true,
|
|
32
|
+
ecb: true,
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The AI modes a row may select. */
|
|
37
|
+
export const AI_MODES = ['auto', 'llm', 'deterministic', 'relay']
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Validate and normalize one raw row config.
|
|
41
|
+
*
|
|
42
|
+
* @param {unknown} raw - configuration from the loader row.
|
|
43
|
+
* @returns {{ value: object, issues: Array<{ message: string, path?: string[] }> }} normalized config and issues.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveConfig(raw) {
|
|
46
|
+
const issues = []
|
|
47
|
+
const config = raw === null || raw === undefined || typeof raw !== 'object' || Array.isArray(raw) ? {} : raw
|
|
48
|
+
if (raw !== undefined && (raw === null || typeof raw !== 'object' || Array.isArray(raw))) {
|
|
49
|
+
issues.push({ message: 'config must be an object', path: [] })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const takeNumber = (key, { min, max, integer = true }) => {
|
|
53
|
+
const value = config[key]
|
|
54
|
+
if (value === undefined) return DEFAULT_CONFIG[key]
|
|
55
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
56
|
+
issues.push({ message: `${key} must be a finite number`, path: [key] })
|
|
57
|
+
return DEFAULT_CONFIG[key]
|
|
58
|
+
}
|
|
59
|
+
if (integer && !Number.isInteger(value)) {
|
|
60
|
+
issues.push({ message: `${key} must be an integer`, path: [key] })
|
|
61
|
+
return Math.trunc(value)
|
|
62
|
+
}
|
|
63
|
+
if (value < min || value > max) {
|
|
64
|
+
issues.push({ message: `${key} must be between ${min} and ${max}`, path: [key] })
|
|
65
|
+
return Math.min(Math.max(value, min), max)
|
|
66
|
+
}
|
|
67
|
+
return value
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const refreshMinutes = takeNumber('refreshMinutes', { min: 0, max: 24 * 60 })
|
|
71
|
+
const noteworthyLimit = takeNumber('noteworthyLimit', { min: 1, max: 20 })
|
|
72
|
+
|
|
73
|
+
let groups = DEFAULT_CONFIG.groups
|
|
74
|
+
if (config.groups !== undefined) {
|
|
75
|
+
if (!Array.isArray(config.groups) || config.groups.length === 0 || config.groups.some((entry) => typeof entry !== 'string')) {
|
|
76
|
+
issues.push({ message: 'groups must be a non-empty array of strings', path: ['groups'] })
|
|
77
|
+
} else {
|
|
78
|
+
groups = [...config.groups]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let storageDir
|
|
83
|
+
if (config.storageDir !== undefined) {
|
|
84
|
+
if (typeof config.storageDir !== 'string' || config.storageDir === '') {
|
|
85
|
+
issues.push({ message: 'storageDir must be a non-empty string when set', path: ['storageDir'] })
|
|
86
|
+
} else {
|
|
87
|
+
storageDir = config.storageDir
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const cacheTtl = { ...DEFAULT_CONFIG.cacheTtl }
|
|
92
|
+
if (config.cacheTtl !== undefined) {
|
|
93
|
+
if (config.cacheTtl === null || typeof config.cacheTtl !== 'object' || Array.isArray(config.cacheTtl)) {
|
|
94
|
+
issues.push({ message: 'cacheTtl must be an object of minutes per frequency', path: ['cacheTtl'] })
|
|
95
|
+
} else {
|
|
96
|
+
for (const [key, value] of Object.entries(config.cacheTtl)) {
|
|
97
|
+
if (!(key in DEFAULT_CONFIG.cacheTtl)) {
|
|
98
|
+
issues.push({ message: `cacheTtl.${key} is not a known frequency`, path: ['cacheTtl', key] })
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
|
102
|
+
issues.push({ message: `cacheTtl.${key} must be a non-negative number`, path: ['cacheTtl', key] })
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
cacheTtl[key] = value
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const ai = { ...DEFAULT_CONFIG.ai }
|
|
111
|
+
if (config.ai !== undefined) {
|
|
112
|
+
if (config.ai === null || typeof config.ai !== 'object' || Array.isArray(config.ai)) {
|
|
113
|
+
issues.push({ message: 'ai must be an object', path: ['ai'] })
|
|
114
|
+
} else {
|
|
115
|
+
if (config.ai.enabled !== undefined) {
|
|
116
|
+
if (typeof config.ai.enabled !== 'boolean') issues.push({ message: 'ai.enabled must be a boolean', path: ['ai', 'enabled'] })
|
|
117
|
+
else ai.enabled = config.ai.enabled
|
|
118
|
+
}
|
|
119
|
+
if (config.ai.mode !== undefined) {
|
|
120
|
+
if (!AI_MODES.includes(config.ai.mode)) {
|
|
121
|
+
issues.push({ message: `ai.mode must be one of ${AI_MODES.join('|')}`, path: ['ai', 'mode'] })
|
|
122
|
+
} else {
|
|
123
|
+
ai.mode = config.ai.mode
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (config.ai.maxChars !== undefined) {
|
|
127
|
+
if (!Number.isInteger(config.ai.maxChars) || config.ai.maxChars < 200 || config.ai.maxChars > 8000) {
|
|
128
|
+
issues.push({ message: 'ai.maxChars must be an integer between 200 and 8000', path: ['ai', 'maxChars'] })
|
|
129
|
+
} else {
|
|
130
|
+
ai.maxChars = config.ai.maxChars
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (config.ai.cacheMinutes !== undefined) {
|
|
134
|
+
if (!Number.isInteger(config.ai.cacheMinutes) || config.ai.cacheMinutes < 0) {
|
|
135
|
+
issues.push({ message: 'ai.cacheMinutes must be a non-negative integer', path: ['ai', 'cacheMinutes'] })
|
|
136
|
+
} else {
|
|
137
|
+
ai.cacheMinutes = config.ai.cacheMinutes
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const sources = { ...DEFAULT_CONFIG.sources }
|
|
144
|
+
if (config.sources !== undefined) {
|
|
145
|
+
if (config.sources === null || typeof config.sources !== 'object' || Array.isArray(config.sources)) {
|
|
146
|
+
issues.push({ message: 'sources must be an object of booleans', path: ['sources'] })
|
|
147
|
+
} else {
|
|
148
|
+
for (const [key, value] of Object.entries(config.sources)) {
|
|
149
|
+
if (typeof value !== 'boolean') {
|
|
150
|
+
issues.push({ message: `sources.${key} must be a boolean`, path: ['sources', key] })
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
sources[key] = value
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let debug = DEFAULT_CONFIG.debug
|
|
159
|
+
if (config.debug !== undefined) {
|
|
160
|
+
if (typeof config.debug !== 'boolean') issues.push({ message: 'debug must be a boolean', path: ['debug'] })
|
|
161
|
+
else debug = config.debug
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
value: { refreshMinutes, cacheTtl, groups, noteworthyLimit, storageDir, debug, ai, sources },
|
|
166
|
+
issues,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The Standard Schema Cordis reads from 'plugin.Config'.
|
|
172
|
+
*
|
|
173
|
+
* 'validate' never throws: an unexpected failure becomes an 'issues' entry so a
|
|
174
|
+
* configuration mistake surfaces as a readable boot message instead of a
|
|
175
|
+
* process-level TypeError.
|
|
176
|
+
*/
|
|
177
|
+
export const ConfigSchema = {
|
|
178
|
+
'~standard': {
|
|
179
|
+
version: 1,
|
|
180
|
+
vendor: 'dsh-plugin-show-me-data',
|
|
181
|
+
/**
|
|
182
|
+
* @param {unknown} value - raw row config.
|
|
183
|
+
* @returns {{ value: object, issues?: Array<{ message: string, path?: string[] }> }} validation result.
|
|
184
|
+
*/
|
|
185
|
+
validate(value) {
|
|
186
|
+
try {
|
|
187
|
+
const { value: normalized, issues } = resolveConfig(value)
|
|
188
|
+
return issues.length === 0 ? { value: normalized } : { value: normalized, issues }
|
|
189
|
+
} catch (error) {
|
|
190
|
+
return { value: DEFAULT_CONFIG, issues: [{ message: `config validation failed: ${error?.message ?? error}` }] }
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP plumbing for the browser half's data channel (docs/07 T7.2).
|
|
3
|
+
*
|
|
4
|
+
* The route table is registered with 'kind: 'prefix'' for the whole
|
|
5
|
+
* '/api/show-me-data' subtree, and every handler is a thin adapter: parse the
|
|
6
|
+
* request, call a use case, serialise. Errors are structured and never leak a
|
|
7
|
+
* stack (docs/05 §3).
|
|
8
|
+
*
|
|
9
|
+
* @module host/http/respond
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The prefix every route lives under. */
|
|
13
|
+
export const API_PREFIX = '/api/show-me-data'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read a request body, with a hard size cap.
|
|
17
|
+
*
|
|
18
|
+
* @param {import('node:http').IncomingMessage} req - request.
|
|
19
|
+
* @param {number} [maxBytes] - cap.
|
|
20
|
+
* @returns {Promise<any>} parsed JSON body ('{}' when empty).
|
|
21
|
+
*/
|
|
22
|
+
export async function readJsonBody(req, maxBytes = 256 * 1024) {
|
|
23
|
+
const chunks = []
|
|
24
|
+
let size = 0
|
|
25
|
+
for await (const chunk of req) {
|
|
26
|
+
size += chunk.length
|
|
27
|
+
if (size > maxBytes) {
|
|
28
|
+
const error = new Error(`request body exceeds ${maxBytes} bytes`)
|
|
29
|
+
error.code = 'BODY_TOO_LARGE'
|
|
30
|
+
throw error
|
|
31
|
+
}
|
|
32
|
+
chunks.push(chunk)
|
|
33
|
+
}
|
|
34
|
+
if (size === 0) return {}
|
|
35
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(text)
|
|
38
|
+
return parsed === null || typeof parsed !== 'object' ? {} : parsed
|
|
39
|
+
} catch {
|
|
40
|
+
const error = new Error('request body is not valid JSON')
|
|
41
|
+
error.code = 'BAD_JSON'
|
|
42
|
+
throw error
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Send a JSON response.
|
|
48
|
+
*
|
|
49
|
+
* @param {import('node:http').ServerResponse} res - response.
|
|
50
|
+
* @param {number} status - status code.
|
|
51
|
+
* @param {any} payload - JSON payload.
|
|
52
|
+
* @returns {void}
|
|
53
|
+
*/
|
|
54
|
+
export function sendJson(res, status, payload) {
|
|
55
|
+
const body = JSON.stringify(payload)
|
|
56
|
+
res.writeHead(status, {
|
|
57
|
+
'content-type': 'application/json; charset=utf-8',
|
|
58
|
+
'content-length': Buffer.byteLength(body),
|
|
59
|
+
'cache-control': 'no-store',
|
|
60
|
+
})
|
|
61
|
+
res.end(body)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Send a structured error. Stacks stay in the log, never on the wire.
|
|
66
|
+
*
|
|
67
|
+
* `payload` merges top-level fields beside `error`. A failure that still produced
|
|
68
|
+
* something the caller needs — a discussion whose session exists even though its
|
|
69
|
+
* first turn failed — has to hand that id over, or the reader is told to open a
|
|
70
|
+
* session whose id they never receive.
|
|
71
|
+
*
|
|
72
|
+
* @param {import('node:http').ServerResponse} res - response.
|
|
73
|
+
* @param {number} status - status code.
|
|
74
|
+
* @param {string} kind - machine-readable kind.
|
|
75
|
+
* @param {string} detail - human-readable detail.
|
|
76
|
+
* @param {{ retryable?: boolean }} [extra] - extra fields.
|
|
77
|
+
* @param {object} [payload] - top-level fields to merge beside `error`.
|
|
78
|
+
* @returns {void}
|
|
79
|
+
*/
|
|
80
|
+
export function sendError(res, status, kind, detail, extra = {}, payload = {}) {
|
|
81
|
+
sendJson(res, status, { ...payload, error: { kind, detail, retryable: extra.retryable === true } })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Start a 'text/event-stream' response.
|
|
86
|
+
*
|
|
87
|
+
* @param {import('node:http').ServerResponse} res - response.
|
|
88
|
+
* @param {Record<string, string>} [headers] - extra headers.
|
|
89
|
+
* @returns {(event: string, data: any) => void} event writer.
|
|
90
|
+
*/
|
|
91
|
+
export function startSse(res, headers = {}) {
|
|
92
|
+
res.writeHead(200, {
|
|
93
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
94
|
+
'cache-control': 'no-store',
|
|
95
|
+
connection: 'keep-alive',
|
|
96
|
+
'x-accel-buffering': 'no',
|
|
97
|
+
...headers,
|
|
98
|
+
})
|
|
99
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders()
|
|
100
|
+
let closed = false
|
|
101
|
+
res.on('close', () => {
|
|
102
|
+
closed = true
|
|
103
|
+
})
|
|
104
|
+
return (event, data) => {
|
|
105
|
+
if (closed || res.writableEnded) return
|
|
106
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Parse a URL into its pathname and query.
|
|
112
|
+
*
|
|
113
|
+
* @param {import('node:http').IncomingMessage} req - request.
|
|
114
|
+
* @param {string} prefix - route prefix.
|
|
115
|
+
* @returns {{ route: string, query: URLSearchParams }} route name and query.
|
|
116
|
+
*/
|
|
117
|
+
export function parseRoute(req, prefix = API_PREFIX) {
|
|
118
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
119
|
+
const path = url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) : url.pathname
|
|
120
|
+
return { route: path.replace(/^\/+|\/+$/g, ''), query: url.searchParams }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Read a positive integer query parameter.
|
|
125
|
+
*
|
|
126
|
+
* @param {URLSearchParams} query - query.
|
|
127
|
+
* @param {string} key - parameter name.
|
|
128
|
+
* @param {number} fallback - default.
|
|
129
|
+
* @param {{ min?: number, max?: number }} [bounds] - bounds.
|
|
130
|
+
* @returns {number} value.
|
|
131
|
+
*/
|
|
132
|
+
export function intParam(query, key, fallback, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
|
|
133
|
+
const raw = query.get(key)
|
|
134
|
+
if (raw === null) return fallback
|
|
135
|
+
const value = Number(raw)
|
|
136
|
+
if (!Number.isFinite(value)) return fallback
|
|
137
|
+
return Math.min(Math.max(Math.trunc(value), min), max)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Read a comma-separated list parameter.
|
|
142
|
+
*
|
|
143
|
+
* @param {URLSearchParams} query - query.
|
|
144
|
+
* @param {string} key - parameter name.
|
|
145
|
+
* @returns {string[]|undefined} values.
|
|
146
|
+
*/
|
|
147
|
+
export function listParam(query, key) {
|
|
148
|
+
const raw = query.get(key)
|
|
149
|
+
if (raw === null || raw.trim() === '') return undefined
|
|
150
|
+
return raw.split(',').map((entry) => entry.trim()).filter((entry) => entry !== '')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Normalise any thrown value into a structured error payload.
|
|
155
|
+
*
|
|
156
|
+
* @param {unknown} error - thrown value.
|
|
157
|
+
* @returns {{ status: number, kind: string, detail: string, retryable: boolean }} payload.
|
|
158
|
+
*/
|
|
159
|
+
export function errorPayload(error) {
|
|
160
|
+
if (error?.code === 'BODY_TOO_LARGE') return { status: 413, kind: 'payload-too-large', detail: error.message, retryable: false }
|
|
161
|
+
if (error?.code === 'BAD_JSON') return { status: 400, kind: 'bad-request', detail: error.message, retryable: false }
|
|
162
|
+
if (error instanceof RangeError) return { status: 400, kind: 'bad-request', detail: error.message, retryable: false }
|
|
163
|
+
if (error?.name === 'ToolArgsError') return { status: 400, kind: 'bad-request', detail: error.message, retryable: false }
|
|
164
|
+
return { status: 500, kind: 'internal', detail: 'plugin error (details in the host log)', retryable: true }
|
|
165
|
+
}
|