@master0071/dsh-systemone 0.4.3 → 0.5.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/README.md CHANGED
@@ -11,9 +11,9 @@ SystemOne 协议一次请求携带业务状态与**结构化问题**(`choice`
11
11
 
12
12
  ---
13
13
 
14
- ## 内置场景(10 大业务域)
14
+ ## 内置场景(11 大业务域)
15
15
 
16
- 每个场景 = 3 个结构化问题,覆盖 30 项业务能力。
16
+ 每个场景 = 3 个结构化问题,覆盖 33 项业务能力。
17
17
 
18
18
  | 场景 id | 业务域 | 覆盖能力 |
19
19
  | --- | --- | --- |
@@ -27,6 +27,7 @@ SystemOne 协议一次请求携带业务状态与**结构化问题**(`choice`
27
27
  | `education` | 教育内容 | 题目知识点归类、难度分级评分、内容合规预检 |
28
28
  | `requirements` | 需求与变更 | 优先级评分、变更风险评级、子任务派发 |
29
29
  | `software_dev` | **软件开发** | 任务类型(缺陷/功能/重构/评审/测试/文档/构建/性能)、改动复杂度、是否需先探查代码库 |
30
+ | `requirement_clarity` | **需求预检** | 需求明确度评分、最缺信息(目标/范围/验收/约束)、不澄清就推进的返工风险 |
30
31
 
31
32
  ### `software_dev`:给编码 Agent 用的软件开发场景
32
33
 
@@ -126,7 +127,7 @@ systemone_decide(
126
127
 
127
128
  - **提供商无关**:所有工具只依赖统一的 `provider.decide()` 接口。切换厂商只改一行配置,业务工具零改动。
128
129
  - **图形配置页**:侧栏「插件 → @master0071/dsh-systemone → systemone」里可直接改提供商、Key、模型与自动决策参数,保存即生效(volatile 热更新,无需重启)。
129
- - **场景可扩展**:内置 10 大场景(含软件开发),并可在配置页里用 JSON 追加或覆盖场景,**无需改代码、无需重启**
130
+ - **场景可扩展**:内置 11 大场景(含软件开发与需求预检),并可在配置页里用 JSON 追加或覆盖场景,**无需改代码、无需重启**
130
131
  - **可自动决策**:开启后挂 `agent/pre-step`,每一步推理前自动捕获上下文并注入判断(fail-open、硬超时、可缓存)。
131
132
  - **概率化输出**:返回每个选项的概率分布与置信度,而非单一答案。
132
133
  - **置信度兜底**:任一答案置信度低于 `minConfidence`(默认 0.6)时标记 `needs_human_review: true`。
@@ -318,6 +319,7 @@ const decision = await waterfall('agent/pre-step', { // ③ 再跑瀑布
318
319
  | `model` | `u2-decision` | 热更新 | 决策模型名 |
319
320
  | `timeoutMs` | `30000` | 热更新 | 请求超时(毫秒) |
320
321
  | `minConfidence` | `0.6` | 热更新 | 置信度阈值,低于则建议人工复核 |
322
+ | `redact` | `false` | 热更新 | 发送前对 `state` 脱敏:手机号/身份证/邮箱/银行卡号替换为 `[手机号]` 等类型标签(判断语义保留;处理含隐私数据的内容时建议开启) |
321
323
  | `customScenarios` | 空 | 热更新 | 自定义场景 JSON 数组(见下)。保存后**场景库立即重建**,无需重启 |
322
324
  | `autoDecide` | `false` | 需重启 | 是否开启自动决策(挂载 `agent/pre-step`) |
323
325
  | `auto*` | 见「自动决策」 | 热更新 | 自动决策的运行参数(钩子挂载后实时生效) |
@@ -334,6 +336,14 @@ const decision = await waterfall('agent/pre-step', { // ③ 再跑瀑布
334
336
 
335
337
  覆盖与别名的解析规则:
336
338
 
339
+ - **patch 式覆盖**:默认同 `id` 是**整体替换**。只想改一两个字段时,给条目加 `"patch": true` 即可做**字段级合并**——`title`/`description`/`recommendation` 给了才覆盖;`questions` 按 qid 合并(只换给出的字段,如只覆盖某个问题的 `criteria`,其余问题原样保留,新 qid 追加);`derive` 同名覆盖;`aliases` 求并集。例如给 `customer_service` 的部门问题加一个 VIP 通道:
340
+
341
+ ```json
342
+ [{ "id": "customer_service", "patch": true,
343
+ "questions": { "department": { "criteria": { "vip": "VIP 专属通道", "billing": "账单", "technical": "技术", "account": "账号", "logistics": "物流", "other": "其他" } } } }]
344
+ ```
345
+
346
+ patch 目标不存在、或合并结果非法(如 choice 选项少于 2 个)时该条目会被跳过并记入日志。不带 `patch` 的条目维持整体替换语义,已有配置不受影响。
337
347
  - **同 `id` 覆盖**:自定义场景替换同名内置场景,只写 `title`/`questions` 也够——被替换场景原有的 `aliases` 会被**继承**(省略 `aliases` 不等于清空别名),新声明的别名与继承的别名共存且自动去重。
338
348
  - **别名冲突**:别名与 `id` 平权参与查找,冲突时**后登记者获胜**(自定义场景优先于内置场景,自定义之间后声明者优先)。因此自定义场景声明的别名一定指向它自己,不会被内置场景抢走。
339
349
  - **冲突提示**:别名撞名是**非致命**的——场景照常可用,但插件会给出提示(宿主日志里带「(提示)」前缀,`systemone_scenario(action: "list")` 的 `warnings` 字段与「配置提示」小节里也会列出),说明谁遮蔽了谁,避免调用方把「别名被占」误判成「场景不存在」。
@@ -380,7 +390,7 @@ SystemOne 是「结构化问题 → 概率分布」的决策模型,**不是开
380
390
  ]
381
391
  ```
382
392
 
383
- 配合 `autoScenario: "intent"`,自动决策就不再受内置 10 个业务场景的约束。
393
+ 配合 `autoScenario: "intent"`,自动决策就不再受内置 11 个业务场景的约束。
384
394
 
385
395
  **容量上限**:每个场景最多 **16 个问题**(延迟随问题数线性增长),`choice` 的选项 **2~26 个**,所以意图标签集实践上限约 26 类——超了只能合并或落到 `other`。
386
396
 
@@ -473,7 +483,7 @@ SystemOne 是「结构化问题 → 概率分布」的决策模型,**不是开
473
483
  ```powershell
474
484
  npm run check # 语法检查
475
485
  npm run test # 59 项测试:
476
- # - 场景库完整性、10 大场景端到端、自定义场景合并/覆盖/非法跳过
486
+ # - 场景库完整性、11 大场景端到端、自定义场景合并/覆盖/patch/非法跳过
477
487
  # - params 覆盖语义、确定性、失败路径(无网络、无 Key)
478
488
  # - 插件入口装配(临时桩实例化:工具注册、降级、卸载)
479
489
  # - 自动决策(开关、注入形状、fail-open、硬超时、缓存、去重、
package/lib/auto.js CHANGED
@@ -64,6 +64,8 @@ export function installAutoDecide(ctx, deps) {
64
64
  if (!readBoolean(config, 'autoDecide', false)) return () => {}
65
65
 
66
66
  const disposers = []
67
+ /** agentId → 该 agent 的 per-agent 清理函数;agent 销毁时一并调用并从表中移除,避免无界增长。 */
68
+ const agentDisposers = new Map()
67
69
  /** agentId → { stateKey, text, scenarioId, source, headline, at }:供 context 通道渲染。 */
68
70
  const perAgent = new Map()
69
71
  /** agentId → 上次注入的决策文本:同一决策只注入一次,避免每步重复污染历史。 */
@@ -84,12 +86,23 @@ export function installAutoDecide(ctx, deps) {
84
86
  }
85
87
  }
86
88
 
87
- // agent 销毁时清理状态,避免长会话内存泄漏
89
+ // agent 销毁时清理状态与 per-agent 监听器,避免长会话内存泄漏
88
90
  disposers.push(ctx.on('agent/disposed', (payload) => {
89
91
  const id = payload?.agent?.id
90
92
  if (id !== undefined) {
91
93
  perAgent.delete(id)
92
94
  lastInjected.delete(id)
95
+ const list = agentDisposers.get(id)
96
+ if (list) {
97
+ for (const dispose of list) {
98
+ try {
99
+ dispose()
100
+ } catch {
101
+ // 卸载竞态
102
+ }
103
+ }
104
+ agentDisposers.delete(id)
105
+ }
93
106
  }
94
107
  }))
95
108
 
@@ -101,10 +114,13 @@ export function installAutoDecide(ctx, deps) {
101
114
  const agent = payload?.agent
102
115
  const agentCtx = agent?.ctx
103
116
  if (!agentCtx) return
117
+ const agentId = agent?.id
118
+ // 该 agent 的监听器与动态上下文注册统一记到这里,随 agent 销毁一起移除
119
+ const perAgentDisposers = []
104
120
  // ① 消息一入箱就异步预计算,让决策尽可能在 assembly 之前就绪
105
121
  if (typeof agentCtx.on === 'function') {
106
122
  try {
107
- disposers.push(agentCtx.on('agent/inbox/inserted', (event) => {
123
+ const dispose = agentCtx.on('agent/inbox/inserted', (event) => {
108
124
  if (readString(config, 'autoInject', 'message') !== 'context') return
109
125
  const newText = extractUserText([event?.message])
110
126
  if (!newText) return
@@ -112,7 +128,8 @@ export function installAutoDecide(ctx, deps) {
112
128
  if (isTrivialInput(newText, readNumber(config, 'autoMinChars', AUTO_DEFAULTS.autoMinChars))) return
113
129
  // 返回 promise 便于测试等待;宿主事件总线忽略返回值
114
130
  return computeAndStore(agent, newText)
115
- }))
131
+ })
132
+ if (typeof dispose === 'function') perAgentDisposers.push(dispose)
116
133
  } catch (error) {
117
134
  log('warn', `注册入箱预计算失败:${messageOf(error)}`)
118
135
  }
@@ -120,17 +137,36 @@ export function installAutoDecide(ctx, deps) {
120
137
  // ② 把最新决策渲染进作用域动态上下文
121
138
  if (agentCtx?.systemPrompt?.context) {
122
139
  try {
123
- disposers.push(agentCtx.systemPrompt.context({
140
+ const dispose = agentCtx.systemPrompt.context({
124
141
  name: 'systemone-auto-decision',
125
142
  order: 900,
126
143
  text: () => (readString(config, 'autoInject', 'message') === 'context')
127
144
  ? (perAgent.get(agent?.id)?.text || '')
128
145
  : '',
129
- }))
146
+ })
147
+ if (typeof dispose === 'function') perAgentDisposers.push(dispose)
130
148
  } catch (error) {
131
149
  log('warn', `注册动态上下文失败:${messageOf(error)}`)
132
150
  }
133
151
  }
152
+ // 记入 per-agent 表:agent 销毁时移除;同一 agent 重复创建时先清理旧条目。
153
+ // agentId 缺失时退回插件级 disposers,随插件卸载统一清理。
154
+ if (perAgentDisposers.length === 0) return
155
+ if (agentId === undefined) {
156
+ disposers.push(...perAgentDisposers)
157
+ return
158
+ }
159
+ const existing = agentDisposers.get(agentId)
160
+ if (existing) {
161
+ for (const dispose of existing) {
162
+ try {
163
+ dispose()
164
+ } catch {
165
+ // 重复创建竞态
166
+ }
167
+ }
168
+ }
169
+ agentDisposers.set(agentId, perAgentDisposers)
134
170
  }))
135
171
 
136
172
  // ── pre-step:同一步生效(仅 message 通道) ──
@@ -243,6 +279,17 @@ export function installAutoDecide(ctx, deps) {
243
279
  // 卸载竞态
244
280
  }
245
281
  }
282
+ // 兜底:即使宿主未触发 agent/disposed,也清掉所有 per-agent 监听器
283
+ for (const list of agentDisposers.values()) {
284
+ for (const dispose of list) {
285
+ try {
286
+ dispose()
287
+ } catch {
288
+ // 卸载竞态
289
+ }
290
+ }
291
+ }
292
+ agentDisposers.clear()
246
293
  perAgent.clear()
247
294
  lastInjected.clear()
248
295
  cache.clear()
package/lib/client.js CHANGED
@@ -42,7 +42,7 @@ window.__ModuleLoader__.load({
42
42
  tabCustom: '自定义场景',
43
43
  tabCustomHint: '用 JSON 定义自己的场景(含「通用意图识别」)。保存后场景库立即重建,无需重启。',
44
44
  customScenarios: '场景定义(JSON 数组)',
45
- customScenariosHint: '同 id 会覆盖内置场景;留空 [] 表示只用内置 10 个。每个场景最多 16 个问题,choice 选项 2~26 个。',
45
+ customScenariosHint: '同 id 默认整体覆盖内置场景;加 "patch": true 则只改给出的字段。留空 [] 表示只用内置 11 个。每个场景最多 16 个问题,choice 选项 2~26 个。',
46
46
  customScenariosPlaceholder: '[\n {\n "id": "intent",\n "title": "通用意图识别",\n "questions": {\n "intent": {\n "type": "choice",\n "instructions": "用户最想做什么?",\n "criteria": { "query": "查询", "action": "执行操作", "chat": "闲聊" }\n }\n }\n }\n]',
47
47
  insertTemplate: '插入意图识别模板',
48
48
  jsonEmpty: '(空 = 只用内置场景)',
@@ -72,8 +72,12 @@ window.__ModuleLoader__.load({
72
72
  timeoutMsHint: '单次决策请求的最长等待时间',
73
73
  minConfidence: '置信度阈值',
74
74
  minConfidenceHint: '任一答案低于该值时,结果会标记为建议人工复核(0~1)',
75
+ redact: '发送前脱敏',
76
+ redactHint: '开启后把 state 里的手机号/身份证/邮箱/银行卡号替换为 [类型] 标签再发给决策服务,判断语义保留',
77
+ redactOn: '开启(含隐私数据时建议)',
78
+ redactOff: '关闭',
75
79
  autoScenario: '固定场景(可选)',
76
- autoScenarioHint: '点右侧箭头可从 10 个内置场景里选,也可手填场景 id 或别名(如 software_dev / 开发);留空则每次自动选场景(多一次路由请求)',
80
+ autoScenarioHint: '点右侧箭头可从 11 个内置场景里选,也可手填场景 id 或别名(如 software_dev / 开发);留空则每次自动选场景(多一次路由请求)',
77
81
  autoInject: '注入通道',
78
82
  autoInjectHint: 'message=同一步生效;context=走宿主动态上下文,不写入会话历史',
79
83
  autoInjectMessage: '同步注入(message)',
@@ -112,7 +116,7 @@ window.__ModuleLoader__.load({
112
116
  tabCustom: 'Custom scenarios',
113
117
  tabCustomHint: 'Define your own scenarios in JSON (including general intent recognition). Saving rebuilds the library immediately.',
114
118
  customScenarios: 'Scenario definitions (JSON array)',
115
- customScenariosHint: 'An id matching a built-in overrides it; empty [] keeps only the 10 built-ins. Max 16 questions per scenario, 2~26 choice options.',
119
+ customScenariosHint: 'An id matching a built-in overrides it; add "patch": true to merge only the given fields. Empty [] keeps only the 11 built-ins. Max 16 questions per scenario, 2~26 choice options.',
116
120
  customScenariosPlaceholder: '[\n {\n "id": "intent",\n "title": "General intent",\n "questions": {\n "intent": {\n "type": "choice",\n "instructions": "What does the user want?",\n "criteria": { "query": "Look up", "action": "Do something", "chat": "Small talk" }\n }\n }\n }\n]',
117
121
  insertTemplate: 'Insert intent template',
118
122
  jsonEmpty: '(empty = built-in scenarios only)',
@@ -142,8 +146,12 @@ window.__ModuleLoader__.load({
142
146
  timeoutMsHint: 'Longest wait for one decision request',
143
147
  minConfidence: 'Confidence threshold',
144
148
  minConfidenceHint: 'Below this value a result is flagged for human review (0~1)',
149
+ redact: 'Redact before send',
150
+ redactHint: 'Replace phone/ID-card/email/bank-card numbers in state with [type] tags before sending to the decision service; semantics preserved',
151
+ redactOn: 'On (recommended with PII)',
152
+ redactOff: 'Off',
145
153
  autoScenario: 'Fixed scenario (optional)',
146
- autoScenarioHint: 'Pick one of the 10 built-in scenarios from the dropdown, or type a scenario id/alias (e.g. software_dev); empty routes automatically on every step (one extra request)',
154
+ autoScenarioHint: 'Pick one of the 11 built-in scenarios from the dropdown, or type a scenario id/alias (e.g. software_dev); empty routes automatically on every step (one extra request)',
147
155
  autoInject: 'Injection channel',
148
156
  autoInjectHint: 'message=applies in the same step; context=host dynamic context, not written to history',
149
157
  autoInjectMessage: 'Same step (message)',
@@ -268,7 +276,7 @@ window.__ModuleLoader__.load({
268
276
  /**
269
277
  * 内置场景候选(供「固定场景」输入框的下拉提示)。
270
278
  *
271
- * 用 `<datalist>` 而不是 `<select>`:既能把 10 个内置场景列出来点选,
279
+ * 用 `<datalist>` 而不是 `<select>`:既能把 11 个内置场景列出来点选,
272
280
  * 又允许手填自定义场景 id 或别名(别名见 README 的场景表)。
273
281
  * 这里只放 id + 中文标题;id 用别名也能命中(findScenario 会按别名匹配)。
274
282
  */
@@ -283,6 +291,7 @@ window.__ModuleLoader__.load({
283
291
  { id: 'education', label: '教育内容 · 知识点、难度与合规' },
284
292
  { id: 'requirements', label: '需求与变更 · 优先级、风险与派发' },
285
293
  { id: 'software_dev', label: '软件开发 · 任务类型、复杂度与处置' },
294
+ { id: 'requirement_clarity', label: '需求预检 · 明确度与澄清点' },
286
295
  ]
287
296
 
288
297
  const SERVICE_FIELDS = [
@@ -293,6 +302,7 @@ window.__ModuleLoader__.load({
293
302
  { key: 'model', kind: 'text' },
294
303
  { key: 'timeoutMs', kind: 'number' },
295
304
  { key: 'minConfidence', kind: 'number' },
305
+ { key: 'redact', kind: 'select', options: ['false', 'true'] },
296
306
  ]
297
307
 
298
308
  /** 自定义场景编辑区(JSON)。 */
@@ -328,35 +338,57 @@ window.__ModuleLoader__.load({
328
338
  return { ok: false, error: `item:${at}`, ids, count: 0 }
329
339
  }
330
340
  const issues = []
341
+ const isPatch = spec.patch === true
331
342
  if (typeof spec.id !== 'string' || spec.id.trim() === '') issues.push('缺少 id')
332
- if (typeof spec.title !== 'string' || spec.title.trim() === '') issues.push('缺少 title')
343
+ if (!isPatch && (typeof spec.title !== 'string' || spec.title.trim() === '')) issues.push('缺少 title')
333
344
  const questions = spec.questions
334
- if (!questions || typeof questions !== 'object' || Array.isArray(questions)) {
335
- issues.push('缺少 questions 对象')
336
- } else {
337
- const qids = Object.keys(questions)
338
- if (qids.length === 0) issues.push('questions 至少 1 个')
339
- if (qids.length > 16) issues.push(`questions 最多 16 个(当前 ${qids.length})`)
340
- for (const [qid, q] of Object.entries(questions)) {
341
- if (!q || typeof q !== 'object') { issues.push(`问题 ${qid} 必须是对象`); continue }
342
- if (!['choice', 'noul', 'score'].includes(q.type)) {
343
- issues.push(`问题 ${qid} 的 type 必须是 choice/noul/score`)
344
- continue
345
+ if (!isPatch || questions !== undefined) {
346
+ if (!questions || typeof questions !== 'object' || Array.isArray(questions)) {
347
+ issues.push(isPatch ? 'patch 的 questions 必须是对象' : '缺少 questions 对象')
348
+ } else if (isPatch) {
349
+ // patch 的问题允许省略 type/instructions/criteria(继承目标场景的同名字段),
350
+ // 深层合法性由宿主对「合并结果」统一校验;这里只拦立刻能发现的结构错误
351
+ for (const [qid, q] of Object.entries(questions)) {
352
+ if (qid === '__proto__' || qid === 'constructor' || qid === 'prototype') {
353
+ issues.push(`问题 id ${qid} 不能使用(与对象原型机制冲突),请换一个 id`)
354
+ continue
355
+ }
356
+ if (!q || typeof q !== 'object') { issues.push(`问题 ${qid} 必须是对象`); continue }
357
+ if (q.type !== undefined && !['choice', 'noul', 'score'].includes(q.type)) {
358
+ issues.push(`问题 ${qid} 的 type 必须是 choice/noul/score`)
359
+ }
345
360
  }
346
- if (typeof q.instructions !== 'string' || q.instructions.trim() === '') {
347
- issues.push(`问题 ${qid} 缺少 instructions`)
348
- }
349
- if (q.type === 'choice') {
350
- const keys = q.criteria && typeof q.criteria === 'object' && !Array.isArray(q.criteria)
351
- ? Object.keys(q.criteria)
352
- : null
353
- if (keys === null) issues.push(`问题 ${qid}(choice)需要 criteria 对象`)
354
- else if (keys.length < 2) issues.push(`问题 ${qid}(choice)至少 2 个选项`)
355
- else if (keys.length > 26) issues.push(`问题 ${qid}(choice)选项不能超过 26 个(当前 ${keys.length})`)
356
- }
357
- if (q.type === 'score'
358
- && (!Array.isArray(q.criteria) || q.criteria.length < 2)) {
359
- issues.push(`问题 ${qid}(score)criteria 需为长度 ≥2 的数组`)
361
+ } else {
362
+ const qids = Object.keys(questions)
363
+ if (qids.length === 0) issues.push('questions 至少 1 个')
364
+ if (qids.length > 16) issues.push(`questions 最多 16 个(当前 ${qids.length})`)
365
+ for (const [qid, q] of Object.entries(questions)) {
366
+ // 与宿主侧 validateScenario 对齐:这类键会被宿主拒绝,
367
+ // 这里不拦的话配置页会显示「JSON 有效」、场景却静默不生效
368
+ if (qid === '__proto__' || qid === 'constructor' || qid === 'prototype') {
369
+ issues.push(`问题 id ${qid} 不能使用(与对象原型机制冲突),请换一个 id`)
370
+ continue
371
+ }
372
+ if (!q || typeof q !== 'object') { issues.push(`问题 ${qid} 必须是对象`); continue }
373
+ if (!['choice', 'noul', 'score'].includes(q.type)) {
374
+ issues.push(`问题 ${qid} 的 type 必须是 choice/noul/score`)
375
+ continue
376
+ }
377
+ if (typeof q.instructions !== 'string' || q.instructions.trim() === '') {
378
+ issues.push(`问题 ${qid} 缺少 instructions`)
379
+ }
380
+ if (q.type === 'choice') {
381
+ const keys = q.criteria && typeof q.criteria === 'object' && !Array.isArray(q.criteria)
382
+ ? Object.keys(q.criteria)
383
+ : null
384
+ if (keys === null) issues.push(`问题 ${qid}(choice)需要 criteria 对象`)
385
+ else if (keys.length < 2) issues.push(`问题 ${qid}(choice)至少 2 个选项`)
386
+ else if (keys.length > 26) issues.push(`问题 ${qid}(choice)选项不能超过 26 个(当前 ${keys.length})`)
387
+ }
388
+ if (q.type === 'score'
389
+ && (!Array.isArray(q.criteria) || q.criteria.length < 2)) {
390
+ issues.push(`问题 ${qid}(score)criteria 需为长度 ≥2 的数组`)
391
+ }
360
392
  }
361
393
  }
362
394
  }
@@ -390,6 +422,9 @@ window.__ModuleLoader__.load({
390
422
  if (value === 'message') return t('autoInjectMessage')
391
423
  if (value === 'context') return t('autoInjectContext')
392
424
  }
425
+ if (key === 'redact') {
426
+ return value === 'true' ? t('redactOn') : t('redactOff')
427
+ }
393
428
  return value
394
429
  }
395
430
 
@@ -599,11 +634,11 @@ window.__ModuleLoader__.load({
599
634
  const dirty = draft !== null && Object.keys(draft).some((key) => draft[key] !== current[key])
600
635
  const canSave = dirty && !busy && writable && customCheck.ok && Object.keys(numberErrors).length === 0
601
636
 
602
- // 「固定场景」候选 = 10 个内置 + 当前生效的自定义场景 id
637
+ // 「固定场景」候选 = 11 个内置 + 当前生效的自定义场景 id(patch 条目指向已有 id,去重避免重复项)
603
638
  const scenarioOptions = [
604
639
  ...SCENARIO_OPTIONS,
605
640
  ...(customCheck.ok
606
- ? customCheck.ids
641
+ ? [...new Set(customCheck.ids)]
607
642
  .filter((id) => !SCENARIO_OPTIONS.some((option) => option.id === id))
608
643
  .map((id) => ({ id, label: id }))
609
644
  : []),
@@ -632,6 +667,8 @@ window.__ModuleLoader__.load({
632
667
  if (!Number.isFinite(parsed)) continue
633
668
  next = parsed
634
669
  }
670
+ // redact 下拉在草稿里是字符串,schema 要的是布尔
671
+ if (key === 'redact') next = value === 'true'
635
672
  ops.push({ op: 'set', path: [key], value: next })
636
673
  }
637
674
  if (ops.length === 0) {
@@ -834,6 +871,8 @@ window.__ModuleLoader__.load({
834
871
  exports.name = name
835
872
  exports.inject = inject
836
873
  exports.apply = apply
874
+ // 仅供测试:暴露纯函数(宿主只读 name/inject/apply,多余导出对宿主无影响)
875
+ exports.__internals = { validateCustomScenarios, describeCustomError }
837
876
  return module.exports
838
877
  },
839
878
  })
package/lib/index.d.ts CHANGED
@@ -19,6 +19,8 @@ export interface Config {
19
19
  timeoutMs?: number
20
20
  /** 置信度阈值 */
21
21
  minConfidence?: number
22
+ /** 发送前对 state 做内置脱敏(手机号/身份证/邮箱/银行卡号替换为类型标签) */
23
+ redact?: boolean
22
24
  /** 自定义场景:JSON 数组字符串 */
23
25
  customScenarios?: string
24
26
 
@@ -43,6 +45,9 @@ export interface Config {
43
45
  autoMinChars?: number
44
46
  }
45
47
 
48
+ /** 运行时导出的配置 schema(schemastery 对象),与上面的接口同名。 */
49
+ export const Config: any
50
+
46
51
  /** 编程式调用 SystemOne 的参数。 */
47
52
  export interface DecideOptions {
48
53
  state: unknown
package/lib/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * - 注册 2 个 Agent 工具:
8
8
  * systemone_scenario — 统一场景调度(list / describe / run)
9
9
  * systemone_decide — 自定义问题的低层入口
10
- * - 场景库内置 10 大业务域,并支持在配置里用 JSON 追加/覆盖场景
10
+ * - 场景库内置 11 大业务域,并支持在配置里用 JSON 追加/覆盖/patch 场景
11
11
  * - 暴露 `ctx.systemone.decide()` 供其他插件编程调用
12
12
  * - 提供商可配置(unisound / http / mock),切换厂商不影响工具层
13
13
  */
@@ -27,16 +27,29 @@ const KNOWN_PROVIDERS = ['unisound', 'http', 'mock']
27
27
  *
28
28
  * 工具与自动决策在装配期就拿到了这个对象并长期持有;有了它,
29
29
  * 配置页里切换 `provider` 后它们无需重新装配即可用上新提供商。
30
+ *
31
+ * 与 liveScenarios 同一原则:**不做 trap 白名单**,统一转发全部
32
+ * (对象目标上合法的)内部方法——白名单的毛病是「漏一个就静默拿不到
33
+ * 新实例的成员」。provider 将来新增 health()/close() 之类接口时,
34
+ * 走代理的调用方自动可见,不需要回来改这里。
35
+ * 注意 apply/construct 两个 trap 只对函数目标合法,provider 是普通对象,不适用。
30
36
  */
31
37
  function createProviderProxy(current) {
32
- return {
33
- get name() {
34
- return current().name
35
- },
36
- decide(options) {
37
- return current().decide(options)
38
- },
39
- }
38
+ // trap 的第一个参数是创建时固定的代理目标(占位空对象),必须丢弃、
39
+ // 换成「当前提供商实例」——转发全部 trap 参数会把占位对象错当成属性名
40
+ const forward = (name) => (_target, ...args) => Reflect[name](current(), ...args)
41
+ return new Proxy({}, {
42
+ get: forward('get'),
43
+ set: forward('set'),
44
+ has: forward('has'),
45
+ deleteProperty: forward('deleteProperty'),
46
+ ownKeys: forward('ownKeys'),
47
+ getOwnPropertyDescriptor: forward('getOwnPropertyDescriptor'),
48
+ defineProperty: forward('defineProperty'),
49
+ getPrototypeOf: forward('getPrototypeOf'),
50
+ setPrototypeOf: forward('setPrototypeOf'),
51
+ isExtensible: forward('isExtensible'),
52
+ })
40
53
  }
41
54
 
42
55
  /**
@@ -72,6 +85,12 @@ const Config = z.object({
72
85
  timeoutMs: z.number().min(1000).default(30000).volatile(),
73
86
  /** 置信度阈值:任一答案低于该值则建议人工复核 */
74
87
  minConfidence: z.number().min(0).max(1).default(0.6).volatile(),
88
+ /**
89
+ * 发送前对 state 做内置脱敏:手机号/身份证/邮箱/银行卡号替换为
90
+ * `[手机号]` 等类型标签(判断语义保留)。state 会发给上游决策服务,
91
+ * 处理含隐私数据的场景(尤其 data_governance)时建议开启。
92
+ */
93
+ redact: z.boolean().default(false).volatile(),
75
94
 
76
95
  /* ── 结构性字段:不在配置页里,改动需要重启 ───────────────────────────────── */
77
96
 
package/lib/provider.js CHANGED
@@ -15,10 +15,13 @@
15
15
  * - "mock" :本地确定性模拟(无需 API Key,用于测试与演示)
16
16
  */
17
17
 
18
- import { readNumber, readString } from './volatile.js'
18
+ import { readBoolean, readNumber, readString } from './volatile.js'
19
19
 
20
20
  const DEFAULT_BASE_URL = 'https://maas-api.unisound.com/v1'
21
21
  const DEFAULT_MODEL = 'u2-decision'
22
+ /** 临时故障(5xx/429/网络错误)自动重试次数与退避间隔;决策请求是幂等读,重发安全。 */
23
+ const RETRY_LIMIT = 1
24
+ const RETRY_DELAY_MS = 300
22
25
 
23
26
  /** 创建提供商实例。config 来自插件配置(已由 Config schema 校验)。未知 provider 值回退到 unisound(入口层负责告警)。 */
24
27
  export function createProvider(config) {
@@ -49,10 +52,10 @@ export class HttpProvider {
49
52
  if (!ep) {
50
53
  throw new Error('SystemOne: provider=http 需要配置 endpoint 或 baseUrl')
51
54
  }
52
- return ep
55
+ return assertValidUrl(ep)
53
56
  }
54
57
  const base = String(baseUrl || DEFAULT_BASE_URL).trim().replace(/\/+$/, '')
55
- return `${base}/systemone`
58
+ return assertValidUrl(`${base}/systemone`)
56
59
  }
57
60
 
58
61
  /** 解析 API Key:配置优先,其次环境变量。 */
@@ -78,44 +81,67 @@ export class HttpProvider {
78
81
  const endpoint = this.resolveEndpoint()
79
82
  const apiKey = this.resolveApiKey()
80
83
  const timeoutMs = readNumber(this.config, 'timeoutMs', 30000) || 30000
84
+ const body = JSON.stringify({
85
+ model: model || readString(this.config, 'model', DEFAULT_MODEL) || DEFAULT_MODEL,
86
+ state: redactValue(state, readBoolean(this.config, 'redact', false)),
87
+ questions,
88
+ })
81
89
 
82
90
  const controller = new AbortController()
83
91
  const timer = setTimeout(() => controller.abort(new Error(`SystemOne: 请求超时(${timeoutMs}ms)`)), timeoutMs)
84
92
  timer.unref?.()
85
93
  const onOuterAbort = () => controller.abort()
86
94
  if (signal && typeof signal.addEventListener === 'function') {
87
- signal.addEventListener('abort', onOuterAbort, { once: true })
95
+ if (signal.aborted) {
96
+ controller.abort()
97
+ } else {
98
+ signal.addEventListener('abort', onOuterAbort, { once: true })
99
+ }
88
100
  }
89
101
 
90
- try {
102
+ /** 单次请求:非 2xx 抛错并带上 status,供重试判定。 */
103
+ const sendOnce = async () => {
91
104
  const res = await fetch(endpoint, {
92
105
  method: 'POST',
93
106
  headers: {
94
107
  Authorization: `Bearer ${apiKey}`,
95
108
  'Content-Type': 'application/json',
96
109
  },
97
- body: JSON.stringify({
98
- model: model || readString(this.config, 'model', DEFAULT_MODEL) || DEFAULT_MODEL,
99
- state,
100
- questions,
101
- }),
110
+ body,
102
111
  signal: controller.signal,
103
112
  })
104
-
105
113
  const text = await res.text().catch(() => '')
106
114
  if (!res.ok) {
107
- throw new Error(
115
+ const error = new Error(
108
116
  `SystemOne: HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ''} — ${text.slice(0, 500) || '无响应体'}`,
109
117
  )
118
+ error.status = res.status
119
+ throw error
110
120
  }
111
-
112
- let data
113
121
  try {
114
- data = JSON.parse(text)
122
+ return JSON.parse(text)
115
123
  } catch {
116
124
  throw new Error(`SystemOne: 响应不是合法 JSON — ${text.slice(0, 300) || '空响应'}`)
117
125
  }
118
- return data
126
+ }
127
+
128
+ try {
129
+ let lastError
130
+ for (let attempt = 0; attempt <= RETRY_LIMIT; attempt += 1) {
131
+ if (attempt > 0) {
132
+ // 已中止(超时/外部取消)不再重试,让上一次的原始错误穿透
133
+ if (controller.signal.aborted) throw lastError
134
+ await sleep(RETRY_DELAY_MS)
135
+ }
136
+ try {
137
+ return await sendOnce()
138
+ } catch (error) {
139
+ lastError = error
140
+ if (attempt < RETRY_LIMIT && !controller.signal.aborted && isRetryable(error)) continue
141
+ throw error
142
+ }
143
+ }
144
+ throw lastError
119
145
  } finally {
120
146
  clearTimeout(timer)
121
147
  if (signal && typeof signal.removeEventListener === 'function') {
@@ -191,11 +217,12 @@ function mockAnswer(id, q, seedSource) {
191
217
  confidence: probabilities[top],
192
218
  }
193
219
  }
194
- case 'noul':
195
- return {
196
- type: 'noul',
197
- noul: Math.round((rand() * 0.6 + 0.2) * 1000) / 1000,
198
- }
220
+ case 'noul': {
221
+ // 与真实响应对齐:noul 也带 confidence(取倾向一侧的概率),
222
+ // 否则 mock 下 computeNeedsHumanReview 走不到置信度分支
223
+ const v = Math.round((rand() * 0.6 + 0.2) * 1000) / 1000
224
+ return { type: 'noul', noul: v, confidence: Math.max(v, 1 - v) }
225
+ }
199
226
  case 'score': {
200
227
  const list = Array.isArray(q.criteria) ? q.criteria : []
201
228
  const len = Math.max(list.length, 1)
@@ -243,4 +270,67 @@ function mulberry32(seed) {
243
270
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
244
271
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296
245
272
  }
273
+ }
274
+
275
+ /* ─── 敏感数据脱敏(redact=true 时,发送前对 state 生效) ──────────────────── */
276
+
277
+ /**
278
+ * 内置脱敏规则:手机号/身份证/邮箱/银行卡号替换为类型标签。
279
+ * 替换成标签(而不是抹成 ***)是有意的:决策模型仍能看出「这段内容
280
+ * 包含手机号」——对 data_governance 这类场景,这个信号正是判断依据。
281
+ * 顺序有意义:身份证(18 位)先于银行卡(13~19 位)匹配,
282
+ * 环视断言保证不会在更长的数字串内部截取匹配。
283
+ */
284
+ const REDACT_RULES = [
285
+ { pattern: /(?<![\dXx])\d{17}[\dXx](?![\dXx])/g, tag: '[身份证]' },
286
+ { pattern: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, tag: '[邮箱]' },
287
+ { pattern: /(?<!\d)1[3-9]\d{9}(?!\d)/g, tag: '[手机号]' },
288
+ { pattern: /(?<!\d)\d{13,19}(?!\d)/g, tag: '[银行卡号]' },
289
+ ]
290
+
291
+ function redactString(text) {
292
+ return REDACT_RULES.reduce((acc, rule) => acc.replace(rule.pattern, rule.tag), text)
293
+ }
294
+
295
+ /**
296
+ * 递归脱敏 state:字符串叶子做规则替换,对象/数组逐层下钻。
297
+ * 不修改原对象(调用方可能还要用),返回脱敏后的副本;非字符串叶子原样保留。
298
+ */
299
+ function redactValue(value, enabled) {
300
+ if (!enabled) return value
301
+ if (typeof value === 'string') return redactString(value)
302
+ if (Array.isArray(value)) return value.map((item) => redactValue(item, enabled))
303
+ if (value !== null && typeof value === 'object') {
304
+ const out = {}
305
+ for (const [key, item] of Object.entries(value)) {
306
+ out[key] = redactValue(item, enabled)
307
+ }
308
+ return out
309
+ }
310
+ return value
311
+ }
312
+
313
+ /* ─── 通用内部工具 ─────────────────────────────────────────────────────────── */
314
+
315
+ /** URL 合法性在发请求前拦下:漏写协议时 fetch 的报错非常隐晦,不如直接说清楚。 */
316
+ function assertValidUrl(url) {
317
+ try {
318
+ new URL(url)
319
+ } catch {
320
+ throw new Error(`SystemOne: 端点不是合法 URL:${url}(需以 http:// 或 https:// 开头)`)
321
+ }
322
+ return url
323
+ }
324
+
325
+ /** 仅临时故障值得重试:5xx、429 限流、网络层错误(fetch 失败抛 TypeError)。 */
326
+ function isRetryable(error) {
327
+ if (error instanceof TypeError) return true
328
+ return error?.status === 429 || (typeof error?.status === 'number' && error.status >= 500)
329
+ }
330
+
331
+ function sleep(ms) {
332
+ return new Promise((resolve) => {
333
+ const timer = setTimeout(resolve, ms)
334
+ timer.unref?.()
335
+ })
246
336
  }
package/lib/scenarios.js CHANGED
@@ -7,10 +7,10 @@
7
7
  * 场景定义格式:
8
8
  * {
9
9
  * id: string, // 唯一标识(必填)
10
- * title: string, // 展示名(必填)
10
+ * title: string, // 展示名(必填;patch 条目可省略,继承目标场景)
11
11
  * description?: string, // 场景说明
12
12
  * aliases?: string[], // 别名,便于模型用中文名/英文名查找
13
- * questions: { // 问题映射(必填,至少 1 个)
13
+ * questions: { // 问题映射(必填,至少 1 个;patch 条目可省略)
14
14
  * [qid]: {
15
15
  * type: 'choice' | 'noul' | 'score',
16
16
  * instructions: string, // 给模型的问题描述
@@ -22,6 +22,7 @@
22
22
  * [name]: { question: string, values?: string[], map?: object }
23
23
  * },
24
24
  * recommendation?: string, // 可选建议模板
25
+ * patch?: boolean, // true=与同 id 已有场景做字段级合并(只覆盖给出的字段),默认整体替换
25
26
  * }
26
27
  *
27
28
  * 建议模板语法:
@@ -32,7 +33,7 @@
32
33
  * {qid?文案A|文案B} → 条件文案(noul 为真 / 分值≥1 / choice 非空非 none 时取 A)
33
34
  */
34
35
 
35
- /** 内置场景:10 个业务域,每个 3 个结构化问题。 */
36
+ /** 内置场景:11 个业务域,每个 3 个结构化问题。 */
36
37
  export const BUILTIN_SCENARIOS = [
37
38
  {
38
39
  id: 'customer_service',
@@ -384,6 +385,44 @@ export const BUILTIN_SCENARIOS = [
384
385
  derive: { effort: { question: 'complexity', values: ['S', 'M', 'L', 'XL'] } },
385
386
  recommendation: '任务类型「{task_type}」,改动规模 {effort}。{needs_context?建议先检索代码库并确认相关实现|可直接动手}',
386
387
  },
388
+ {
389
+ id: 'requirement_clarity',
390
+ title: '需求预检 · 明确度与澄清点',
391
+ description: '接到开发需求先做结构化预检:判断明确程度、最缺什么信息、不澄清就推进的返工风险。',
392
+ // 别名刻意避开 requirements 已占用的 requirement / 需求 / 变更管理
393
+ aliases: ['clarity', '预检', '需求预检'],
394
+ questions: {
395
+ clarity: {
396
+ type: 'score',
397
+ label: '明确度',
398
+ instructions: '该开发任务的需求明确程度是?',
399
+ criteria: [
400
+ '模糊:目标都不清楚',
401
+ '偏低:目标清楚但范围或关键约束不明',
402
+ '较高:目标范围清楚,细节可自行决策',
403
+ '明确:目标、范围、验收都清楚',
404
+ ],
405
+ },
406
+ missing: {
407
+ type: 'choice',
408
+ label: '缺失项',
409
+ instructions: '当前最需要澄清的信息是?',
410
+ criteria: {
411
+ goal: '目标/预期结果',
412
+ scope: '范围与边界',
413
+ acceptance: '验收标准',
414
+ constraint: '技术约束或依赖',
415
+ none: '无明显缺失',
416
+ },
417
+ },
418
+ proceed: {
419
+ type: 'noul',
420
+ label: '可直接推进',
421
+ instructions: '不做额外澄清就推进,返工风险是否可接受?',
422
+ },
423
+ },
424
+ recommendation: '明确度 {clarity},最缺 {missing}。{proceed?可直接推进|建议先澄清目标再动手}',
425
+ },
387
426
  ]
388
427
 
389
428
  const VALID_TYPES = new Set(['choice', 'noul', 'score'])
@@ -488,12 +527,24 @@ export function resolveScenarios(customJson) {
488
527
  })
489
528
 
490
529
  parsed.forEach((spec, i) => {
491
- const issues = validateScenario(spec)
530
+ let candidate = spec
531
+ // patch 条目:先与同 id 已有场景做字段级合并,再走与整体替换完全相同的
532
+ // 校验/规范化/别名继承路径——patch 与替换的唯一区别就在这一步合并。
533
+ if (spec && typeof spec === 'object' && !Array.isArray(spec) && spec.patch === true) {
534
+ const id = typeof spec.id === 'string' ? spec.id.trim() : ''
535
+ const key = id.toLowerCase()
536
+ if (!id || !indexById.has(key)) {
537
+ problems.push(`自定义场景 #${i + 1} 已跳过:patch 目标 "${id || '(未提供)'}" 不在场景库中(patch 只能修改已有场景)`)
538
+ return
539
+ }
540
+ candidate = mergeScenario(scenarios[indexById.get(key)], spec)
541
+ }
542
+ const issues = validateScenario(candidate)
492
543
  if (issues.length > 0) {
493
544
  problems.push(`自定义场景 #${i + 1} 已跳过:${issues.join(';')}`)
494
545
  return
495
546
  }
496
- const entry = normalizeScenario(spec)
547
+ const entry = normalizeScenario(candidate)
497
548
  const key = entry.id.toLowerCase()
498
549
 
499
550
  // 别名撞名是一条提示:声明者获胜(后写优先),被遮蔽者从此无法通过该别名查到。
@@ -537,6 +588,47 @@ export function resolveScenarios(customJson) {
537
588
  return { scenarios, problems, warnings }
538
589
  }
539
590
 
591
+ /**
592
+ * 把 patch 条目与已有场景做字段级合并,产出「完整场景定义」形状的对象:
593
+ * - title/description/recommendation:patch 给了才覆盖,否则保留目标的值;
594
+ * - questions:按 qid 合并——patch 里给出的字段(instructions/label/criteria)
595
+ * 覆盖目标同名问题的对应字段,其余问题原样保留,新 qid 直接追加;
596
+ * - derive:同名派生覆盖,其余保留;
597
+ * - aliases:目标与 patch 求并集(与整体替换的继承规则一致)。
598
+ *
599
+ * 返回值随后会重新过 validateScenario:patch 把合法问题改非法(如 criteria
600
+ * 少于 2 个选项)在校验处被拦下,而不是静默产出坏场景。
601
+ */
602
+ function mergeScenario(target, spec) {
603
+ // Object.create(null):patch 里的 __proto__ 这类键只会长成普通属性,
604
+ // 由 validateScenario 的 isUnsafeKey 统一拦下,不会走到原型 setter
605
+ const questions = Object.create(null)
606
+ for (const [qid, question] of Object.entries(target.questions || {})) {
607
+ questions[qid] = question
608
+ }
609
+ for (const [qid, patch] of Object.entries(spec.questions || {})) {
610
+ questions[qid] = patch && typeof patch === 'object'
611
+ ? { ...(questions[qid] || {}), ...patch }
612
+ : patch
613
+ }
614
+ return {
615
+ id: target.id,
616
+ title: typeof spec.title === 'string' && spec.title.trim() ? spec.title : target.title,
617
+ description: typeof spec.description === 'string' ? spec.description : target.description,
618
+ aliases: [
619
+ ...(target.aliases || []),
620
+ ...(Array.isArray(spec.aliases) ? spec.aliases : []),
621
+ ],
622
+ questions,
623
+ derive: {
624
+ ...(target.derive || {}),
625
+ ...(spec.derive && typeof spec.derive === 'object' && !Array.isArray(spec.derive) ? spec.derive : {}),
626
+ },
627
+ recommendation: typeof spec.recommendation === 'string' ? spec.recommendation : target.recommendation,
628
+ minConfidence: typeof spec.minConfidence === 'number' ? spec.minConfidence : target.minConfidence,
629
+ }
630
+ }
631
+
540
632
  /**
541
633
  * 规范化场景定义,补齐可选字段。
542
634
  *
package/lib/tools.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * 1. systemone_scenario — action: list / describe / run,覆盖全部内置与自定义场景
6
6
  * 2. systemone_decide — 直接提交自定义 questions 的低层入口
7
7
  *
8
- * 场景全部来自 scenarios.js 的场景库(内置 10 个 + 配置追加),新增场景无需改代码。
8
+ * 场景全部来自 scenarios.js 的场景库(内置 11 个 + 配置追加),新增场景无需改代码。
9
9
  */
10
10
  import {
11
11
  computeNeedsHumanReview,
@@ -20,6 +20,36 @@ import { readNumber, readString } from './volatile.js'
20
20
 
21
21
  const DEFAULT_MIN_CONFIDENCE = 0.6
22
22
 
23
+ /**
24
+ * 工具入参 state 的硬上限。自动决策路径有自己的截断(见 auto.js 的
25
+ * MAX_CURRENT_CHARS / 每条历史 2000 字),这里只防工具路径被塞进超大输入
26
+ * 直接打到上游 API;超长内容对决策质量也没有增量。
27
+ */
28
+ const MAX_TOOL_STATE_CHARS = 16000
29
+
30
+ /**
31
+ * 给 state 套硬上限:超长字符串截断;对象/数组按序列化长度衡量,
32
+ * 超限降级为截断后的 JSON 文本(决策模型反正是按文本理解的)。
33
+ * undefined/null/空字符串原样返回,让「缺少 state」的校验路径保持不变。
34
+ */
35
+ function capState(state) {
36
+ if (state === undefined || state === null) return state
37
+ if (typeof state === 'string') {
38
+ return state.length <= MAX_TOOL_STATE_CHARS
39
+ ? state
40
+ : `${state.slice(0, MAX_TOOL_STATE_CHARS)}…(已截断,原 ${state.length} 字符)`
41
+ }
42
+ let serialized
43
+ try {
44
+ serialized = JSON.stringify(state)
45
+ } catch {
46
+ // 循环引用等无法序列化的结构:原样交给下游,由序列化失败的报错路径兜底
47
+ return state
48
+ }
49
+ if (serialized.length <= MAX_TOOL_STATE_CHARS) return state
50
+ return `${serialized.slice(0, MAX_TOOL_STATE_CHARS)}…(已截断,原 ${serialized.length} 字符)`
51
+ }
52
+
23
53
  /**
24
54
  * 创建全部工具定义。
25
55
  *
@@ -45,7 +75,7 @@ function scenarioTool(provider, config, scenarios, hooks = {}) {
45
75
  description: [
46
76
  'SystemOne 场景决策:把业务状态与预置的结构化问题一次性提交给决策模型,返回带概率分布的判断与处置建议。',
47
77
  'action=list 列出全部场景;action=describe 查看某个场景的问题定义与可覆盖参数;action=run 执行决策。',
48
- '内置 10 大业务场景,可用 params 覆盖问题定义。',
78
+ '内置 11 大业务场景,可用 params 覆盖问题定义。',
49
79
  ].join(' '),
50
80
  parameters: {
51
81
  type: 'object',
@@ -93,7 +123,7 @@ function scenarioTool(provider, config, scenarios, hooks = {}) {
93
123
  config,
94
124
  scenarios,
95
125
  scenario: args.scenario,
96
- state: args.state,
126
+ state: capState(args.state),
97
127
  params: args.params,
98
128
  exec,
99
129
  })
@@ -296,9 +326,12 @@ function applyParams(questions, params) {
296
326
  // criteria 整体替换;addCriteria 在替换结果之上追加/覆盖单个选项
297
327
  if (override.criteria) {
298
328
  if (question.type === 'choice' && !Array.isArray(override.criteria)) {
299
- next.criteria = Object.fromEntries(
300
- Object.entries(override.criteria).map(([k, v]) => [String(k), String(v)]),
301
- )
329
+ const entries = Object.entries(override.criteria)
330
+ if (entries.length < 2) {
331
+ ignored.push(`${qid}.criteria(choice 至少需要 2 个选项)`)
332
+ } else {
333
+ next.criteria = Object.fromEntries(entries.map(([k, v]) => [String(k), String(v)]))
334
+ }
302
335
  } else if (question.type === 'score' && Array.isArray(override.criteria) && override.criteria.length >= 2) {
303
336
  next.criteria = override.criteria.map((v) => String(v))
304
337
  } else {
@@ -364,7 +397,7 @@ function decideTool(provider, config) {
364
397
  return executeDecision({
365
398
  title: 'SystemOne 通用决策',
366
399
  scenarioId: null,
367
- state: args.state,
400
+ state: capState(args.state),
368
401
  questions: args.questions || {},
369
402
  provider,
370
403
  config,
package/lib/volatile.js CHANGED
@@ -36,9 +36,11 @@ export function readString(config, key, fallback = '') {
36
36
  return text === '' ? fallback : text
37
37
  }
38
38
 
39
- /** 读取数值配置(去壳 + 非法值兜底)。 */
39
+ /** 读取数值配置(去壳 + 空值/非法值兜底)。 */
40
40
  export function readNumber(config, key, fallback) {
41
- const value = Number(unwrap(config?.[key]))
41
+ const raw = unwrap(config?.[key])
42
+ if (raw === undefined || raw === null || raw === '') return fallback
43
+ const value = Number(raw)
42
44
  return Number.isFinite(value) ? value : fallback
43
45
  }
44
46
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@master0071/dsh-systemone",
3
- "version": "0.4.3",
4
- "description": "DeepSeek Harness 插件:将 SystemOne 决策模型(默认云知声 u2-decision,可切换提供商)接入软件开发、客服、内容审核、路由、销售线索、风控、招聘、数据治理、教育、需求变更等 10 大业务场景,为 Agent 返回带概率分布的决策与处置建议。",
3
+ "version": "0.5.0",
4
+ "description": "DeepSeek Harness 插件:将 SystemOne 决策模型(默认云知声 u2-decision,可切换提供商)接入软件开发、需求预检、客服、内容审核、路由、销售线索、风控、招聘、数据治理、教育、需求变更等 11 大业务场景,为 Agent 返回带概率分布的决策与处置建议。",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "repository": {