@master0071/dsh-systemone 0.5.0 → 0.6.1

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/lib/index.d.ts CHANGED
@@ -7,7 +7,12 @@ import { Context, Service } from '@deepseek-ai/cordis'
7
7
  export interface Config {
8
8
  /** 提供商类型:unisound(官方)/ http(SystemOne 兼容端点)/ mock(本地模拟) */
9
9
  provider?: string
10
- /** Unisound API Key(也可用环境变量 UNISOUND_API_KEY / SYSTEMONE_API_KEY) */
10
+ /**
11
+ * 凭证引用名(推荐):从 `$DSH_HOME/.credentials.yaml` 的 `refs.<名字>` 读密钥,
12
+ * 配置文件里只留这个名字。留空时依次尝试 SYSTEMONE_API_KEY / UNISOUND_API_KEY / TYPESAFE_API_KEY。
13
+ */
14
+ apiKeyRef?: string
15
+ /** 明文 API Key(备用;也可用环境变量 UNISOUND_API_KEY / SYSTEMONE_API_KEY) */
11
16
  apiKey?: string
12
17
  /** Unisound API 基础地址 */
13
18
  baseUrl?: string
@@ -17,12 +22,17 @@ export interface Config {
17
22
  model?: string
18
23
  /** 请求超时(毫秒) */
19
24
  timeoutMs?: number
20
- /** 置信度阈值 */
25
+ /**
26
+ * **次要**复核信号:置信度低于该值时也标记需复核。
27
+ * 主要判据是概率分布平坦度(见 `reviewAssessment` 语义),因为这类概率未标定、跨后端不可比。
28
+ */
21
29
  minConfidence?: number
22
30
  /** 发送前对 state 做内置脱敏(手机号/身份证/邮箱/银行卡号替换为类型标签) */
23
31
  redact?: boolean
24
32
  /** 自定义场景:JSON 数组字符串 */
25
33
  customScenarios?: string
34
+ /** 用量台账文件路径;留空则用 `$DSH_HOME/dsh-systemone/usage.jsonl` */
35
+ usageLogPath?: string
26
36
 
27
37
  /* ── 自动决策(默认关闭) ─────────────────────────────── */
28
38
  /** 是否在每一步推理前自动捕获上下文并执行决策 */
@@ -31,6 +41,8 @@ export interface Config {
31
41
  autoScenario?: string
32
42
  /** 注入通道:message=同一步生效并去重;context=宿主动态上下文(不写历史) */
33
43
  autoInject?: string
44
+ /** 自动决策失败语义:open=放行(默认);closed=决策失败时否决本轮(供将来做闸门用,仅 message 通道) */
45
+ autoFailMode?: string
34
46
  /** 自动决策超时(毫秒) */
35
47
  autoTimeoutMs?: number
36
48
  /** 捕获的历史消息条数 */
@@ -64,6 +76,46 @@ export interface ScenarioInfo {
64
76
  questions: string[]
65
77
  }
66
78
 
79
+ /**
80
+ * 工具决策结果里与复核相关的字段。
81
+ *
82
+ * `computeNeedsHumanReview` 的语义自本版本起改为「**概率分布是否平坦**」
83
+ * (最大概率相对均匀分布不足 1.5 倍即视为在猜,不依赖标定),
84
+ * 绝对置信度 `minConfidence` 降为次要信号。
85
+ */
86
+ export interface ReviewOutcome {
87
+ /**
88
+ * 是否需要人工复核。判据为分布平坦(主要)或低于 `minConfidence`(次要),
89
+ * 以及 `choice` 返回 uncertain/空值、`noul` 落在 0.45~0.55 摇摆区间。
90
+ */
91
+ needs_human_review: boolean
92
+ /** 判定理由(每条形如 `qid:原因`);为空表示无需复核。仅工具输出提供。 */
93
+ review_reasons?: string[]
94
+ }
95
+
96
+ /** 工具返回的决策结果(`systemone_scenario(action:"run")` 与 `systemone_decide`)。 */
97
+ export interface DecisionResult extends ReviewOutcome {
98
+ ok: boolean
99
+ scenario: string | null
100
+ provider: string
101
+ model?: string
102
+ request_id?: string
103
+ latency_ms?: number
104
+ answers?: Record<string, unknown>
105
+ decision?: Record<string, unknown>
106
+ labels?: Record<string, unknown>
107
+ derived?: Record<string, unknown>
108
+ confidences?: Record<string, number>
109
+ recommendation?: string
110
+ warnings?: string[]
111
+ /** 人可读的 Markdown 摘要(含复核理由子项)。 */
112
+ summary: string
113
+ /** 失败时的人类可读原因。 */
114
+ error?: string
115
+ /** 未知场景时列出的可用场景 id。 */
116
+ available?: string[]
117
+ }
118
+
67
119
  declare module '@deepseek-ai/cordis' {
68
120
  interface Context {
69
121
  /** SystemOne 决策服务。 */
package/lib/index.js CHANGED
@@ -13,11 +13,12 @@
13
13
  */
14
14
  import { Service } from '@deepseek-ai/cordis'
15
15
  import z from '@deepseek-ai/schemastery'
16
- import { createProvider } from './provider.js'
17
- import { createTools } from './tools.js'
16
+ import { createProvider, redactRuleSummary } from './provider.js'
17
+ import { createTools, MAX_TOOL_STATE_CHARS } from './tools.js'
18
18
  import { resolveScenarios } from './scenarios.js'
19
- import { installAutoDecide, AUTO_DEFAULTS } from './auto.js'
20
- import { readBoolean, readString, unwrap } from './volatile.js'
19
+ import { installAutoDecide, AUTO_DEFAULTS, MAX_CURRENT_CHARS, HISTORY_CHARS_PER_MESSAGE } from './auto.js'
20
+ import { createUsageLedger } from './usage.js'
21
+ import { readBoolean, readNumber, readString, unwrap } from './volatile.js'
21
22
 
22
23
  /** 合法的提供商类型;其他值会按 unisound 处理并告警一次。 */
23
24
  const KNOWN_PROVIDERS = ['unisound', 'http', 'mock']
@@ -73,7 +74,21 @@ const Config = z.object({
73
74
  customScenarios: z.string().default('').volatile(),
74
75
  /** 提供商类型:unisound(官方)/ http(SystemOne 兼容端点)/ mock(本地模拟) */
75
76
  provider: z.string().default('unisound').volatile(),
76
- /** Unisound API Key(也可用环境变量 UNISOUND_API_KEY / SYSTEMONE_API_KEY) */
77
+ /**
78
+ * 凭证引用名:从 DSH 凭证缝 `$DSH_HOME/.credentials.yaml` 的 `refs.<名字>` 读取密钥。
79
+ *
80
+ * 这是**推荐的凭证方式**:`apiKey` 虽然标了 `.role('secret')`(表单不回显),
81
+ * 但值仍以明文落在 profile 的 `cordis.patch.yml` 里。用引用名则配置文件里只有
82
+ * 一个名字,真正的密钥留在凭证文件(或环境变量)中。
83
+ *
84
+ * 留空时按顺序尝试内置默认引用名:SYSTEMONE_API_KEY → UNISOUND_API_KEY → TYPESAFE_API_KEY。
85
+ */
86
+ apiKeyRef: z.string().default('').volatile(),
87
+ /**
88
+ * Unisound API Key(明文;不想落明文请改用 apiKeyRef 或环境变量)。
89
+ *
90
+ * 解析顺序:apiKey → 环境变量 → 凭证引用(apiKeyRef 或内置默认名)。
91
+ */
77
92
  apiKey: z.string().default('').role('secret').volatile(),
78
93
  /** Unisound API 基础地址(默认 https://maas-api.unisound.com/v1) */
79
94
  baseUrl: z.string().default('https://maas-api.unisound.com/v1').volatile(),
@@ -100,8 +115,27 @@ const Config = z.object({
100
115
  /* ── 自动决策运行参数(volatile):钩子已挂载时实时生效 ────────────────────── */
101
116
  /** 自动决策固定使用的场景 id;留空则用一次 choice 问句自动路由 */
102
117
  autoScenario: z.string().default('').volatile(),
103
- /** 注入通道:message=本步生效(pre-step 注入);context=官方动态上下文(晚一步生效) */
104
- autoInject: z.string().default('message').volatile(),
118
+ /**
119
+ * 注入通道:message=本步生效(pre-step 注入);context=官方动态上下文(晚一步生效)。
120
+ *
121
+ * 必须是枚举而不是裸 string:auto.js 的三处门控都是精确比较
122
+ * (`!== 'context'` / `=== 'context'` / `!== 'message'`),写错大小写
123
+ * (如 `Context`)会静默掉进 message 分支、产生意料之外的历史写入。
124
+ * 枚举让非法值在加载期就报错,而不是运行时无提示地走错通道。
125
+ */
126
+ autoInject: z.union(['message', 'context']).default('message').volatile(),
127
+ /**
128
+ * 自动决策失败语义:open=放行(注入用途的正确默认);closed=否决本轮
129
+ * (pre-step 返回 `{ kind: 'reject' }`,宿主把本轮 turn 记为 blocked)。
130
+ *
131
+ * 必须是枚举而不是裸 string,理由同 autoInject:auto.js 的分支是精确比较,
132
+ * 写错值会静默落回 open。closed 只对"本应得到决策却没有"的真失败生效
133
+ * (请求异常、超时、场景执行失败、路由无可用品);"主动不注入"
134
+ * (寒暄、低置信度、斜杠命令)永远放行。仅 message 通道会拦截——
135
+ * context 通道只注入参考信息,不 gate 任何东西。本档是为将来做闸门
136
+ * (形态 C)预留的语义缝,当前注入用途请保持 open。
137
+ */
138
+ autoFailMode: z.union(['open', 'closed']).default('open').volatile(),
105
139
  /** 自动决策超时(毫秒),超时即放行,绝不阻塞会话 */
106
140
  autoTimeoutMs: z.number().min(500).default(AUTO_DEFAULTS.autoTimeoutMs).volatile(),
107
141
  /** 捕获的历史消息条数(不含本轮新消息) */
@@ -114,6 +148,11 @@ const Config = z.object({
114
148
  autoRouteMinConfidence: z.number().min(0).max(1).default(AUTO_DEFAULTS.autoRouteMinConfidence).volatile(),
115
149
  /** 触发自动决策的最小输入长度(字符);低于该长度且整体是寒暄才跳过 */
116
150
  autoMinChars: z.number().min(0).default(AUTO_DEFAULTS.autoMinChars).volatile(),
151
+
152
+ /* ── 用量台账(volatile)────────────────────────────────────────────────── */
153
+
154
+ /** 用量台账文件路径;留空则用 `$DSH_HOME/dsh-systemone/usage.jsonl` */
155
+ usageLogPath: z.string().default('').volatile(),
117
156
  })
118
157
 
119
158
  export default class SystemOneService extends Service {
@@ -154,6 +193,15 @@ export default class SystemOneService extends Service {
154
193
  this._refreshScenarios()
155
194
  const scenarios = this.liveScenarios()
156
195
 
196
+ // 外发声明:装载时打印一行,之后 provider / redact 变化时再打印一次。
197
+ // 见 _announceEgress 的说明——state 会原样发往上游,这件事必须是可审计的。
198
+ this._egressStamp = null
199
+ this._announceEgress()
200
+
201
+ // 用量台账:工具路径与自动路径都记账(含路由请求),让花销可见。
202
+ // 台账写盘失败会降级为仅内存统计,绝不影响决策。
203
+ this.usageLedger = createUsageLedger({ path: readString(config, 'usageLogPath', ''), logger: ctx.logger })
204
+
157
205
  // inject 保证 ctx.tools 存在;注册失败必须显式抛出,绝不静默降级
158
206
  const tools = ctx.get('tools') ?? ctx.tools
159
207
  if (!tools || typeof tools.register !== 'function') {
@@ -163,6 +211,7 @@ export default class SystemOneService extends Service {
163
211
  // 惰性读取:场景库可能已随 customScenarios 热更新重建,
164
212
  // 这里必须每次取最新的 warnings,而不是装配期快照。
165
213
  warnings: () => this.scenarioWarnings || [],
214
+ usage: this.usageLedger,
166
215
  }).map((tool) => tools.register(tool))
167
216
 
168
217
  // 自动决策:默认关闭;开启后在每一步推理前自动捕获上下文并注入判断
@@ -174,6 +223,7 @@ export default class SystemOneService extends Service {
174
223
  config,
175
224
  scenarios,
176
225
  logger: ctx.logger,
226
+ usage: this.usageLedger,
177
227
  })
178
228
  } catch (error) {
179
229
  ctx.logger?.warn?.(`@master0071/dsh-systemone: 自动决策安装失败,工具仍可用:${error?.message || error}`)
@@ -187,6 +237,66 @@ export default class SystemOneService extends Service {
187
237
  )
188
238
  }
189
239
 
240
+ /**
241
+ * 外发声明:把「这次会不会联网、往哪发、发什么」打成一行可审计的日志。
242
+ *
243
+ * 为什么需要它:`state`(含对话历史与用户原文)会原样发往上游决策服务,
244
+ * 而 `redact` 默认是 **false**。生态里同类插件被审计出的最常见问题正是
245
+ * 「名为 guard/gate 的模块同时在往外发数据,而 README 不说」。一行启动声明
246
+ * 让这件事在日志里可见,而不是只藏在配置文档里。
247
+ *
248
+ * `provider` 与 `redact` 都是热更新字段,所以本方法在**值变化时**会再打一次;
249
+ * 相同状态不重复输出,避免每步刷日志。
250
+ */
251
+ _announceEgress() {
252
+ try {
253
+ const kind = readString(this.config, 'provider', 'unisound')
254
+ const redact = readBoolean(this.config, 'redact', false)
255
+ const stamp = `${kind}|${redact}`
256
+ if (stamp === this._egressStamp) return
257
+ this._egressStamp = stamp
258
+
259
+ const logger = this.ctx?.logger
260
+ if (kind === 'mock') {
261
+ logger?.info?.('@master0071/dsh-systemone[egress]: OFF — provider=mock,不会发起任何网络请求')
262
+ return
263
+ }
264
+
265
+ const budget = this._stateBudget()
266
+ // 尽量报告真实端点;解析失败(配错 URL 等)时退回配置原文,不让声明本身抛错
267
+ let target = ''
268
+ try {
269
+ target = this.currentProvider().resolveEndpoint()
270
+ } catch {
271
+ target = readString(this.config, 'endpoint', '') || readString(this.config, 'baseUrl', '')
272
+ }
273
+ const where = target || '(端点未配置)'
274
+
275
+ const redactLine = redact
276
+ ? `ON — 发送前把 ${redactRuleSummary()} 替换为 [类型] 标签(尽力而为:不在规则内的敏感串仍会外发)`
277
+ : `OFF — ${redactRuleSummary()} 等敏感内容**原样外发**,建议在处理含隐私数据的内容时开启 redact`
278
+
279
+ logger?.info?.(`@master0071/dsh-systemone[egress]: ON → ${where}`)
280
+ logger?.info?.(
281
+ `@master0071/dsh-systemone[egress]: SENDS state(<=${budget} 字符,含对话历史与用户原文) + questions`
282
+ + ` + model;redact=${redactLine}`,
283
+ )
284
+ } catch {
285
+ // 声明失败绝不能影响装配
286
+ }
287
+ }
288
+
289
+ /**
290
+ * state 的字符预算:工具路径截断到 MAX_TOOL_STATE_CHARS,自动决策路径拼装后可能更大。
291
+ * 声明里报一个保守上界,让「发出去多大」有个量级概念。
292
+ * 数字全部同源引用(tools.js / auto.js 的截断常量),改一处声明自动跟随。
293
+ */
294
+ _stateBudget() {
295
+ if (!readBoolean(this.config, 'autoDecide', false)) return MAX_TOOL_STATE_CHARS
296
+ const messages = readNumber(this.config, 'autoMaxMessages', 6)
297
+ return Math.max(MAX_TOOL_STATE_CHARS, MAX_CURRENT_CHARS + messages * HISTORY_CHARS_PER_MESSAGE)
298
+ }
299
+
190
300
  /**
191
301
  * 返回当前应当使用的提供商;`provider` 配置变化时按需重建。
192
302
  *
@@ -200,6 +310,10 @@ export default class SystemOneService extends Service {
200
310
  this._provider = createProvider(this.config)
201
311
  this._providerKind = kind
202
312
  }
313
+ // 所有请求路径(工具/自动决策/服务 decide)取提供商都经过这里:
314
+ // provider **或 redact** 任一变化(stamp 去重)即重发外发声明。
315
+ // 放在方法末尾,保证声明解析端点用的是(可能刚重建的)新提供商。
316
+ this._announceEgress()
203
317
  return this._provider
204
318
  }
205
319
 
package/lib/provider.js CHANGED
@@ -15,6 +15,8 @@
15
15
  * - "mock" :本地确定性模拟(无需 API Key,用于测试与演示)
16
16
  */
17
17
 
18
+ import { readFileSync } from 'node:fs'
19
+ import { join } from 'node:path'
18
20
  import { readBoolean, readNumber, readString } from './volatile.js'
19
21
 
20
22
  const DEFAULT_BASE_URL = 'https://maas-api.unisound.com/v1'
@@ -58,15 +60,25 @@ export class HttpProvider {
58
60
  return assertValidUrl(`${base}/systemone`)
59
61
  }
60
62
 
61
- /** 解析 API Key:配置优先,其次环境变量。 */
63
+ /**
64
+ * 解析 API Key,顺序为:插件配置明文 → 环境变量 → DSH 凭证缝引用。
65
+ *
66
+ * 推荐用最后一种(`apiKeyRef`):配置里只留一个引用名,密钥本体留在
67
+ * `$DSH_HOME/.credentials.yaml`,避免明文落进 profile 的 `cordis.patch.yml`。
68
+ * 三种来源都没有时给出可操作的报错。
69
+ */
62
70
  resolveApiKey() {
63
71
  const key = readString(this.config, 'apiKey', '')
64
72
  || process.env.UNISOUND_API_KEY
65
73
  || process.env.SYSTEMONE_API_KEY
74
+ || readCredentialRef(readString(this.config, 'apiKeyRef', ''))
66
75
  || ''
67
76
  if (!key) {
68
77
  throw new Error(
69
- 'SystemOne: 未配置 apiKey(请在插件配置中填写,或设置环境变量 UNISOUND_API_KEY / SYSTEMONE_API_KEY)',
78
+ 'SystemOne: 未配置凭证。按序尝试了:插件配置的 apiKey → 环境变量 UNISOUND_API_KEY / SYSTEMONE_API_KEY '
79
+ + `→ 凭证缝 ${credentialsPath()} 的 refs.<apiKeyRef>`
80
+ + `${readString(this.config, 'apiKeyRef', '') ? '' : '(apiKeyRef 留空,回退默认名 ' + DEFAULT_KEY_REFS.join(' / ') + ')'}。`
81
+ + '推荐在凭证文件里写 `refs: { SYSTEMONE_API_KEY: <你的 key> }` 并保持 apiKey 为空。',
70
82
  )
71
83
  }
72
84
  return key
@@ -218,8 +230,8 @@ function mockAnswer(id, q, seedSource) {
218
230
  }
219
231
  }
220
232
  case 'noul': {
221
- // 与真实响应对齐:noul 也带 confidence(取倾向一侧的概率),
222
- // 否则 mock 下 computeNeedsHumanReview 走不到置信度分支
233
+ // 与真实响应对齐:noul 也带 confidence(取倾向一侧的概率)。
234
+ // mock 的分布是合成的,**不代表真实模型行为**,只能用于契约与确定性回归。
223
235
  const v = Math.round((rand() * 0.6 + 0.2) * 1000) / 1000
224
236
  return { type: 'noul', noul: v, confidence: Math.max(v, 1 - v) }
225
237
  }
@@ -310,6 +322,93 @@ function redactValue(value, enabled) {
310
322
  return value
311
323
  }
312
324
 
325
+ /* ─── DSH 凭证缝读取 ───────────────────────────────────────────────────────── */
326
+
327
+ /** DSH 主目录:与用量台账同源(usage.js 导出)。 */
328
+ import { dshHome } from './usage.js'
329
+
330
+ /** 未配置 apiKeyRef 时依次尝试的内置引用名(覆盖 SystemOne 各家后端)。 */
331
+ const DEFAULT_KEY_REFS = ['SYSTEMONE_API_KEY', 'UNISOUND_API_KEY', 'TYPESAFE_API_KEY']
332
+
333
+ /** 凭证文件路径(用于报错文案与读取)。 */
334
+ function credentialsPath() {
335
+ return join(dshHome(), '.credentials.yaml')
336
+ }
337
+
338
+ /**
339
+ * 从凭证文件里取 `refs.<name>` 的值。
340
+ *
341
+ * 只解析 `refs:` 下的**单层标量**,不引入 YAML 依赖(插件对 `@deepseek-ai/*` 之外的
342
+ * 依赖保持为零)。解析要点:
343
+ * - 只认 `refs:` 之后、缩进更深的行,避免误取段外同名键;
344
+ * - 值两端的成对引号会被剥掉(`TYPESAFE_API_KEY: "sk-x"` 应得到 `sk-x`)——
345
+ * 这是同类实现常见的一个缺陷:直接把捕获组当值,会连引号一起塞进 Authorization 头;
346
+ * - 行内 ` #` 注释会被裁掉(仅对未加引号的值);
347
+ * - 多个候选名都在文件里时,**按 `wanted` 的顺序**返回,而不是按文件出现顺序。
348
+ *
349
+ * @param {string} name - 引用名;留空时依次尝试 DEFAULT_KEY_REFS
350
+ * @returns {string|undefined} 找到的凭证;找不到返回 undefined
351
+ */
352
+ function readCredentialRef(name) {
353
+ const wanted = name && String(name).trim() ? [String(name).trim()] : DEFAULT_KEY_REFS
354
+ let raw
355
+ try {
356
+ raw = readFileSync(credentialsPath(), 'utf8')
357
+ } catch {
358
+ return undefined // 文件不存在/不可读 → 视为未配置
359
+ }
360
+ const refs = new Map()
361
+ let inRefs = false
362
+ let refsIndent = -1
363
+ for (const line of raw.split(/\r?\n/)) {
364
+ if (line.trim() === '' || line.trimStart().startsWith('#')) continue
365
+ const indent = line.length - line.trimStart().length
366
+ if (!inRefs) {
367
+ if (/^refs\s*:\s*$/.test(line.trim())) {
368
+ inRefs = true
369
+ refsIndent = indent
370
+ }
371
+ continue
372
+ }
373
+ // 缩进不深于 refs: 即说明已离开该段
374
+ if (indent <= refsIndent) break
375
+ const match = /^\s*([^\s:#][^:]*?)\s*:\s*(.*)$/.exec(line)
376
+ if (!match) continue
377
+ const value = unquoteScalar(match[2]) ?? ''
378
+ if (value) refs.set(match[1].trim(), value)
379
+ }
380
+ // 按候选名给定的优先级取值(不是文件顺序)
381
+ for (const key of wanted) {
382
+ const hit = refs.get(key)
383
+ if (hit) return hit
384
+ }
385
+ return undefined
386
+ }
387
+
388
+ /** 剥掉标量两端成对的引号并裁掉未加引号值后的行内注释。 */
389
+ function unquoteScalar(raw) {
390
+ const trimmed = String(raw ?? '').trim()
391
+ if (trimmed === '') return ''
392
+ const first = trimmed[0]
393
+ if (first === '"' || first === "'") {
394
+ // 引号值:取到配对的收尾引号为止,引号内的 # 不算注释
395
+ const end = trimmed.indexOf(first, 1)
396
+ return end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)
397
+ }
398
+ const hash = trimmed.indexOf(' #')
399
+ return hash === -1 ? trimmed : trimmed.slice(0, hash).trim()
400
+ }
401
+
402
+ /* ─── 脱敏规则清单(供外发声明复用,避免文案与实现漂移)────────────────────── */
403
+
404
+ /** 规则的中文类型标签,按 REDACT_RULES 的顺序。 */
405
+ const REDACT_TAGS = REDACT_RULES.map((rule) => rule.tag.replace(/^\[|\]$/g, ''))
406
+
407
+ /** 脱敏规则的中文清单,形如 `手机号/身份证/邮箱/银行卡号`。 */
408
+ export function redactRuleSummary() {
409
+ return REDACT_TAGS.join('/')
410
+ }
411
+
313
412
  /* ─── 通用内部工具 ─────────────────────────────────────────────────────────── */
314
413
 
315
414
  /** URL 合法性在发请求前拦下:漏写协议时 fetch 的报错非常隐晦,不如直接说清楚。 */
@@ -329,8 +428,10 @@ function isRetryable(error) {
329
428
  }
330
429
 
331
430
  function sleep(ms) {
431
+ // ref 定时器(不能 unref):重试退避期间调用方正在 await decide(),
432
+ // 这个承诺必须落定。unref 会让无其他句柄的进程(如测试 runner)事件循环
433
+ // 提前抽干、承诺悬空,也可能在宿主退出时静默吞掉一次重试。
332
434
  return new Promise((resolve) => {
333
- const timer = setTimeout(resolve, ms)
334
- timer.unref?.()
435
+ setTimeout(resolve, ms)
335
436
  })
336
437
  }
package/lib/scenarios.js CHANGED
@@ -33,6 +33,17 @@
33
33
  * {qid?文案A|文案B} → 条件文案(noul 为真 / 分值≥1 / choice 非空非 none 时取 A)
34
34
  */
35
35
 
36
+ /**
37
+ * choice 题型的选项数上限。
38
+ *
39
+ * 单一真源:场景校验(validateScenario)与自动路由的分批逻辑都读这里。
40
+ * 上游决策模型对 choice 的选项数有硬限制,超过会被拒——因此不仅是"建议"。
41
+ */
42
+ export const MAX_CHOICE_OPTIONS = 26
43
+
44
+ /** choice 题型的选项数下限。 */
45
+ export const MIN_CHOICE_OPTIONS = 2
46
+
36
47
  /** 内置场景:11 个业务域,每个 3 个结构化问题。 */
37
48
  export const BUILTIN_SCENARIOS = [
38
49
  {
@@ -465,10 +476,10 @@ export function validateScenario(spec) {
465
476
  if (q.type === 'choice') {
466
477
  if (!q.criteria || typeof q.criteria !== 'object' || Array.isArray(q.criteria)) {
467
478
  problems.push(`问题 ${qid}(choice)的 criteria 必须是"选项 key → 说明"的对象`)
468
- } else if (Object.keys(q.criteria).length < 2) {
469
- problems.push(`问题 ${qid}(choice)至少需要 2 个选项`)
470
- } else if (Object.keys(q.criteria).length > 26) {
471
- problems.push(`问题 ${qid}(choice)选项建议不超过 26 个`)
479
+ } else if (Object.keys(q.criteria).length < MIN_CHOICE_OPTIONS) {
480
+ problems.push(`问题 ${qid}(choice)至少需要 ${MIN_CHOICE_OPTIONS} 个选项`)
481
+ } else if (Object.keys(q.criteria).length > MAX_CHOICE_OPTIONS) {
482
+ problems.push(`问题 ${qid}(choice)选项最多 ${MAX_CHOICE_OPTIONS} 个(当前 ${Object.keys(q.criteria).length} 个)`)
472
483
  }
473
484
  }
474
485
  if (q.type === 'score' && (!Array.isArray(q.criteria) || q.criteria.length < 2)) {